Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: AIP-162 – Resource revisions #35

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion aip/general/0131/get.oas.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ paths:
/publishers/{publisherId}/books/{bookId}:
get:
operationId: getBook
description: Get a single book.
description: Retrieve a single book.
responses:
200:
description: OK
Expand Down
2 changes: 1 addition & 1 deletion aip/general/0131/get.proto
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import "google/api/field_behavior.proto";
import "google/api/resource.proto";

service Library {
// Get a single book.
// Retrieve a single book.
rpc GetBook(GetBookRequest) returns (Book) {
option (google.api.http) = {
get: "/v1/{name=publishers/*/books/*}"
Expand Down
311 changes: 311 additions & 0 deletions aip/general/0162/aip.md.j2
Original file line number Diff line number Diff line change
@@ -0,0 +1,311 @@
# Resource Revisions

mkistler marked this conversation as resolved.
Show resolved Hide resolved
| This AIP is pending final review. See the [#todos][] section for a list of known issues to be resolved. |
| :- |

Some APIs need to have resources with a revision history, where users can
reason about the state of the resource over time. There are several reasons for
this:

- Users may want to be able to roll back to a previous revision, or diff
against a previous revision.
- An API may create data which is derived in some way from a resource at a
given point in time. In these cases, it may be desirable to snapshot the
resource for reference later.

**Note:** We use the word _revision_ to refer to a historical reference for a
particular resource, and intentionally avoid the term _version_, which refers
to the version of an API as a whole.

## Guidance
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • @hudlow: We should rewrite this to merge etags and revision IDs semantically, and say that you can preserve etags.
    • During discussion, this held up quite well.
  • @hudlow: Also need discussion about whether every field constitutes a new revision or whether the service can decide specific fields.


APIs **may** store a revision history for a resource if it is useful to users.

APIs implementing resources with a revision history **must** provide a
`revision_id` field on the resource:

```typescript
interface Book {
// The ID of the book.
id: string;

// Other fields…

// The revision ID of the book.
// A new revision is committed whenever the book is changed in any way.
// The format is an 8-character hexadecimal string.
readonly revisionId: string;

// The timestamp that the revision was created.
readonly revisionCreateTime: string; // ISO 8601
}
```

- The resource **must** contain a `revision_id` field, which **should** be a
string and contain a short, automatically-generated random string. A good
rule of thumb is the last eight characters of a UUID4.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
rule of thumb is the last eight characters of a UUID4.
rule of thumb is the last eight characters of a version 4 UUID.

- The `revision_id` field **must** document when new revisions are created
(see [committing revisions](#committing-revisions) below).
- The `revision_id` field **should** document the format of revision IDs.
- The resource **must** contain a `revision_create_time` field, which
**should** be a timestamp (see AIP-142).

**Note:** A randomly generated string is preferred over other operations, such
as an auto-incrementing integer, because there is often a need to delete or
revert revisions, and a randomly generated string holds up better in those
situations.

### Referencing revisions

When it is necessary to refer to a specific revision of a resource, APIs
**must** use the following syntax: `{resource_id}@{revision_id}`. For example:

publishers/123/books/les-miserables@c7cfa2a8

**Note:** The `@` character is selected because it is the only character
permitted by [RFC 3987][] for special meaning within a URI scheme that is
not already used elsewhere.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It still seems to carry a collision risk, though—using the example above, what if a book's title itself includes "@"?


APIs **should** generally accept a resource reference at a particular revision
in any place where they ordinarily accept the resource ID. However, they **must
not** accept a revision in situations that mutate the resource, and should
error with `400 Bad Request` if one is given.

**Important:** APIs **must not** require a revision ID, and **must** default to
the current revision if one is not provided, except in operations specifically
dealing with the revision history (such as rollback) where failing to require
it would not make sense (or lead to dangerous mistakes).

### Getting a revision

APIs implementing resource revisions **should** accept a resource ID with a
revision ID in the standard `Get` operation (AIP-131):

{% tab proto %}

{% sample 'revisions.proto', 'message GetBookRequest' %}

{% tab oas %}

{% sample 'revisions.oas.yaml', '/publishers/{publisherId}/books/{bookId}' %}

{% endtabs %}

If the user passes a revision ID that does not exist, the API **must** fail
with a `404 Not Found` error.

APIs **must** return a `id` value corresponding to what the user sent. If the
user sent a resource ID with no revision ID, the returned `id` string **must
not** include the revision ID either. Similarly, if the user sent a resource ID
with a revision ID, the returned `id` string **must** explicitly include it.

### Tagging revisions

APIs implementing resource revisions **may** provide a mechanism for users to
tag a specific revision with a user provided name by implementing a "Tag
Revision" custom operation:

{% tab proto %}

{% sample 'revisions.proto', 'rpc TagBookRevision', 'message TagBookRevisionRequest' %}

{% tab oas %}

{% sample 'revisions.oas.yaml', '/publishers/{publisherId}/books/{bookId}@{revisionId}:tagRevision' %}

{% endtabs %}

- The `id` field **should** require an explicit revision ID to be provided.
- The field **should** be [annotated as required][aip-203].
- The field **should** identify the [resource type][aip-123] that it
references.
- The `tag` field **should** be [annotated as required][aip-203].
- Additionally, tags **should** restrict letters to lower-case.
- Once a revision is tagged, the API **must** support using the tag in place of
the revision ID in `id` fields.
- If the user sends a tag, the API **must** return the tag in the resource's
`id` field, but the revision ID in the resource's `revision_id` field.
- If the user sends a revision ID, the API **must** return the revision ID in
both the `id` field and the `revision_id` field.
- If the user calls the `Tag` operation with an existing tag, the request
**must** succeed and the tag updated to point to the new requested revision
ID. This allows users to write code against specific tags (e.g. `published`)
and the revision can change in the background with no code change.

### Listing revisions

APIs implementing resource revisions **should** provide a custom operation for
listing the revision history for a resource, with a structure similar to
standard `List` operations (AIP-132):

{% tab proto %}

{% sample 'revisions.proto', 'rpc ListBookRevisions', 'message ListBookRevisionsRequest' %}

{% tab oas %}

{% sample 'revisions.oas.yaml', '/publishers/{publisherId}/books/{bookId}:listRevisions' %}

{% endtabs %}

While revision listing operations are mostly similar to standard `List`
operations (AIP-132), the following important differences apply:

- The first field in the request message **must** be called `id` rather than
`parent` (this is listing revisions for a specific book, not a collection of
books).
- The URI **must** end with `:listRevisions`.
- Revisions **must** be ordered in reverse chronological order. An `order_by`
field **should not** be provided.
- The returned resources **must** include an explicit revision ID in the
resource's `id` field.
- If providing the full resource is expensive or infeasible, the revision
object **may** only populate the `id`, `revision_id`, and
`revision_create_time` fields instead. The `id` field **must** include the
resource ID and an explicit revision ID, which can be used for an explicit
`Get` request. The API **must** document that it will do this.

### Child resources

Resources with a revision history **may** have child resources. If they do,
there are two potential variants:

- Child resources where each child resource is a child of the parent resource
as a whole.
- Child resources where each child resource is a child of _a single revision
of_ the parent resource.

If a child resource is a child of a single revision, the child resource's name
**must** always explicitly include the parent's resource ID:

publishers/123/books/les-miserables@c7cfa2a8/pages/42

In `List` requests for such resources, the service **should** default to the
latest revision of the parent if the user does not specify one, but **must**
explicitly include the parent's revision ID in the `id` field of resources in
the response.

If necessary, APIs **may** explicitly support listing child resources across
parent revisions by accepting the `@-` syntax. For example:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This also is quite ugly, I think it's arguing against the @ patterns.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note also that it prohibits use of - as a revision identifier.


GET /v1/publishers/123/books/les-miserables@-/pages

APIs **should not** include multiple levels of resources with revisions, as
this quickly becomes difficult to reason about.

### Committing revisions

Depending on the resource, different APIs may have different strategies for
when to commit a new revision, such as:

- Commit a new revision any time that there is a change
- Commit a new revision when something important happens
- Commit a new revision when the user specifically asks

APIs **may** use any of these strategies. APIs that want to commit a revision
on user request **should** handle this with a `Commit` custom operation:

{% tab proto %}

{% sample 'revisions.proto', 'rpc CommitBook', 'message CommitBookRequest' %}

{% tab oas %}

{% sample 'revisions.oas.yaml', '/publishers/{publisherId}/books/{bookId}:commit' %}

{% endtabs %}

- The operation **must** use the `POST` HTTP method.
- The operation **should** return the resource, and the resource ID **must**
include the revision ID.
- The request message **must** include the `id` field.
- The field **should** be [annotated as required][aip-203].
- The field **should** identify the [resource type][aip-123] that it
references.

### Rollback

A common use case for a resource with a revision history is the ability to roll
back to a given revision. APIs **should** handle this with a `Rollback` custom
operation:

{% tab proto %}

{% sample 'revisions.proto', 'rpc RollbackBook', 'message RollbackBookRequest' %}

{% tab oas %}

{% sample 'revisions.oas.yaml', '/publishers/{publisherId}/books/{bookId}:rollback' %}

mkistler marked this conversation as resolved.
Show resolved Hide resolved
- The operation **must** use the `POST` HTTP method.
{% endtabs %}

- The operation **should** return the resource, and the resource ID **must**
include the revision ID.
- The request message **must** have a `id` field to identify the resource being
rolled back.
- The field **should** be [annotated as required][aip-203].
- The field **should** identify the [resource type][aip-123] that it
references.
- The request message **must** include a `revision_id` field.
- The API **must** fail the request with `NOT_FOUND` if the revision does not
exist on that resource.
- The field **should** be [annotated as required][aip-203].

**Note:** When rolling back, the API should return a _new_ revision of the
resource with a _new_ revision ID, rather than reusing the original ID. This
avoids problems with representing the same revision being active for multiple
ranges of time.

### Deleting revisions

Revisions are sometimes expensive to store, and there are valid use cases to
want to remove one or more revisions from a resource's revision history.

APIs **may** define a operation to delete revisions, with a structure similar
to `Delete` (AIP-135) operations:

{% tab proto %}

{% sample 'revisions.proto', 'rpc DeleteBookRevision', 'message DeleteBookRevisionRequest' %}

{% tab oas %}

{% sample 'revisions.oas.yaml', '/publishers/{publisherId}/books/{bookId}@{revisionId}:deleteRevision' %}

{% endtabs %}

- The request message **must** have a `id` field to identify the resource
revision being deleted.
- The explicit revision ID **must** be required (the operation **must** fail
with `INVALID_ARGUMENT` if it is not provided, and **must not** default to
the latest revision).
- The field **should** be [annotated as required][aip-203].
- The field **should** identify the [resource type][aip-123] that it
references.
- The API **must not** overload the `DeleteBook` operation to serve both
purposes (this could lead to dangerous or confusing mistakes).
- If the resource supports soft delete, then revisions of that resource
**should** also support soft delete.
- The operation **must not** cause the _resource_ to be deleted.
- Because a resource **should not** exist with zero revisions, the operation
**must** fail with `FAILED_PRECONDITION` if the user attempts to delete the
only revision.

### Character Collision

Most resource IDs have a restrictive set of characters, but some are very open.
For example, Google Cloud Storage allows the `@` character in filenames, which
are part of the resource ID, and therefore uses the `#` character to indicate a
revision.

APIs **should not** permit the `@` character in resource IDs, and if APIs that
do permit it need to support resources with revisions, they **should** pick an
appropriate separator depending on how and where the API is used, and **must**
clearly document it.

mkistler marked this conversation as resolved.
Show resolved Hide resolved
## Todos

- Revisit the selection of the `@` character as the delimiter for the version.

[rfc 3987]: https://tools.ietf.org/html/rfc3987
7 changes: 7 additions & 0 deletions aip/general/0162/aip.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
id: 162
state: pending
created: 2019-09-17
placement:
category: design-patterns
order: 88
Loading