diff --git a/api.go b/api.go index 2e85b663..1e0c9f82 100644 --- a/api.go +++ b/api.go @@ -226,6 +226,18 @@ type Config struct { // them here does nothing. DocsRendererConfig any + // DocsAdapter is an optional [Adapter] that serves the OpenAPI spec, the + // docs and the schemas. If unset, the main adapter serves them. Use it to + // put them on a different router or middleware stack, e.g. to hide them + // from the public API. + // + // The responses of the main API link to the schemas. If this adapter serves + // the schemas, replace the default create hook with [DefaultSchemaLinkHook] + // and give it the path where this adapter is mounted. Huma cannot detect + // this path, and the links work only if this adapter is on the same host + // as the main API. + 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. @@ -556,9 +568,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) { @@ -570,7 +587,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) { @@ -582,7 +599,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) { @@ -594,7 +611,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) { @@ -611,7 +628,13 @@ func NewAPI(config Config, a Adapter) API { } if config.SchemasPath != "" { - a.Handle(&Operation{ + schemaRef := []byte(config.SchemasPath + `/$1.json`) + if config.DocsAdapter != nil { + // The docs adapter can be mounted under any path, so nested refs are + // relative to the schema that contains them. + schemaRef = []byte(`$1.json`) + } + docsAdapter.Handle(&Operation{ Method: http.MethodGet, Path: config.SchemasPath + "/{schema}", }, func(ctx Context) { @@ -619,7 +642,7 @@ func NewAPI(config Config, a Adapter) API { schema := strings.TrimSuffix(ctx.Param("schema"), ".json") ctx.SetHeader("Content-Type", "application/json") b, _ := json.Marshal(config.Components.Schemas.Map()[schema]) - b = rxSchema.ReplaceAll(b, []byte(config.SchemasPath+`/$1.json`)) + b = rxSchema.ReplaceAll(b, schemaRef) ctx.BodyWriter().Write(b) }) } @@ -628,8 +651,16 @@ 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. Each segment of + // the docs path after the first one needs a step up. + docsAdapter = a.config.DocsAdapter + up := strings.Repeat("../", max(strings.Count(a.config.DocsPath, "/")-1, 0)) + openAPIPath = up + strings.TrimPrefix(openAPIPath, "/") + } else if prefix := getAPIPrefix(a.OpenAPI()); prefix != "" { openAPIPath = path.Join(prefix, openAPIPath) } @@ -780,7 +811,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) { diff --git a/api_test.go b/api_test.go index 85b3964e..61cb68be 100644 --- a/api_test.go +++ b/api_test.go @@ -3,6 +3,7 @@ package huma_test import ( "context" "net/http" + "net/http/httptest" "testing" "github.com/danielgtaylor/huma/v2" @@ -466,3 +467,152 @@ func TestDocsRenderers(t *testing.T) { }) }) } + +func TestDocsAdapter(t *testing.T) { + t.Run("Docs", 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("NestedDocsPath", func(t *testing.T) { + for docsPath, specURL := range map[string]string{ + "/reference/docs": "../openapi.yaml", + "/docs/": "../openapi.yaml", + "/a/b/docs": "../../openapi.yaml", + } { + docsAdapter := humatest.NewAdapter() + humatest.New(t, huma.Config{ + OpenAPI: &huma.OpenAPI{Info: &huma.Info{Title: "Test API", Version: "1.0.0"}}, + DocsPath: docsPath, + DocsAdapter: docsAdapter, + OpenAPIPath: "/openapi", + Formats: huma.DefaultFormats, + }) + + w := httptest.NewRecorder() + docsAdapter.ServeHTTP(w, httptest.NewRequest(http.MethodGet, docsPath, nil)) + assert.Equal(t, http.StatusOK, w.Code, docsPath) + assert.Contains(t, w.Body.String(), `apiDescriptionUrl="`+specURL+`"`, docsPath) + } + }) + + t.Run("NestedSchemaRef", func(t *testing.T) { + type Inner struct { + Value string `json:"value"` + } + type Outer struct { + Inner Inner `json:"inner"` + } + + docsAdapter := humatest.NewAdapter() + config := huma.DefaultConfig("Test API", "1.0.0") + config.DocsAdapter = docsAdapter + config.CreateHooks = []func(huma.Config) huma.Config{ + huma.DefaultSchemaLinkHook("/internal"), + } + _, api := humatest.New(t, config) + + huma.Get(api, "/outer", func(ctx context.Context, input *struct{}) (*struct{ Body Outer }, error) { + return &struct{ Body Outer }{}, nil + }) + + // The nested ref is relative, so it works under any mount path. + w := httptest.NewRecorder() + docsAdapter.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/schemas/Outer.json", nil)) + assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), `"$ref":"Inner.json"`) + }) + + t.Run("Schemas", func(t *testing.T) { + type Greeting struct { + Message string `json:"message"` + } + + docsAdapter := humatest.NewAdapter() + config := huma.DefaultConfig("Test API", "1.0.0") + config.Servers = []*huma.Server{{URL: "https://api.example.com/api/v1"}} + config.DocsAdapter = docsAdapter + config.CreateHooks = []func(huma.Config) huma.Config{ + huma.DefaultSchemaLinkHook("/internal"), + } + _, api := humatest.New(t, config) + + huma.Get(api, "/greeting", func(ctx context.Context, input *struct{}) (*struct{ Body Greeting }, error) { + return &struct{ Body Greeting }{Body: Greeting{Message: "hello"}}, nil + }) + + // The hook prefix replaces the server prefix, because the docs adapter + // serves the schemas. + resp := api.Get("/greeting") + assert.Equal(t, http.StatusOK, resp.Code) + assert.Equal(t, `; rel="describedBy"`, resp.Header().Get("Link")) + assert.Contains(t, resp.Body.String(), `"$schema":"https://api.example.com/internal/schemas/Greeting.json"`) + + schema := api.OpenAPI().Components.Schemas.Map()["Greeting"] + assert.Equal(t, []any{"https://api.example.com/internal/schemas/Greeting.json"}, schema.Properties["$schema"].Examples) + + // The docs adapter serves the schemas, the main adapter does not. + assert.Equal(t, http.StatusNotFound, api.Get("/schemas/Greeting.json").Code) + w := httptest.NewRecorder() + docsAdapter.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/schemas/Greeting.json", nil)) + assert.Equal(t, http.StatusOK, w.Code) + }) + + t.Run("SchemaLinkPrefixRoot", func(t *testing.T) { + config := huma.DefaultConfig("Test API", "1.0.0") + config.Servers = []*huma.Server{{URL: "https://api.example.com/api/v1"}} + config.DocsAdapter = humatest.NewAdapter() + config.CreateHooks = []func(huma.Config) huma.Config{ + huma.DefaultSchemaLinkHook("/"), + } + _, api := humatest.New(t, config) + + huma.Get(api, "/greeting", func(ctx context.Context, input *struct{}) (*struct{ Body struct{} }, error) { + return &struct{ Body struct{} }{}, nil + }) + + resp := api.Get("/greeting") + assert.Equal(t, http.StatusOK, resp.Code) + assert.Equal(t, `; rel="describedBy"`, resp.Header().Get("Link")) + }) + + t.Run("SchemaLinkPrefixMissing", func(t *testing.T) { + config := huma.DefaultConfig("Test API", "1.0.0") + config.DocsAdapter = humatest.NewAdapter() + assert.PanicsWithValue(t, `DocsAdapter serves the schemas, so DefaultSchemaLinkHook needs the path where DocsAdapter is mounted (use "/" for the root)`, func() { + humatest.New(t, config) + }) + }) + + t.Run("SchemaLinkPrefixInvalid", func(t *testing.T) { + assert.PanicsWithValue(t, "schema link path prefix must start with a slash: internal", func() { + huma.DefaultSchemaLinkHook("internal") + }) + }) +} diff --git a/defaults.go b/defaults.go index 79a01db8..9c6aa5f4 100644 --- a/defaults.go +++ b/defaults.go @@ -3,6 +3,7 @@ package huma import ( "encoding/json" "io" + "strings" ) // DefaultJSONFormat is the default JSON formatter that can be set in the API's @@ -80,16 +81,41 @@ func DefaultConfig(title string, version string) Config { 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(""), }, } } + +// DefaultSchemaLinkHook returns a create hook that adds a link transformer to +// the API. The transformer adds `Link` headers and `$schema` fields to response +// bodies. These point to the JSON Schema that describes the response +// structure. It is a create hook so it gets the latest schema path setting. +// +// The pathPrefix is the path where the adapter that serves the schemas is +// mounted, from the root of the host, e.g. `/internal`. Use `/` for the root. +// If it is empty, the path of the first server URL is used. You must set it +// when [Config.DocsAdapter] serves the schemas, because the server URLs +// describe the main API only. +// +// config := huma.DefaultConfig("My API", "1.0.0") +// config.DocsAdapter = humachi.NewAdapter(internalRouter) +// config.CreateHooks = []func(huma.Config) huma.Config{ +// huma.DefaultSchemaLinkHook("/internal"), +// } +func DefaultSchemaLinkHook(pathPrefix string) func(Config) Config { + if pathPrefix != "" && !strings.HasPrefix(pathPrefix, "/") { + panic("schema link path prefix must start with a slash: " + pathPrefix) + } + + return func(c Config) Config { + if c.DocsAdapter != nil && c.SchemasPath != "" && pathPrefix == "" { + panic(`DocsAdapter serves the schemas, so DefaultSchemaLinkHook needs the path where DocsAdapter is mounted (use "/" for the root)`) + } + + linkTransformer := NewSchemaLinkTransformer(schemaRefPrefix, c.SchemasPath) + linkTransformer.pathPrefix = pathPrefix + c.OnAddOperation = append(c.OnAddOperation, linkTransformer.OnAddOperation) + c.Transformers = append(c.Transformers, linkTransformer.Transform) + return c + } +} diff --git a/transforms.go b/transforms.go index 10d3d6f0..efa92983 100644 --- a/transforms.go +++ b/transforms.go @@ -24,6 +24,10 @@ type SchemaLinkTransformer struct { prefix string schemasPath string types map[any][]schemaLinkType + + // pathPrefix replaces the API prefix from the server URL when it is set. + // See DefaultSchemaLinkHook. + pathPrefix string } type schemaLinkType struct { @@ -68,12 +72,19 @@ func (t *SchemaLinkTransformer) addSchemaField(oapi *OpenAPI, content *MediaType } } + schemasPath := t.schemasPath + if t.pathPrefix != "" { + // The path prefix replaces the path of the server URL. + server = serverOrigin(server) + schemasPath = path.Join(t.pathPrefix, schemasPath) + } + schema.Properties["$schema"] = &Schema{ Type: TypeString, Format: "uri", Description: "A URL to the JSON Schema for this object.", ReadOnly: true, - Examples: []any{server + t.schemasPath + "/" + path.Base(content.Schema.Ref) + ".json"}, + Examples: []any{server + schemasPath + "/" + path.Base(content.Schema.Ref) + ".json"}, } return false @@ -96,7 +107,9 @@ 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 != "" { + if t.pathPrefix != "" { + schemasPath = path.Join(t.pathPrefix, schemasPath) + } else if prefix := getAPIPrefix(oapi); prefix != "" { schemasPath = path.Join(prefix, schemasPath) } @@ -200,6 +213,10 @@ func (t *SchemaLinkTransformer) Transform(ctx Context, _ string, v any) (any, er if schemaURL == "" || strings.HasPrefix(schemaURL, "localhost") { if t.oapi != nil && len(t.oapi.Servers) > 0 && t.oapi.Servers[0].URL != "" { serverURL := t.oapi.Servers[0].URL + if t.pathPrefix != "" { + // The ref already starts with the path prefix. + serverURL = serverOrigin(serverURL) + } if !strings.HasPrefix(serverURL, "/") { schemaURL = serverURL } @@ -257,3 +274,18 @@ func getSchemaHost(ctx Context) string { return ctx.Host() } + +// serverOrigin returns the server URL without its path, e.g. +// `https://example.com` for `https://example.com/api/v1`. It works on the raw +// URL, so server variables like `{port}` are kept as they are. +func serverOrigin(server string) string { + if strings.HasPrefix(server, "/") { + return "" + } + if i := strings.Index(server, "://"); i >= 0 { + if j := strings.IndexByte(server[i+3:], '/'); j >= 0 { + return server[:i+3+j] + } + } + return server +}