diff --git a/autoconfig.go b/autoconfig.go new file mode 100644 index 00000000..f0c7f750 --- /dev/null +++ b/autoconfig.go @@ -0,0 +1,20 @@ +package huma + +// AutoConfigVar represents a variable given by the user when prompted during +// auto-configuration setup of an API. +type AutoConfigVar struct { + Description string `json:"description,omitempty"` + Example string `json:"example,omitempty"` + Default interface{} `json:"default,omitempty"` + Enum []interface{} `json:"enum,omitempty"` +} + +// AutoConfig holds an API's automatic configuration settings for the CLI. These +// are advertised via OpenAPI extension and picked up by the CLI to make it +// easier to get started using an API. +type AutoConfig struct { + Security string `json:"security"` + Headers map[string]string `json:"headers,omitempty"` + Prompt map[string]AutoConfigVar `json:"prompt,omitempty"` + Params map[string]string `json:"params"` +} diff --git a/openapi.go b/openapi.go index fb85461c..796ec4cf 100644 --- a/openapi.go +++ b/openapi.go @@ -1,6 +1,9 @@ package huma import ( + "fmt" + "reflect" + "github.com/istreamlabs/huma/schema" ) @@ -41,3 +44,77 @@ type oaParam struct { // params sent between a load balander / proxy and the service internally. Internal bool `json:"-"` } + +type oaComponents struct { + Schemas map[string]*schema.Schema `json:"schemas,omitempty"` + SecuritySchemes map[string]oaSecurityScheme `json:"securitySchemes,omitempty"` +} + +func (c *oaComponents) AddSchema(t reflect.Type, mode schema.Mode, hint string) string { + // Try to determine the type's name. + name := t.Name() + if name == "" && t.Kind() == reflect.Ptr { + // Take the name of the pointed-to type. + name = t.Elem().Name() + } + if name == "" && t.Kind() == reflect.Slice { + // Take the name of the type in the array and append "List" to it. + tmp := t.Elem() + if tmp.Kind() == reflect.Ptr { + tmp = tmp.Elem() + } + name = tmp.Name() + if name != "" { + name += "List" + } + } + if name == "" { + // No luck, fall back to the passed-in hint. Better than nothing. + name = hint + } + + s, err := schema.GenerateWithMode(t, mode, nil) + if err != nil { + panic(err) + } + + orig := name + num := 1 + for { + if c.Schemas[name] == nil { + // No existing schema, we are the first! + break + } + + if reflect.DeepEqual(c.Schemas[name], s) { + // Existing schema matches! + break + } + + // If we are here, then an existing schema doesn't match and this is a new + // type. So we will rename it in a deterministic fashion. + num++ + name = fmt.Sprintf("%s%d", orig, num) + } + + c.Schemas[name] = s + + return "#/components/schemas/" + name +} + +type oaFlow struct { + AuthorizationURL string `json:"authorizationUrl,omitempty"` + TokenURL string `json:"tokenUrl,omitempty"` + Scopes map[string]string `json:"scopes,omitempty"` +} + +type oaFlows struct { + ClientCredentials *oaFlow `json:"clientCredentials,omitempty"` + AuthorizationCode *oaFlow `json:"authorizationCode,omitempty"` +} + +type oaSecurityScheme struct { + Type string `json:"type"` + Scheme string `json:"scheme,omitempty"` + Flows oaFlows `json:"flows,omitempty"` +} diff --git a/openapi_test.go b/openapi_test.go new file mode 100644 index 00000000..25343ae3 --- /dev/null +++ b/openapi_test.go @@ -0,0 +1,48 @@ +package huma + +import ( + "reflect" + "testing" + + "github.com/istreamlabs/huma/schema" + "github.com/stretchr/testify/assert" +) + +type componentFoo struct { + Field string `json:"field"` + Another string `json:"another" readOnly:"true"` +} + +type componentBar struct { + Field string `json:"field"` +} + +func TestComponentSchemas(t *testing.T) { + components := oaComponents{ + Schemas: map[string]*schema.Schema{}, + } + + // Adding two different versions of the same component. + ref := components.AddSchema(reflect.TypeOf(&componentFoo{}), schema.ModeRead, "hint") + assert.Equal(t, ref, "#/components/schemas/componentFoo") + assert.NotNil(t, components.Schemas["componentFoo"]) + + ref = components.AddSchema(reflect.TypeOf(&componentFoo{}), schema.ModeWrite, "hint") + assert.Equal(t, ref, "#/components/schemas/componentFoo2") + assert.NotNil(t, components.Schemas["componentFoo2"]) + + // Re-adding the second should not create a third. + ref = components.AddSchema(reflect.TypeOf(&componentFoo{}), schema.ModeWrite, "hint") + assert.Equal(t, ref, "#/components/schemas/componentFoo2") + assert.Nil(t, components.Schemas["componentFoo3"]) + + // Adding a list of pointers to a struct. + ref = components.AddSchema(reflect.TypeOf([]*componentBar{}), schema.ModeAll, "hint") + assert.Equal(t, ref, "#/components/schemas/componentBarList") + assert.NotNil(t, components.Schemas["componentBarList"]) + + // Adding an anonymous empty struct, should use the hint. + ref = components.AddSchema(reflect.TypeOf(struct{}{}), schema.ModeAll, "hint") + assert.Equal(t, ref, "#/components/schemas/hint") + assert.NotNil(t, components.Schemas["hint"]) +} diff --git a/operation.go b/operation.go index a3e159ac..babeea63 100644 --- a/operation.go +++ b/operation.go @@ -43,7 +43,7 @@ func newOperation(resource *Resource, method, id, docs string, responses []Respo } } -func (o *Operation) toOpenAPI() *gabs.Container { +func (o *Operation) toOpenAPI(components *oaComponents) *gabs.Container { doc := gabs.New() doc.Set(o.id, "operationId") @@ -98,11 +98,8 @@ func (o *Operation) toOpenAPI() *gabs.Container { } if resp.model != nil { - schema, err := schema.GenerateWithMode(resp.model, schema.ModeRead, nil) - if err != nil { - panic(err) - } - doc.Set(schema, "responses", status, "content", resp.contentType, "schema") + ref := components.AddSchema(resp.model, schema.ModeRead, o.id) + doc.Set(ref, "responses", status, "content", resp.contentType, "schema", "$ref") } } diff --git a/resource.go b/resource.go index 1eede6bd..b9498422 100644 --- a/resource.go +++ b/resource.go @@ -22,15 +22,15 @@ type Resource struct { tags []string } -func (r *Resource) toOpenAPI() *gabs.Container { +func (r *Resource) toOpenAPI(components *oaComponents) *gabs.Container { doc := gabs.New() for _, sub := range r.subResources { - doc.Merge(sub.toOpenAPI()) + doc.Merge(sub.toOpenAPI(components)) } for _, op := range r.operations { - opValue := op.toOpenAPI() + opValue := op.toOpenAPI(components) if len(r.tags) > 0 { opValue.Set(r.tags, "tags") diff --git a/router.go b/router.go index 4995f1d9..d64e83f0 100644 --- a/router.go +++ b/router.go @@ -10,6 +10,7 @@ import ( "github.com/Jeffail/gabs/v2" "github.com/go-chi/chi" + "github.com/istreamlabs/huma/schema" ) type contextKey string @@ -32,13 +33,15 @@ type Router struct { mux *chi.Mux resources []*Resource - title string - version string - description string - contact oaContact - servers []oaServer - // securitySchemes - // security + title string + version string + description string + contact oaContact + servers []oaServer + securitySchemes map[string]oaSecurityScheme + security map[string][]string + + autoConfig *AutoConfig // Documentation handler function docsPrefix string @@ -70,9 +73,24 @@ func (r *Router) OpenAPI() *gabs.Container { doc.Set(r.description, "info", "description") } + components := &oaComponents{ + Schemas: map[string]*schema.Schema{}, + SecuritySchemes: r.securitySchemes, + } + paths, _ := doc.Object("paths") for _, res := range r.resources { - paths.Merge(res.toOpenAPI()) + paths.Merge(res.toOpenAPI(components)) + } + + doc.Set(components, "components") + + if len(r.security) > 0 { + doc.Set(r.security, "security") + } + + if r.autoConfig != nil { + doc.Set(r.autoConfig, "x-cli-config") } if r.openapiHook != nil { @@ -97,6 +115,56 @@ func (r *Router) ServerLink(description, uri string) { }) } +// GatewayBasicAuth documents that the API gateway handles auth using HTTP Basic. +func (r *Router) GatewayBasicAuth(name string) { + r.securitySchemes[name] = oaSecurityScheme{ + Type: "http", + Scheme: "basic", + } +} + +// GatewayClientCredentials documents that the API gateway handles auth using +// OAuth2 client credentials (pre-shared secret). +func (r *Router) GatewayClientCredentials(name, tokenURL string, scopes map[string]string) { + r.securitySchemes[name] = oaSecurityScheme{ + Type: "oauth2", + Flows: oaFlows{ + ClientCredentials: &oaFlow{ + TokenURL: tokenURL, + Scopes: scopes, + }, + }, + } +} + +// GatewayAuthCode documents that the API gateway handles auth using +// OAuth2 authorization code (user login). +func (r *Router) GatewayAuthCode(name, authorizeURL, tokenURL string, scopes map[string]string) { + r.securitySchemes[name] = oaSecurityScheme{ + Type: "oauth2", + Flows: oaFlows{ + AuthorizationCode: &oaFlow{ + AuthorizationURL: authorizeURL, + TokenURL: tokenURL, + Scopes: scopes, + }, + }, + } +} + +// AutoConfig sets up CLI autoconfiguration via `x-cli-config` for use by CLI +// clients, e.g. using a tool like Restish (https://rest.sh/). +func (r *Router) AutoConfig(autoConfig AutoConfig) { + r.autoConfig = &autoConfig +} + +// SecurityRequirement sets up a security requirement for the entire API by +// name and with the given scopes. Use together with the other auth options +// like GatewayAuthCode. +func (r *Router) SecurityRequirement(name string, scopes ...string) { + r.security[name] = scopes +} + // Resource creates a new resource attached to this router at the given path. // The path can include parameters, e.g. `/things/{thing-id}`. Each resource // path must be unique. @@ -249,12 +317,14 @@ func New(docs, version string) *Router { title, desc := splitDocs(docs) r := &Router{ - mux: chi.NewRouter(), - resources: []*Resource{}, - title: title, - description: desc, - version: version, - servers: []oaServer{}, + mux: chi.NewRouter(), + resources: []*Resource{}, + title: title, + description: desc, + version: version, + servers: []oaServer{}, + securitySchemes: map[string]oaSecurityScheme{}, + security: map[string][]string{}, } r.docsHandler = RapiDocHandler(r) diff --git a/router_test.go b/router_test.go index 1886f087..fb1320b7 100644 --- a/router_test.go +++ b/router_test.go @@ -2,6 +2,7 @@ package huma import ( "bytes" + "encoding/json" "io" "io/ioutil" "net/http" @@ -240,3 +241,99 @@ func TestInvalidPathParam(t *testing.T) { }) }) } + +func TestRouterSecurity(t *testing.T) { + app := newTestRouter() + + // Document that the API gateway handles auth via OAuth2 Authorization Code. + app.GatewayAuthCode("default", "https://example.com/authorize", "https://example.com/token", nil) + app.GatewayClientCredentials("m2m", "https://example.com/token", nil) + app.GatewayBasicAuth("basic") + + // Every call must be authenticated using the default auth mechanism + // registered above. + app.SecurityRequirement("default") + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/openapi.json", nil) + app.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var parsed map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &parsed) + assert.Nil(t, err) + + assert.Equal(t, parsed["security"], map[string]interface{}{ + "default": nil, + }) + + assert.Equal(t, parsed["components"].(map[string]interface{})["securitySchemes"], map[string]interface{}{ + "default": map[string]interface{}{ + "type": "oauth2", + "flows": map[string]interface{}{ + "authorizationCode": map[string]interface{}{ + "authorizationUrl": "https://example.com/authorize", + "tokenUrl": "https://example.com/token", + }, + }, + }, + "m2m": map[string]interface{}{ + "type": "oauth2", + "flows": map[string]interface{}{ + "clientCredentials": map[string]interface{}{ + "tokenUrl": "https://example.com/token", + }, + }, + }, + "basic": map[string]interface{}{ + "type": "http", + "scheme": "basic", + "flows": map[string]interface{}{}, + }, + }) +} + +// TODO: test app.AutoConfig +func TestRouterAutoConfig(t *testing.T) { + app := newTestRouter() + + app.GatewayAuthCode("authcode", "https://example.com/authorize", "https://example.com/token", nil) + app.SecurityRequirement("authcode") + + app.AutoConfig(AutoConfig{ + Security: "authcode", + Prompt: map[string]AutoConfigVar{ + "extra": { + Description: "Some extra value", + Example: "abc123", + }, + }, + Params: map[string]string{ + "another": "https://example.com/extras/{extra}", + }, + }) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/openapi.json", nil) + app.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var parsed map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &parsed) + assert.Nil(t, err) + + assert.Equal(t, parsed["x-cli-config"], map[string]interface{}{ + "security": "authcode", + "prompt": map[string]interface{}{ + "extra": map[string]interface{}{ + "description": "Some extra value", + "example": "abc123", + }, + }, + "params": map[string]interface{}{ + "another": "https://example.com/extras/{extra}", + }, + }) +}