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
How to add versioning to my API using huma? #377
Comments
@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.
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. |
- Route implementation seems easier - binding and input validation already taken care of - Open API spec, Docs browser, etc (http://localhost:8080/docs) - Removed the `<version>/API` prefix - danielgtaylor/huma#377 (comment) seems like a good take on why this versioning doesn't make much sense - If there is need for a public API it might make sense to introduce it explicitly. E.g. have `internal/...` for our web app (free to change) and `api/...` for external tools (versioned and backwards compatible)
One method that my colleagues have used is using the Content-Type along with the request's Accept header. For example: It's conceptually the same idea as a vendor specific content type, eg 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. |
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 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 ========== 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:
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 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.
The text was updated successfully, but these errors were encountered: