Skip to content

Commit

Permalink
multiple OpenAPI instances support
Browse files Browse the repository at this point in the history
  • Loading branch information
cardinalby committed Nov 9, 2024
1 parent cb500c4 commit be8c341
Show file tree
Hide file tree
Showing 17 changed files with 313 additions and 164 deletions.
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ Similar to other routers you can create a derived `api` (i.e. group) that has pr
Now you have [manual control](./docs/openapi_endpoints.md) over exposing the spec, docs and schemas:
- Expose only needed spec versions
- Add own middlewares (e.g. authentication to protect the spec on public APIs)
- Have separate scoped OpenAPI specs for different parts of your API

### ❤️ Access more metadata in Operation Handlers

Expand Down Expand Up @@ -59,7 +60,7 @@ go get github.com/cardinalby/hureg

- [Operation metadata](./docs/metadata.md)
- [Per-group Transformers](./docs/transformers.md)
- [OpenAPI endpoints](./docs/openapi_endpoints.md)
- [OpenAPI endpoints](./docs/openapi_endpoints.md) and multiple scoped specs

## Examples

Expand Down
45 changes: 42 additions & 3 deletions api_gen.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package hureg
import (
"github.com/danielgtaylor/huma/v2"

"github.com/cardinalby/hureg/pkg/huma/humaapi"
"github.com/cardinalby/hureg/pkg/huma/op_handler"
)

Expand All @@ -11,9 +12,11 @@ import (
// It provides a fluent API to create derived APIGen instances with own set of actions/changes to an
// operation before its registration.
type APIGen struct {
humaAPIWrapper humaApiWrapper
regMiddlewares RegMiddlewares
transformers []huma.Transformer
humaAPIWrapper humaApiWrapper
regMiddlewares RegMiddlewares
bubblingRegMiddlewares RegMiddlewares
transformers []huma.Transformer
extraHumaAPIs []huma.API
}

// NewAPIGen creates a new APIGen instance with the given huma.API.
Expand All @@ -39,6 +42,19 @@ func (a APIGen) GetRegMiddlewares() RegMiddlewares {
return a.regMiddlewares
}

// AddBubblingRegMiddleware returns a new APIGen instance with the given RegMiddlewares added to the
// stored bubbling RegMiddlewares. Bubbling RegMiddlewares will be applied to the operation in the opposite order
// after normal RegMiddlewares are done allowing you to observe changes made by all previous RegMiddlewares.
func (a APIGen) AddBubblingRegMiddleware(regMiddlewares ...RegMiddleware) APIGen {
a.bubblingRegMiddlewares = append(a.bubblingRegMiddlewares, regMiddlewares...)
return a
}

// GetBubblingRegMiddlewares returns the stored bubbling RegMiddlewares.
func (a APIGen) GetBubblingRegMiddlewares() RegMiddlewares {
return a.bubblingRegMiddlewares
}

// AddOpHandler returns a new APIGen instance with the given OperationHandlers added to it.
func (a APIGen) AddOpHandler(handlers ...op_handler.OperationHandler) APIGen {
for _, opHandler := range handlers {
Expand Down Expand Up @@ -111,3 +127,26 @@ func (a APIGen) AddMultiBasePaths(
func (a APIGen) AddMiddlewares(middlewares ...func(huma.Context, func(huma.Context))) APIGen {
return a.AddOpHandler(op_handler.AddMiddlewares(middlewares...))
}

// AddExtraHumaAPI returns a new APIGen instance with the given huma.API added to the extraHumaAPIs.
// All operations registered with this APIGen instance or derived instances will be passed to the given `api`
// additionally to the main huma.API.
func (a APIGen) AddExtraHumaAPI(api huma.API) APIGen {
a.extraHumaAPIs = append(a.extraHumaAPIs, api)
return a
}

// GetExtraHumaAPIs returns the stored extraHumaAPIs.
func (a APIGen) GetExtraHumaAPIs() []huma.API {
return a.extraHumaAPIs
}

// AddOwnOpenAPI is a shortcut function to create a new APIGen instance and a OpenAPI object instance together.
// All operations registered with this APIGen instance or derived instances will be added to the OpenAPI object.
// It allows you to have separate OpenAPI spec that contains only operations registered with this APIGen instance
// or derived instances.
// Use it in combination with `pkg/huma/oapi_handlers` package to serve the created OpenAPI spec.
func (a APIGen) AddOwnOpenAPI(apiConfig huma.Config) (APIGen, *huma.OpenAPI) {
humaApi := humaapi.NewDummyHumaAPI(apiConfig)
return a.AddExtraHumaAPI(humaApi), humaApi.OpenAPI()
}
2 changes: 1 addition & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,4 @@

- [Extended operation metadata](./metadata.md)
- [Per-group Transformers](./transformers.md)
- [OpenAPI endpoints](./openapi_endpoints.md)
- [OpenAPI endpoints](./openapi_endpoints.md) and multiple scoped specs
2 changes: 1 addition & 1 deletion docs/op_handlers.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Operation Handlers

## Recall
## Operation Handlers in Huma

_Operation Handlers_ are used in Huma to modify an operation before registration.

Expand Down
40 changes: 33 additions & 7 deletions docs/openapi_endpoints.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# OpenAPI endpoints

## Recall
## OpenAPI endpoints in Huma

Huma generates OpenAPI endpoints by default:
- **OpenAPI spec** endpoints (if `Config.OpenAPIPath` is set):
Expand Down Expand Up @@ -57,17 +57,43 @@ oapiGroup := api. // api is APIGen instance
**Step 2**: register OpenAPI endpoints manually using library-provided handlers

```go
yaml31Handler, _ := oapi_handlers.GetOpenAPITypedHandler(
api.GetHumaAPI(),
oapi_handlers.OpenAPIVersion3dot1,
oapi_handlers.OpenAPIFormatYAML,
openAPI := api.GetHumaAPI().GetOpenAPI() // OpenAPI object

yaml31Handler, err := oapi_handlers.GetOpenAPISpecHandler(
openAPI, oapi_handlers.OpenAPIVersion3dot1, oapi_handlers.OpenAPIFormatYAML,
)

docsHandler := oapi_handlers.GetDocsTypedHandler(
docsHandler := oapi_handlers.GetDocsHandler(
api.GetHumaAPI(),
"/openapi.yaml", // path to the OpenAPI spec HTML page will request
)

schemaHandler := oapi_handlers.GetSchemaHandler(openApi, "")

Get(oapiGroup, "/openapi.yaml", yaml31Handler)
Get(oapiGroup, "/docs", docsHandler)
```
Get(oapiGroup, "/schemas/{schemaPath}", schemaHandler)
```

### Advanced: separate spec for some endpoints

Sometimes we need to have a separate spec that contains only a subset of the operations.

Add scoped `OpenAPI` object to your group:

```go
api := hureg.NewAPIGen(humaApi)
experimentalApi, experimentalOpenAPI := api.AddOwnOpenAPI(huma.DefaultConfig("experimental", "1.0.0"))
stableApi, stableOpenAPI := api.AddOwnOpenAPI(huma.DefaultConfig("stable", "1.0.0"))
```

`experimentalOpenAPI` and `stableOpenAPI` are `*huma.OpenAPI` objects bind to the corresponding `APIGen` instances.

All registered operations will be added to the main `api` instance and its `OpenAPI` object.

But `experimentalOpenAPI` and `stableOpenAPI` will contain only operations registered with
`experimentalApi` and `stableApi` correspondingly.

You can use the handlers from the previous example to expose them separately.


17 changes: 17 additions & 0 deletions docs/reg_middlewares.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,5 +51,22 @@ myRegMiddleware := func(op huma.Operation, next func(huma.Operation)) {
}
```

## Advanced: bubbling registration middlewares

If you want to see the changes made by all registration middlewares
in the chain (in derived `APIGen` instances) before an operation will be actually registered in Huma, here is the way:

```go
bubblingRegMiddleware := func(op huma.Operation, next func(huma.Operation)) {
// do something with the operation
next(op)
}
api = api.AddBubblingRegMiddleware(bubblingRegMiddleware)
derived = api.AddOpHandler(op_handlers.AddTags("tag1"))
```

In this example `bubblingRegMiddleware` will receive an operation registered with `derived` back being able
to observe added tags.

---
[Create a group with base path →](./base_path.md)
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ module github.com/cardinalby/hureg
go 1.22

require (
github.com/danielgtaylor/huma/v2 v2.18.0
github.com/danielgtaylor/huma/v2 v2.25.0
github.com/stretchr/testify v1.9.0
gopkg.in/yaml.v3 v3.0.1
)
Expand Down
3 changes: 3 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
github.com/danielgtaylor/huma/v2 v2.18.0 h1:L6AoiCD9WGxUFnAQMZpEub1hnRJpEs7ZUdWwvkrUWHE=
github.com/danielgtaylor/huma/v2 v2.18.0/go.mod h1:fFOnahr3rZdFha4rqDq7rjb8q3CPuZvCjoP37qg8fTI=
github.com/danielgtaylor/huma/v2 v2.25.0 h1:8q/tZLozDs2oFPUHS1xaFVa1mlNYBXV8UbmSQUQeAXo=
github.com/danielgtaylor/huma/v2 v2.25.0/go.mod h1:NbSFXRoOMh3BVmiLJQ9EbUpnPas7D9BeOxF/pZBAGa0=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-chi/chi/v5 v5.0.12 h1:9euLV5sTrTNTRUU9POmDUvfxyj6LAABLUcEWO+JJb4s=
github.com/go-chi/chi/v5 v5.0.12/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
Expand Down
27 changes: 18 additions & 9 deletions integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ func TestHttpServer(t *testing.T) {
testOpenApiSpec(t, addr)

// uncomment to play with the server
//waitSigInt(stop)
// waitSigInt(stop)
}

func createTestServer(t *testing.T) http.Handler {
Expand All @@ -49,15 +49,15 @@ func createTestServer(t *testing.T) http.Handler {

api := NewAPIGen(humaApi)

defineAnimalEndpoints(api)
defineAnimalEndpoints(t, api)

apiWithBasicAuth := api.AddMiddlewares(newTestBasicAuthMiddleware())
defineManualOpenApiEndpoints(t, apiWithBasicAuth)
defineManualOpenApiEndpoints(t, apiWithBasicAuth, humaApi.OpenAPI(), "")

return httpServeMux
}

func defineAnimalEndpoints(api APIGen) {
func defineAnimalEndpoints(t *testing.T, api APIGen) {
type testResponseDto struct {
Body string
}
Expand All @@ -67,6 +67,9 @@ func defineAnimalEndpoints(api APIGen) {
AddTransformers(duplicateResponseStringTransformer)

v1gr := beasts.AddBasePath("/v1")
// create separate huma.API instance to expose isolated OpenAPI spec only for v1 endpoints
v1gr, v1OpenSpec := v1gr.AddOwnOpenAPI(huma.DefaultConfig("v1", "1.0.0"))
defineManualOpenApiEndpoints(t, v1gr, v1OpenSpec, "/v1")

Get(v1gr, "/cat", func(ctx context.Context, _ *struct{}) (*testResponseDto, error) {
return &testResponseDto{Body: "Meow"}, nil
Expand Down Expand Up @@ -103,17 +106,23 @@ func newTestBasicAuthMiddleware() func(ctx huma.Context, next func(huma.Context)
)
}

func defineManualOpenApiEndpoints(t *testing.T, api APIGen) {
func defineManualOpenApiEndpoints(
t *testing.T,
api APIGen,
openApi *huma.OpenAPI,
prefix string,
) {
api = api.AddOpHandler(op_handler.SetHidden(true, true))
humaApi := api.GetHumaAPI()

yaml31Handler, err := oapi_handlers.GetOpenAPITypedHandler(
humaApi, oapi_handlers.OpenAPIVersion3dot1, oapi_handlers.OpenAPIFormatYAML,
yaml31Handler, err := oapi_handlers.GetOpenAPISpecHandler(
openApi, oapi_handlers.OpenAPIVersion3dot1, oapi_handlers.OpenAPIFormatYAML,
)
require.NoError(t, err)

Get(api, "/openapi.yaml", yaml31Handler)
Get(api, "/docs", oapi_handlers.GetDocsTypedHandler(humaApi, "/openapi.yaml"))
Get(api, "/docs", oapi_handlers.GetDocsHandler(openApi, prefix+"/openapi.yaml"))
schemaHandler := oapi_handlers.GetSchemaHandler(openApi, "")
Get(api, "/schemas/{schemaPath}", schemaHandler)
}

func testServerEndpoints(t *testing.T, addr string) {
Expand Down
24 changes: 24 additions & 0 deletions pkg/huma/humaapi/dummy_huma_api.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package humaapi

import (
"net/http"

"github.com/danielgtaylor/huma/v2"
)

// NewDummyHumaAPI creates a new Huma API with dummy adapter to be used only for OpenAPI object generation
// combined with manually added endpoints for docs, schemas and spec.
func NewDummyHumaAPI(config huma.Config) huma.API {
config.OpenAPIPath = ""
config.DocsPath = ""
config.SchemasPath = ""
return huma.NewAPI(config, dummyAdapter{})
}

type dummyAdapter struct{}

func (d dummyAdapter) Handle(*huma.Operation, func(ctx huma.Context)) {
}

func (d dummyAdapter) ServeHTTP(http.ResponseWriter, *http.Request) {
}
88 changes: 37 additions & 51 deletions pkg/huma/oapi_handlers/docs.go
Original file line number Diff line number Diff line change
@@ -1,61 +1,47 @@
package oapi_handlers

import "github.com/danielgtaylor/huma/v2"
import (
"context"
"embed"
"html/template"

func getOpenApiTitle(humaApi huma.API) string {
openApi := humaApi.OpenAPI()
if openApi == nil {
return ""
}
if openApi.Info == nil {
return ""
}
return openApi.Info.Title
"github.com/danielgtaylor/huma/v2"
)

//go:embed docs_page.gohtml
var tmplFS embed.FS

type pageData struct {
Title string
OpenAPIYamlPath string
}

// GetDocsAdapterHandler returns a handler that will return HTML page that renders OpenAPI spec from the specified
// GetDocsHandler returns a handler that will return HTML page that renders OpenAPI spec from the specified
// `openAPIYamlPath` URL.
// The handler format is suitable for passing it directly
// into Adapter.Handle() method or using with huma.StreamResponse
func GetDocsAdapterHandler(humaApi huma.API, openAPIYamlPath string) func(ctx huma.Context) {
return func(ctx huma.Context) {
ctx.SetHeader("Content-Type", "text/html")
title := "Elements in HTML"

if oaTitle := getOpenApiTitle(humaApi); oaTitle != "" {
title = oaTitle + " Reference"
}
_, _ = ctx.BodyWriter().Write([]byte(`<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="referrer" content="same-origin" />
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
<title>` + title + `</title>
<!-- Embed elements Elements via Web Component -->
<link href="https://unpkg.com/@stoplight/elements@8.1.0/styles.min.css" rel="stylesheet" />
<script src="https://unpkg.com/@stoplight/elements@8.1.0/web-components.min.js"
integrity="sha256-985sDMZYbGa0LDS8jYmC4VbkVlh7DZ0TWejFv+raZII="
crossorigin="anonymous"></script>
</head>
<body style="height: 100vh;">
<elements-api
apiDescriptionUrl="` + openAPIYamlPath + `"
router="hash"
layout="sidebar"
tryItCredentialsPolicy="same-origin"
/>
</body>
</html>`))
// The handler format is suitable for passing it to huma or hureg registration functions.
func GetDocsHandler(openAPI *huma.OpenAPI, openAPIYamlPath string) StreamResponseHandler[*struct{}] {
tmpl, err := template.ParseFS(tmplFS, "docs_page.gohtml")
if err != nil {
panic(err)
}

return func(ctx context.Context, _ *struct{}) (*huma.StreamResponse, error) {
pageData := getPageData(openAPI, openAPIYamlPath)

return &huma.StreamResponse{
Body: func(ctx huma.Context) {
ctx.SetHeader("Content-Type", "text/html")
_ = tmpl.Execute(ctx.BodyWriter(), pageData)
},
}, nil
}
}

// GetDocsTypedHandler returns a handler that will return HTML page that renders OpenAPI spec from the specified
// `openAPIYamlPath` URL.
// The handler format is suitable for passing it to huma or hureg registration functions.
func GetDocsTypedHandler(humaApi huma.API, openAPIYamlPath string) TypedStreamHandler {
adapterHandler := GetDocsAdapterHandler(humaApi, openAPIYamlPath)
return getTypedStreamHandler(adapterHandler)
func getPageData(openAPI *huma.OpenAPI, openAPIYamlPath string) (res pageData) {
res.Title = "Elements in HTML"
if openAPI.Info != nil && openAPI.Info.Title != "" {
res.Title = openAPI.Info.Title + " Reference"
}
res.OpenAPIYamlPath = openAPIYamlPath
return res
}
Loading

0 comments on commit be8c341

Please sign in to comment.