Skip to content
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
3 changes: 3 additions & 0 deletions adapters/humachi/humachi_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,9 @@ func TestChiRouterPrefix(t *testing.T) {
mux.Route("/api", func(r chi.Router) {
config := huma.DefaultConfig("My API", "1.0.0")
config.Servers = []*huma.Server{{URL: "http://localhost:8888/api"}}
// config.CreateHooks = []func(huma.Config) huma.Config{
// huma.DefaultSchemaLinkHook("/api"),
// }
api = New(r, config)
})

Expand Down
36 changes: 28 additions & 8 deletions api.go
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,15 @@ type Config struct {
// them here does nothing.
DocsRendererConfig any

// DocsAdapter is an optional [Adapter] that will be used to serve the API
// documentation. If unset, the main adapter will be used.
// Setting this allows you to use a different router or middleware stack for
// the documentation.
// Note that if this [Adapter] has a different path than the main one, the
// default CreateHook set in [DefaultConfig] needs to be modified to account
// for it.
DocsAdapter Adapter

// SchemasPath is the path to the API schemas. If set to `/schemas` it will
// allow clients to get `/schemas/{schema}` to view the schema in a browser
// or for use in editors like VSCode to provide autocomplete & validation.
Expand Down Expand Up @@ -510,7 +519,7 @@ func parseContentType(contentType string) (int, int, error) {
// config := huma.DefaultConfig("Example API", "1.0.0")
// api := huma.NewAPI(config, adapter)
func NewAPI(config Config, a Adapter) API {
for i := 0; i < len(config.CreateHooks); i++ {
for i := range config.CreateHooks {
config = config.CreateHooks[i](config)
}

Expand Down Expand Up @@ -556,9 +565,14 @@ func NewAPI(config Config, a Adapter) API {
newAPI.formatKeys = append(newAPI.formatKeys, k)
}

docsAdapter := newAPI.adapter
if config.DocsAdapter != nil {
docsAdapter = config.DocsAdapter
}

if config.OpenAPIPath != "" {
var specJSON []byte
a.Handle(&Operation{
docsAdapter.Handle(&Operation{
Method: http.MethodGet,
Path: config.OpenAPIPath + ".json",
}, func(ctx Context) {
Expand All @@ -570,7 +584,7 @@ func NewAPI(config Config, a Adapter) API {
})

var specJSON30 []byte
a.Handle(&Operation{
docsAdapter.Handle(&Operation{
Method: http.MethodGet,
Path: config.OpenAPIPath + "-3.0.json",
}, func(ctx Context) {
Expand All @@ -582,7 +596,7 @@ func NewAPI(config Config, a Adapter) API {
})

var specYAML []byte
a.Handle(&Operation{
docsAdapter.Handle(&Operation{
Method: http.MethodGet,
Path: config.OpenAPIPath + ".yaml",
}, func(ctx Context) {
Expand All @@ -594,7 +608,7 @@ func NewAPI(config Config, a Adapter) API {
})

var specYAML30 []byte
a.Handle(&Operation{
docsAdapter.Handle(&Operation{
Method: http.MethodGet,
Path: config.OpenAPIPath + "-3.0.yaml",
}, func(ctx Context) {
Expand All @@ -611,7 +625,7 @@ func NewAPI(config Config, a Adapter) API {
}

if config.SchemasPath != "" {
a.Handle(&Operation{
docsAdapter.Handle(&Operation{
Method: http.MethodGet,
Path: config.SchemasPath + "/{schema}",
}, func(ctx Context) {
Expand All @@ -628,8 +642,14 @@ func NewAPI(config Config, a Adapter) API {
}

func (a *api) registerDocsRoute() {
docsAdapter := a.adapter
openAPIPath := a.config.OpenAPIPath
if prefix := getAPIPrefix(a.OpenAPI()); prefix != "" {
if a.config.DocsAdapter != nil {
// The API prefix describes the main adapter, not the docs adapter, so
// link to the spec relative to the docs page instead.
docsAdapter = a.config.DocsAdapter
openAPIPath = strings.TrimPrefix(openAPIPath, "/")
} else if prefix := getAPIPrefix(a.OpenAPI()); prefix != "" {
openAPIPath = path.Join(prefix, openAPIPath)
}

Expand Down Expand Up @@ -780,7 +800,7 @@ func (a *api) registerDocsRoute() {
panic("missing CSP for docs renderer: " + a.config.DocsRenderer)
}

a.adapter.Handle(&Operation{
docsAdapter.Handle(&Operation{
Method: http.MethodGet,
Path: a.config.DocsPath,
}, func(ctx Context) {
Expand Down
32 changes: 32 additions & 0 deletions api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package huma_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"

"github.com/danielgtaylor/huma/v2"
Expand Down Expand Up @@ -332,6 +333,37 @@ func TestDocsRenderers(t *testing.T) {
assert.Contains(t, resp.Body.String(), `apiDescriptionUrl="/api/v1/openapi.yaml"`)
})

t.Run("DocsAdapter", func(t *testing.T) {
docsAdapter := humatest.NewAdapter()
_, api := humatest.New(t, huma.Config{
OpenAPI: &huma.OpenAPI{
Info: &huma.Info{Title: "Test API", Version: "1.0.0"},
Servers: []*huma.Server{
{URL: "https://example.com/api/v1"},
},
},
DocsPath: "/docs",
DocsAdapter: docsAdapter,
OpenAPIPath: "/openapi",
Formats: huma.DefaultFormats,
})

// The main adapter does not serve the docs or the spec.
assert.Equal(t, http.StatusNotFound, api.Get("/docs").Code)
assert.Equal(t, http.StatusNotFound, api.Get("/openapi.yaml").Code)

w := httptest.NewRecorder()
docsAdapter.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/docs", nil))
assert.Equal(t, http.StatusOK, w.Code)
// The server prefix applies to the main adapter only, so the spec URL is
// relative to the docs page.
assert.Contains(t, w.Body.String(), `apiDescriptionUrl="openapi.yaml"`)

w = httptest.NewRecorder()
docsAdapter.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/openapi.yaml", nil))
assert.Equal(t, http.StatusOK, w.Code)
})

t.Run("APIPrefixWithServerVars", func(t *testing.T) {
_, api := humatest.New(t, huma.Config{
OpenAPI: &huma.OpenAPI{
Expand Down
34 changes: 19 additions & 15 deletions defaults.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import (
"io"
)

// DefaultSchemaNamer is the default prefix used to reference schemas in the API spec.
const DefaultSchemaRefPrefix = "#/components/schemas/"

// DefaultJSONFormat is the default JSON formatter that can be set in the API's
// `Config.Formats` map. This is used by the `DefaultConfig` function.
//
Expand Down Expand Up @@ -53,10 +56,7 @@ var DefaultFormats = map[string]Format{
//
// import _ "github.com/danielgtaylor/huma/v2/formats/cbor"
func DefaultConfig(title string, version string) Config {
schemaPrefix := "#/components/schemas/"
schemasPath := "/schemas"

registry := NewMapRegistry(schemaPrefix, DefaultSchemaNamer)
registry := NewMapRegistry(DefaultSchemaRefPrefix, DefaultSchemaNamer)

return Config{
OpenAPI: &OpenAPI{
Expand All @@ -73,23 +73,27 @@ func DefaultConfig(title string, version string) Config {
registryConfig: getConfig[registryConfig](registry),
DocsPath: "/docs",
DocsRenderer: DocsRendererStoplightElements,
SchemasPath: schemasPath,
SchemasPath: "/schemas",
Formats: DefaultFormats,
DefaultFormat: "application/json",
NoFormatFallback: false,
RejectUnknownQueryParameters: false,
Transformers: nil,
CreateHooks: []func(Config) Config{
func(c Config) Config {
// Add a link transformer to the API. This adds `Link` headers and
// puts `$schema` fields in the response body which point to the JSON
// Schema that describes the response structure.
// This is a create hook so we get the latest schema path setting.
linkTransformer := NewSchemaLinkTransformer(schemaPrefix, c.SchemasPath)
c.OnAddOperation = append(c.OnAddOperation, linkTransformer.OnAddOperation)
c.Transformers = append(c.Transformers, linkTransformer.Transform)
return c
},
DefaultSchemaLinkHook(""), // assume schemas are on mounted the root
},
}
}

// DefaultSchemaLinkHook adds a link transformer to the API.
// This adds `Link` headers and puts `$schema` fields in the response body which
// point to the JSON Schema that describes the response structure.
// This is a create hook so we get the latest schema path setting.
func DefaultSchemaLinkHook(schemaPathPrefix string) func(c Config) Config {
return func(c Config) Config {
linkTransformer := NewSchemaLinkTransformer(schemaPathPrefix, c.SchemasPath)
c.OnAddOperation = append(c.OnAddOperation, linkTransformer.OnAddOperation)
c.Transformers = append(c.Transformers, linkTransformer.Transform)
return c
}
}
12 changes: 7 additions & 5 deletions transforms.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ type schemaField struct {
// VSCode.
type SchemaLinkTransformer struct {
oapi *OpenAPI
prefix string
apiPrefix string
schemasPath string
types map[any][]schemaLinkType
}
Expand All @@ -40,9 +40,9 @@ type schemaLinkType struct {
// to understand the structure of the response and enables things like
// as-you-type validation & completion of HTTP resources in editors like
// VSCode.
func NewSchemaLinkTransformer(prefix, schemasPath string) *SchemaLinkTransformer {
func NewSchemaLinkTransformer(apiPrefix, schemasPath string) *SchemaLinkTransformer {
return &SchemaLinkTransformer{
prefix: prefix,
apiPrefix: apiPrefix,
schemasPath: schemasPath,
types: map[any][]schemaLinkType{},
}
Expand Down Expand Up @@ -96,8 +96,10 @@ func (t *SchemaLinkTransformer) OnAddOperation(oapi *OpenAPI, op *Operation) {
// Figure out if there should be a base path prefix. This might be set when
// using a sub-router / group or if the gateway consumes a part of the path.
schemasPath := t.schemasPath
if prefix := getAPIPrefix(oapi); prefix != "" {
schemasPath = path.Join(prefix, schemasPath)
if apiPrefix := getAPIPrefix(oapi); apiPrefix != "" {
schemasPath = path.Join(apiPrefix, schemasPath)
} else if t.apiPrefix != "" {
schemasPath = path.Join(t.apiPrefix, schemasPath)
}
Comment on lines +99 to 103

registry := oapi.Components.Schemas
Expand Down
Loading