From 549f57b8509137d6eca2bf7413ff3fc366e1970e Mon Sep 17 00:00:00 2001 From: Yusuke Tsutsumi Date: Sat, 18 Oct 2025 12:17:40 -0700 Subject: [PATCH] feat(301): add undelete pattern, replace soft-delete The current soft-delete pattern has the undesirable side effect of adding special-casing and additional query parameters to the standard methods, as well as possibly impacting their behavior. Introducing undelete, which is a resource oriented solution that achieves much of the same user journeys: - Restoring resources after being deleted. - Listing / getting deleted resources to figure out what is restorable. fixes #111. --- aep/general/0164/aep.md.j2 | 3 ++ aep/general/0164/aep.yaml | 2 +- aep/general/0301/aep.md.j2 | 86 +++++++++++++++++++++++++++++++++ aep/general/0301/aep.yaml | 8 +++ aep/general/example.oas.yaml | 88 +++++++++++++++++++++++++++++++++ aep/general/example.proto | 94 ++++++++++++++++++++++++++++++++++++ scripts/build.sh | 3 +- 7 files changed, 282 insertions(+), 2 deletions(-) create mode 100644 aep/general/0301/aep.md.j2 create mode 100644 aep/general/0301/aep.yaml diff --git a/aep/general/0164/aep.md.j2 b/aep/general/0164/aep.md.j2 index 01abdc88..04fc4e7b 100644 --- a/aep/general/0164/aep.md.j2 +++ b/aep/general/0164/aep.md.j2 @@ -1,5 +1,8 @@ # Soft delete +**NOTE: this pattern is now deprecated, and is no longer valid. Please use +[undelete](/undelete) instead.** + There are several reasons why a client could desire soft delete and undelete functionality, but one over-arching reason stands out: recovery from mistakes. A service that supports undelete makes it possible for users to recover diff --git a/aep/general/0164/aep.yaml b/aep/general/0164/aep.yaml index 800f446b..81ae8ae6 100644 --- a/aep/general/0164/aep.yaml +++ b/aep/general/0164/aep.yaml @@ -1,6 +1,6 @@ --- id: 164 -state: approved +state: deprecated slug: soft-delete created: 2020-10-06 placement: diff --git a/aep/general/0301/aep.md.j2 b/aep/general/0301/aep.md.j2 new file mode 100644 index 00000000..126eaa2f --- /dev/null +++ b/aep/general/0301/aep.md.j2 @@ -0,0 +1,86 @@ +# Undelete + +There are several reasons why a client could desire undelete functionality, but +one over-arching reason stands out: recovery from mistakes. A service that +supports undelete makes it possible for users to recover resources that were +deleted by accident. + +## Guidance + +Services **may** support the ability to "undelete", to allow for situations +where users mistakenly delete resources and need the ability to recover. + +These resources **must** be stored in a separate sibling collection, prefixed +with `deleted-`. (e.g. `deleted-books`). Resources deleted will remain in this +sibling collection until they expire, or until they are undeleted into the +original collection. + +Resources that support soft delete **should** have an `expire_time` field on +the deleted version of the resource, as described in AEP-148. + +### Sibling collection + +To implement the undelete pattern, a sibling collection, +`deleted-{resource_plural}`, **should** be created where all undeletable +resources can be listed and retrieved, as well as undeleted via the `Undelete` +custom method. + +### Undelete + +The `Undelete` custom method **should** be available. A successful call to this +method will: + +1. remove the resource from the deleted collection. +2. restore the resource back into the original collection. + +{% tab proto %} + +{% sample '../example.proto', 'rpc UndeleteDeletedPublisher', 'message UndeleteDeletedPublisherRequest' %} + +- The HTTP method **must** be `POST`. +- The `body` clause **must** be `"*"`. +- The response **may** include the fully-populated resource or an empty + response. +- A `path` field **must** be included in the request message; it **should** be + called `path`. + - The field **should** be [annotated as required][aep-203]. + - The field **should** identify the [resource type][aep-4] that it + references. + - The comment for the field **should** document the resource pattern. +- The request message **must not** contain any other required fields, and + **should not** contain other optional fields except those described in this + or another AEP. + +{% tab oas %} + +{% sample '../example.oas.yaml', '$.paths./deleted-publishers/{deleted_publisher_id}:undelete' %} + +- The HTTP method **must** be `POST`. +- The response message **must** be the resource itself. + - The response **may** include the fully-populated resource or an empty + response. +- The operation **must not** require any other fields, and **should not** + contain other optional query parameters except those described in this or + another AEP. + +{% endtabs %} + +### Long-running undelete + +Some resources take longer to undelete a resource than is reasonable for a +regular API request. In this situation, the API **should** follow the +long-running request pattern AEP-151. + +### Errors + +If the user calling `Undelete` has proper permission, but the requested +resource is not deleted, the service **must** error with `409 Conflict`. + +For additional guiance, see [errors](/errors). + +## Further reading + +- For the `Delete` standard method, see AEP-135. +- For long-running operations, see AEP-151. +- For resource freshness validation (`etag`), see AEP-154. +- For change validation (`validate_only`), see AEP-163. diff --git a/aep/general/0301/aep.yaml b/aep/general/0301/aep.yaml new file mode 100644 index 00000000..6406f383 --- /dev/null +++ b/aep/general/0301/aep.yaml @@ -0,0 +1,8 @@ +--- +id: 301 +state: approved +slug: undelete +created: 2020-10-06 +placement: + category: design-patterns + order: 95 diff --git a/aep/general/example.oas.yaml b/aep/general/example.oas.yaml index 8a662064..df47b6cb 100644 --- a/aep/general/example.oas.yaml +++ b/aep/general/example.oas.yaml @@ -62,6 +62,27 @@ components: - publishers/{publisher_id}/books/{book_id}/editions/{book_edition_id} plural: book-editions singular: book-edition + deleted_publisher: + properties: + description: + type: string + expire_time: + description: + The time when this deleted publisher will expire and be permanently + removed + format: date-time + type: string + path: + description: + The server-assigned path of the resource, which is unique within + the service. + type: string + type: object + x-aep-resource: + patterns: + - deleted-publishers/{deleted_publisher_id} + plural: deleted_publishers + singular: deleted_publisher isbn: properties: path: @@ -144,6 +165,73 @@ info: version: version not set openapi: 3.1.0 paths: + /deleted-publishers: + get: + description: List method for deleted_publisher + operationId: ListDeletedPublisher + parameters: + - in: query + name: max_page_size + schema: + type: integer + - in: query + name: page_token + schema: + type: string + responses: + '200': + content: + application/json: + schema: + properties: + next_page_token: + type: string + results: + items: + $ref: '#/components/schemas/deleted_publisher' + type: array + type: object + description: Successful response + /deleted-publishers/{deleted_publisher_id}: + get: + description: Get method for deleted_publisher + operationId: GetDeletedPublisher + parameters: + - in: path + name: deleted_publisher_id + required: true + schema: + type: string + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/deleted_publisher' + description: Successful response + /deleted-publishers/{deleted_publisher_id}:undelete: + post: + description: Custom method undelete for deleted_publisher + operationId: :UndeleteDeletedPublisher + parameters: + - in: path + name: deleted_publisher_id + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + type: object + required: true + responses: + '200': + content: + application/json: + schema: + type: object + description: Successful response /isbns: get: description: List method for isbn diff --git a/aep/general/example.proto b/aep/general/example.proto index e51b52f3..c364c2ee 100644 --- a/aep/general/example.proto +++ b/aep/general/example.proto @@ -106,6 +106,28 @@ service Bookstore { option (google.api.method_signature) = "parent"; } + // An aep-compliant Get method for deleted_publisher. + rpc GetDeletedPublisher(GetDeletedPublisherRequest) returns (DeletedPublisher) { + option (google.api.http) = {get: "/{path=deleted-publishers/*}"}; + + option (google.api.method_signature) = "path"; + } + + // An aep-compliant List method for deleted_publishers. + rpc ListDeletedPublishers(ListDeletedPublishersRequest) returns (ListDeletedPublishersResponse) { + option (google.api.http) = {get: "/deleted_publishers"}; + + option (google.api.method_signature) = "parent"; + } + + // undelete a deleted_publisher. + rpc UndeleteDeletedPublisher(UndeleteDeletedPublisherRequest) returns (UndeleteDeletedPublisherResponse) { + option (google.api.http) = { + post: "/{path=deleted-publishers/*}:undelete" + body: "*" + }; + } + // An aep-compliant Create method for isbn. rpc CreateIsbn(CreateIsbnRequest) returns (Isbn) { option (google.api.http) = { @@ -351,6 +373,25 @@ message BookEdition { string path = 10018; } +// A DeletedPublisher. +message DeletedPublisher { + option (google.api.resource) = { + type: "bookstore.example.com/deleted_publisher" + pattern: ["deleted-publishers/{deleted_publisher_id}"] + plural: "deleted_publishers" + singular: "deleted_publisher" + }; + + // Field for description. + string description = 1; + + // Field for expire_time. + string expire_time = 2 [json_name = "expire_time"]; + + // Field for path. + string path = 10018; +} + // A Isbn. message Isbn { option (google.api.resource) = { @@ -650,6 +691,59 @@ message ListBookEditionsResponse { string next_page_token = 10011 [json_name = "next_page_token"]; } +// Request message for the Getdeleted_publisher method +message GetDeletedPublisherRequest { + // The globally unique identifier for the resource + string path = 10018 [ + (aep.api.field_info) = { + resource_reference: ["bookstore.example.com/deleted_publisher"] + field_behavior: [FIELD_BEHAVIOR_REQUIRED] + }, + (google.api.field_behavior) = REQUIRED + ]; +} + +// Request message for the Listdeleted_publisher method +message ListDeletedPublishersRequest { + // A field for the parent of deleted_publisher + string parent = 10013 [ + (aep.api.field_info) = { + field_behavior: [FIELD_BEHAVIOR_REQUIRED] + }, + (google.api.field_behavior) = REQUIRED + ]; + + // The page token indicating the starting point of the page + string page_token = 10010 [json_name = "page_token"]; + + // The maximum number of resources to return in a single page. + int32 max_page_size = 10017 [json_name = "max_page_size"]; +} + +// Response message for the Listdeleted_publisher method +message ListDeletedPublishersResponse { + // A list of deleted_publishers + repeated DeletedPublisher results = 10016; + + // The page token indicating the ending point of this response. + string next_page_token = 10011 [json_name = "next_page_token"]; +} + +// Response message for the undelete method +message UndeleteDeletedPublisherResponse {} + +// Request message for the undelete method +message UndeleteDeletedPublisherRequest { + // The globally unique identifier for the resource + string path = 10018 [ + (aep.api.field_info) = { + resource_reference: ["bookstore.example.com/deleted_publisher"] + field_behavior: [FIELD_BEHAVIOR_REQUIRED] + }, + (google.api.field_behavior) = REQUIRED + ]; +} + // A Create request for a isbn resource. message CreateIsbnRequest { // A field for the parent of isbn diff --git a/scripts/build.sh b/scripts/build.sh index e0a31aa5..01ce028f 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -21,7 +21,8 @@ cd "${SG_DIRECTORY}" || exit mkdir -p src/content/docs/tooling/linter/rules mkdir -p src/content/docs/tooling/openapi-linter/rules mkdir -p src/content/docs/tooling/website +npm install @playwright/test@latest npm install -npx playwright install --with-deps chromium +npx playwright install npm run generate npm run build