Open aleksicmarija opened 7 months ago
@aleksicmarija there isn't any sort of API resource versioning functionality built-in, since there are many different ways to accomplish this and it's not always easy to model via OpenAPI. Currently the easiest way to do this is via separate operation paths (e.g. version in the URL path) so each "version" is a distinct operation with its own input/output models. This versioning strategy doesn't need any special support in Huma, but maybe some utility functions or a utility package could be written to make it nicer to set up.
Supporting something like one operation with different inputs/outputs based on a version header, or looking up the user's selected version, or some other mechanism is harder to do and I'm not sure how to model it. I'm open to ideas on this one - how would you like it to work?
Also worth looking into is OpenAPI Project Moonwalk, their current special interest group working on OpenAPI v4 which enables multiple operations per HTTP method, differentiated by things like query or header values.
We can use dotnet core on how they implement this functionality. Generally it's based on either headers (x-api-version) or the generic prefix like /v1/
Well, my idea is that instead of a single handler function, the register function could receive a map with uint keys where the values are handlers. Then, based on the version extracted from the route /api/v{version}, it would determine which route should be called. I'm not sure if something like this is possible?
This is how we are doing it now without Huma: we wrap our handler, where firstly we extract the version from the route, and then we get a handler for that version.
This might belong in a package that extends huma rather than in huma itself, for the reasons Daniel mentioned.
/v1
/v2
etc routes aren't a great ideal as far as versioning goes. it's a very inflexible system and hard to upgrade all endpoints in sync.
Personally I'm a fan of Stripe's date-based api versioning, and––depending on the project––might want the ability to upgrade and downgrade request and responses based on the requested version vs the current version. This involves writing request/response upgraders and downgraders, rather than maintaining separate versions of each api endpoint forever.
Point is, I agree versioning could be complicated and should probably be a separate extension. Not sure what huma would have to expose to support this, but what I've done before is output multiple openapi schemas, one per version. When the UI selects a different version, it's a completely different openapi schema.
Supporting something like one operation with different inputs/outputs based on a version header, or looking up the user's selected version, or some other mechanism is harder to do and I'm not sure how to model it. I'm open to ideas on this one - how would you like it to work?
One method that my colleagues have used is using the Content-Type along with the request's Accept header. For example:
Accept: application/json; version=1
It's conceptually the same idea as a vendor specific content type, eg application/vnd.myapi.v2+json
To handle the former, you need robust content negotiation, which there isn't a huge amount of support for in the Go world yet. Not many Go http libraries/frameworks will parse media-type parameters. c.f. Express's res.format
which takes parameters into account.
Modelling content type-based versioning in an openapi document is fairly easy since there is already a way to specify responses for multiple content types. Having a dedicated header to specify the versioning is easier implementation-wise, but it also makes it a little harder to map a payload type to the corresponding API version.
Here is a very basic example of how you could support types that can be downgraded and allow clients to select a version via the Accept
header:
https://go.dev/play/p/GYj_GyszZWN
First, define the current version and the downgrade transforms:
type MyType struct {
Name string `json:"name"`
Tags map[string]string `json:"tags"`
}
func (m MyType) V2() any {
// In v2, tags were just a list of strings.
tags := []string{}
for k, v := range m.Tags {
tags = append(tags, fmt.Sprintf("%s=%s", k, v))
}
return map[string]any{
"name": m.Name,
"tags": tags,
}
}
func (m MyType) V1() any {
// In v1, the name was split into first and last. Tags did not exist yet.
parts := strings.Split(m.Name, " ")
return map[string]any{
"first": strings.Join(parts[:len(parts)-1], " "),
"last": parts[len(parts)-1],
}
}
Next, write a Huma transformer to convert types which have such methods based on the incoming accept header. This is hacky - in a real world service you want to properly parse the header.
func versionedTransform(ctx huma.Context, status string, v any) (any, error) {
accept := ctx.Header("Accept")
if strings.Contains(accept, "version=2") {
if m, ok := v.(interface{ V2() any }); ok {
return m.V2(), nil
}
return nil, fmt.Errorf("unsupported type: %T", v)
} else if strings.Contains(accept, "version=1") {
if m, ok := v.(interface{ V1() any }); ok {
return m.V1(), nil
}
return nil, fmt.Errorf("unsupported type: %T", v)
}
return v, nil
}
Lastly, set the transform on the config
before creating the API instance, then make an operation that returns your type from the body. You can run the code in the playground link to see the results:
========== Latest version ==========
HTTP/1.1 200 OK
Connection: close
Content-Type: application/json
Link: </schemas/MyType.json>; rel="describedBy"
{
"$schema": "https://example.com/schemas/MyType.json",
"name": "John Doe",
"tags": {
"foo": "bar"
}
}
========== Version 2 ==========
HTTP/1.1 200 OK
Connection: close
Content-Type: application/json
{
"name": "John Doe",
"tags": [
"foo=bar"
]
}
========== Version 1 ==========
HTTP/1.1 200 OK
Connection: close
Content-Type: application/json
{
"first": "John",
"last": "Doe"
}
Some caveats:
Accept
header.$schema
into the responses, because returning map[string]any
destroys any specific type information it uses to link to a type.Anyway, someone could probably build on this idea to provide a utility library for versioning on top of Huma.
I found that simply defining different huma.MediaType
s for a Response's Content almost works with media-type parameters. Here's what I mean:
Responses: map[string]*huma.Response{
"200": {
Content: map[string]*huma.MediaType{
"application/json; version=1": {
// stuff
},
"application/json; version=2": {
// other stuff
},
},
},
}
From my understanding, the above would in theory place everything in the generated openapi. However, the parsing of Accept headers doesn't handle parameters (though it could). I actually added media-type paramter parsing to fasthttp because a framework I like uses fasthttp and I wanted better content negotiation. SelectQValue and SelectQValueFast are almost there, but as the name suggests, they only get the qvalue. For those that don't actually care about allocation, the standard library already provides a way to parse media types with parameters.
Using vendor-specific content types is definitely simpler implementation-wise though because of the sheer lack of demand for media-type parameter parsing :( . I'm not a huge fan of vendor specific content types because it's harder for me to remember which to use when sending a request on the command line.
I found some configurations for prefixes, but it seems that's not it. Is there a way to register a route without a prefix and huma mechanism will choose between versions if we implement them (I'm aware that it needs to be configured but I'm searching for a best way)? I have api versioning but i would like to add it into huma so i can have all versions in my API docs.
Some example would be amazing.