Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
1 change: 1 addition & 0 deletions changelog.d/4-docs/pr-1958
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Proposal for API versioning system.
388 changes: 388 additions & 0 deletions docs/developer/api-versioning.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,388 @@
# Introduction

Since upgrades cannot happen instantaneously, we need release wire
Comment thread
fisx marked this conversation as resolved.
Outdated
backends and wire client apps that work together accross releases.

In the past, we have made sure that the api is changed in a way that
the backend can be newer than the client, and then releasing the
change on the backend first. This worked well on the cloud where we
had control, but fails a lot in the on-prem setting, and will get
worse with federation taking off: if we add a new end-point to the
API, the backend will still be able to handle older clients that
simply don't know about that end-point, but the client won't handle
old backends well, since it will try to call the end-point, and fail.

The new approach outlined here therefore supports API versions. Every
API version is only compatible with itself, but every node in the
network can support a *set of API versions*. A HTTP client can query
the set of supported versions from an HTTP server, and then pick one
that works for it.

In the following, we will refer to HTTP clients as "clients", no
matter whether it is a backend talking to another backend
(federation); and to HTTP servers as "server", no matter which API is
it serving (federation or app). This approach is intended to work for
both.


# Versions and servant routing tables

All routing tables for which a new version is born will be changed
into taking the version number as a parameter, which is added as a
prefix to every route:
Comment thread
fisx marked this conversation as resolved.

```haskell
data Api (version :: Symbol) routes = Api
Comment thread
fisx marked this conversation as resolved.
{ getUnqualifiedConversation ::
routes
:- version
:> Summary "Get a conversation by ID"
:> ZLocalUser
:> "conversations"
:> Capture "cnv" ConvId
:> Get '[Servant.JSON] Conversation,
getConversation ::
routes
:- version
:> Summary "Get a conversation by ID"
:> ZLocalUser
:> "conversations"
:> QualifiedCapture "cnv" ConvId
:> Get '[Servant.JSON] Conversation,
[...]
}
```

APIs of all the supported versions can be composed like this:

```haskell
type ServantAPI =
ToServantApi (Api "v1")
:<|> ToServantApi (Api "v2")
:<|> ToServantApi (Api "v4") -- v3 is broken
Comment thread
fisx marked this conversation as resolved.
```


## Changes between versions

The point of having versions is of course not that all of them look
exactly the same except for their prefix. The point is that there are
other things that change between versions.

There are essentially two categories of changes:

1. **data**: request or response bodies, variable path segments,
possible headers or status codes (as in `UVerb` or `MultiVerb`),
etc.
2. **structure**: literal path segments, verb, the version itself, ...


## Changes in the data

If a data type in request, response, variable path segments, or anywhere else
changes, introduce a type family parameterized in the version.

```haskell
[...]
getConversation ::
routes
:- version
:> Summary "Get a conversation by ID"
:> ZLocalUser
:> "conversations"
:> QualifiedCapture "cnv" ConvId
:> Get '[Servant.JSON] (ConversationV version),
[...]

type family ConversationV (version :: Symbol) :: * where
ConversationV "v1" = Conversation
ConversationV "v2" = Conversation
ConversationV "v4" = ConversationV4
Comment thread
fisx marked this conversation as resolved.
```

Note that before version `"v4"`, nothing changed for this type, so
there was no need to introduce a new concrete data type.

If the last change of a data type is entirely phased out, the type
family turns constant and can be removed again. If you see this in
your code:

```haskell
type family ConversationV (version :: Symbol) :: * where
ConversationV "v4" = ConversationV4
ConversationV "v5" = ConversationV4
ConversationV "v6" = ConversationV4
ConversationV "v7" = ConversationV4
```

You can remove 'ConversationV', rename `ConversationV4` to Conversation,
and use it in the routing table instead of `ConversationV` again, as
before `"v4"`.


## Changing structure

Without loss of generality, we only consider additions and deletions
of routes in this section: if you want to change the path or verb of
an end-point, add a new path instead, and phase the old one out (now
or in some future version).

When end-points are present in some supported versions, but not in
others, their record fields in the servant routing type needs to be
present for all versions, but in some versions should behave as if it
weren't.

This is best solved by a new type alias:

```haskell
type NotInThisVersion = Verb 'NOTINTHISVERSION '[] NoContent
```

Then we can write a type family that can crawl a `ServantAPI` (not the
record one, the one with `:<|>`) and drop all the routes marked as not
existing.
Comment thread
fisx marked this conversation as resolved.
Outdated

This will save us the trouble of writing lots of instances for
`NotInThisVersion` (server, swagger, client, ...), and yield exactly
the desired result:

```haskell
type ServantAPI =
DropNotInThisVersion
( ToServantApi (Api "v1")
:<|> ToServantApi (Api "v2")
:<|> ToServantApi (Api "v4") -- v3 is broken
)
```


## Adoption of versioned APIs

When API versions are introduced to a code base that has a routing
table without versions, the question arises what to do with old
clients or servers talking to new ones.

We define a middleware that

(1) maps requests without version prefix in the path to ones that
have version `"v0"`.

(2) responds with a specific type of error if an unsupported version
is requested (so the client can re-negotiate a new version to
speak after an upgrade, see below).


## Version handshake

Client and server need to agree on a version to use. The server
provides two (kinds of) end-points for that.

```
GET /api-versions
Comment thread
fisx marked this conversation as resolved.
=> { "supported": [1, 2, 45, 119] }

GET /v*/api-docs
=> <swagger for this particular version>
```

The client developer can pull the swagger docs of a new version and

@tmspzz tmspzz Dec 9, 2021

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

You mean: there is an automated diffing too that the backend provides. 😉

Even better: there is a compare function similar to https://github.com/octocat/linguist/compare/master...octocat:master

diff it against the one they already support, and work their way
through the changes (see below).

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

You mean: And the changes automatically generate a new stub client

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I think so, yes.


The client will call `GET /api-versions` and pick any number in the
intersection of the versions it supports and the ones the server
supports (usually the largest).

*Corner case:* if we want to distinguish between backend-to-backend
and client-to-backend, we can do that in path suffixes (`GET
/api-versions/{client,federation}` etc.).


### No shared api version

If the intersection set is empty, the client has no way of talking to
the server. It needs to politely fail with an error message about
upgrading, downgrading, or talking to another server.

This should only happen if the distance between last upgrade on client
and server exceeds the agreed-upon limits (eg., 6 months).


### Update detection and version re-negotation

If the server is upgraded and some old supported versions are phased
out, the client may be caught by surprise.

The clients should therefore frequently (every 24h?) refetch
Comment thread
fisx marked this conversation as resolved.
Outdated
/api-versions and possibly raise warnings if the intersection set gets
too small.

The client could also avoid that extra work and instead have a
catch-all that will handle the specific response from the versioning
middleware (see above), re-fetch `/api-versions` and try again.


## Strongly typed versions

If we make version an ADT `ApiVersion`, we can remove old versions
from it in one place and have the compiler guide us through all the
places where we need to remove it.

There are at least two ways to implement this:

1. Add a few extra servant instances for `(v :: ApiVersion) :> route`.
2. Define a type family `Versions` that maps `V*` to `"v*"`, and write
`Versions version :>` in the routing type instead of `"v1"`.

2 seems a lot less work to write, read, and understand.


## Data migration (aka data marshalling)

If the shape of an end-point changes between versions (if a data type
in the routing table becomes a type family), it is often possible to
write marshalling functions that translate a value from an older
version into one of a newer version or vice versa.

These functions are called marshalling functions and are useful to
define separately to keep the application logic clean.

For certain changes to a data type used in an API, marshalling is
straight-forward in both directions. The most common example is
adding an optional attribute to a JSON object:

- *backward migration*: remove the new attribute.
- *forward migration*: set the new attribute to `null`.

(This is what wire has traditionally done to accomplish client
backwards compatibility without any API versioning.)

If a mandatory attribute is added in a newer version, there may be a
plausible default value that can be used in the forward migration
(backward migration would still remove the field).

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Older clients won't be able to read the field because they are unaware of it.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This is not about an additional field that clients can not yet read, but an additional fiend that the backend expects FROM the client. If the client did not specify that field, the backend will assume a default value.


In other cases, whether there is an automatic migration depends on the
use case and the semantics.

It may even be impossible to marshal either in one or in both
directions. In this case, you have 3 options:

1. abandon compatibility;
2. rethink your new version and craft it in a way that two-way
marshalling is possible;
3. make the application work around the gap, eg. by gracefully
refusing to offer video conferencing in a client if it is not
supported on the server yet.


## Writing client code

If you write all code by hand and don't generate anything from the
swagger docs, just look at the swagger diff for every new version and
take it from there.

If you generate, say, typescript or kotlin or swift from swagger:

0. have a generated source module `Gen.ts`, plus a source module with

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

We will version a library per version of the API achieving something similar to what you describe I believe

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

That may end you up with more dead code in the executable, but that may not be a problem. Implementation detail!

manually written code `Man.ts`. Re-export everything from `Gen.ts`
in `Man.ts`, and only import `Man.ts` in any modules that contain
application logic.

1. look at the diff of the swagger of the last supported and the new
versions.

2. copy all functions for routes that have changed from `Gen.ts` to
`Man.ts`. these are speaking an old api version and won't need to
be re-generated any more. work the last version supported by this
function into the name somehow (eg., suffix `"_v23"`).

3. for every function that moved to `Man.ts` in this way, write a
function *without* the version suffix. It somehow knows the api
version of the server that it talks to (function parameter, app
config, dosn't matter), and decides based on that whether to call
the deprecated function with the `"_v23"` suffix or the one from
`Gen.ts`. If the old one is called, it may have to do some
marshalling of request and response (see above).

It will happen that a new client will not be able to accomplish
something with an old API (example: if video calling is introduced in
`"v12"`, you can't emulate `POST /video-call` when talking to a `"v9"`
server. In these cases, the function in `Man.ts` must raise a "server
too old" exception, and gracefully shut down the new functionality.


## Concerns and design alternatives

### Why not version every end-point separately?

Yes, that would work in principle. On the backend, it would make the
entire routing table smaller (no need to concatenate the same
end-point for many versions), which may result in shorter compile
times. On the clients, with a new API version it would be
straight-forward to see which end-points need to be worked on, and
which remain unchanged.

On the other hand, the routing table size may not be an issue, and if
it is there are solutions (introduce a CCP switch to compile only the
most recent API version that you're working on); and the client
process is already quite straight-forward with the approach outlined
above via diffing the swagger docs between most recent version and
predecessor.

Plus, if the entire API has one version, you get a few advantages:

1. The fact that clients are forced to commit to a concrete API
version for all end-points when talking to the backend reduces
testing complexity. If there is a mapping of end-points to
versions, the behavior of interacting parties is much less
restricted, and versions that have not been tested against each
other may be used together. (This can be avoided, but it's less
obvious how to get it right, and testing complexity will likely be
worse.)

2. The "one version" approach makes it obvious which end-points are in
the most recent API at any given point in time. The "one version
per end-point" approach would either yeild a noisy union of all
supported versions, or there would have to be a mechanism for
reconstructing something close to what we get for free otherwise.

3. The backend code is a good combinatin of concise and type-safe in
Comment thread
fisx marked this conversation as resolved.
Outdated
the "one version" approach. If every end-point had its own
version, the routing table entry would either have to accept a
variable path segment for the version, and fail at run-time if the
version is not supported, or you would have to add one handler per
supported version (even if in the case where all versions call the
same handler function with slightly different parameters).


### Syntactical vs. behavioral changes

It is quite common that behavior of end-points changes together with
the syntax, or even without a change in the syntax.

This is not a fundamental problem: since the handler can be called

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

What happens to old frontend clients if we force a change of version when the behavior changes, but the data doesn't?

E.g. there's an endpoint to upload assets, that are stored in a somehow generic bucket (V1).
A new implementation is introduced so that the image is stored on a per-user bucket (V2).
The endpoint otherwise has the same data between V1 and V2.

Old clients that only talk V1 could, in theory, still use the endpoint to send images. The fact that they are not aware of the new behavior does not prevent them from sending images; both V1 and V2-aware backends and clients can process those images successfully. However, since the client doesn't speak V2, it will be prevented from talking to V2 backends. This sounds to me like an artificial limitation.

Probably in cases like this we should consider changing the behavior of the V1 endpoint to behave like V2, keep the V1, and at the same time introduce V2. Basically backporting the V2 behavior to V1, since they are compatible data-wise. This might increase complexity on the backend implementation (does it?), but has the advantage of not forcing a client update for every user.

Would this be a valid strategy?

@pcapriotti pcapriotti Dec 9, 2021

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I would recommend against retroactively changing APIs of old versions. That somewhat defeats the purpose of using versioning, breaks the whole model and can introduce hard-to-diagnose issues.

I don't quite understand your example here. If V2 is the same as V1, but just implemented differently, it should just not cause a version bump, so V1=V2, and clients have no compatibility issues. If V2 has strictly more features than V1, the backend can support both, so V1-clients can still talk to {V1,V2}-backends.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

thanks @pcapriotti, I agree! @marcoconti83 let me know if this just raises further questions. :)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

My example was a bit convoluted, but you @pcapriotti got what I meant when you said:

If V2 has strictly more features than V1, the backend can support both, so V1-clients can still talk to {V1,V2}-backends.

... (adding) thanks to the backend still honoring the V1 API, even if the internals of the backend behavior is changed, if that change is not relevant for the API.

I'll rewrite the example:

With V1, we store assets without any user information (who uploaded them).
The clients use POST /v1/assets.

With V2, we introduce a new feature to keep track of which user uploaded which asset, and allow users to delete them. We somehow run a migration script once to assign all previously unassigned assets to a user (doesn't matter how) and from now on, we assume all assets are assigned to a user.
The clients use POST /v2/assets, GET /v2/assets and DELETE /v2/assets/<id>

We now have two options:

  • Have the backend support only V2
    • Clean cut, but it also means all clients need to update NOW or they will stop working as they still post to V1
  • Have the backend support V1 and V2
    • When clients use POST /v1/assets, the backend assigns a user ID to the asset. This was not the behavior before V2 was introduced, BUT the V1 API is still honored (this is what I meant by "backporting" the behavior). The new behavior is not relevant for the API contract, so why deprecate it? We just map /v1/assets/ to /v2/assets/. We can remove the extra v2 bits from the response to look like v1, but that's not even strictly necessary if they were only additions.
    • V2 clients can still do the extra bits: delete assets, even on assets uploaded by V1 clients
    • This means we can release the backend version that supports V2 and wait for all clients to update to V2 whenever they are ready

I'm bringing up this example because we had to do such hybrid systems in the past to support a transition between one behavior and another, and I don't want the versioning system to preclude this possibility.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks @marcoconti83 for clarifying the example, now I understand better what you mean. I think option two should work without problems. In practice, what I imagine would happen in the server is that V1 would be unmodified at the level of the API and the corresponding types, but its implementation would share code with V2, implementing the new user-tracking functionality.

But I see your point now: in some sense, we are indeed retroactively changing V1 when we add V2, since V1 is now implemented the same as V2. However, this is only in the implementation, and the API contract of V1 is still identical to before.

How to make sure that these contracts are indeed preserved over time is another matter altogether, and not discussed in this proposal. We've had some previous discussions around a compatibility testing framework, but it was focused on the server-to-server case, and based on a slightly different approach to versioning: https://wearezeta.atlassian.net/wiki/spaces/CORE/pages/529170506/Compatibility+testing+for+different+backend+versions+in+federation. I imagine many of the ideas still apply, though.

with the version as a type parameter, there is no reason why it
shouldn't change behavior with or without changing the syntax. In
each such case, it needs to be decided whether the difference is
significant enough to justify a new API version.

At the very least though it should result in diverging swagger docs
that explains those differences.


### Client capabilities

Wire supports client capabilities to decide whether a client should be
allowed to use certain parts of the API.
Comment thread
fisx marked this conversation as resolved.

This is another alternative to API versions, and it is in some ways
more straight-forward to decide who to interpret capability sets. But
this approach has its own problems: Most importantly, the number of
supported capability sets grows quadratically (not in practice,
because historically clients will only ever support a small part of
all possible combinations of capabilities, but that makes thigns
worse: it makes the system more complex, and then doesn't use that
complexity for anything).

Therefore, the capabilities we're using in the wire code base should
be gracefully phased out and replaced by API versions.