Skip to content

Commit

Permalink
Merge pull request #31 from danielgtaylor/schema-link
Browse files Browse the repository at this point in the history
feat: add schema describedby links and $schema property
  • Loading branch information
danielgtaylor authored Mar 21, 2022
2 parents 8498106 + e96f662 commit bf3a8a3
Show file tree
Hide file tree
Showing 11 changed files with 831 additions and 194 deletions.
55 changes: 44 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ Features include:
- SDKs with [OpenAPI Generator](https://github.com/OpenAPITools/openapi-generator)
- CLI with [Restish](https://rest.sh/)
- And [plenty](https://openapi.tools/) [more](https://apis.guru/awesome-openapi3/category.html)
- Generates JSON Schema for each resource using `describedby` link relation headers as well as optional `$schema` properties in returned objects that integrate into editors for validation & completion.

This project was inspired by [FastAPI](https://fastapi.tiangolo.com/). Look at the [benchmarks](https://github.com/danielgtaylor/huma/tree/master/benchmark) to see how Huma compares.

Expand Down Expand Up @@ -788,7 +789,16 @@ app.DocsHandler(huma.ReDocHandler("My API"))

> :whale: Pass a custom handler function to have even more control for branding or browser authentication.
## Custom OpenAPI Fields
## OpenAPI

By default, the generated OpenAPI and autogenerated documentation are served in the root at `/openapi.json` and `/docs` respectively. This default path can be modified:

```go
// Serve `/public/openapi.json and `/public/docs`:
app.DocsPrefix("/public")
```

### Custom OpenAPI Fields

Use the OpenAPI hook for OpenAPI customization. It gives you a `*gabs.Container` instance that represents the root of the OpenAPI document.

Expand All @@ -803,23 +813,28 @@ app.OpenAPIHook(modify)

> :whale: See the [OpenAPI 3 spec](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.3.md) for everything that can be set.
## CLI
### JSON Schema

The `cli` package provides a convenience layer to create a simple CLI for your server, which lets a user set the host, port, TLS settings, etc when running your service.
Each resource operation also returns a `describedby` HTTP link relation which references a JSON-Schema file. These schemas re-use the `DocsPrefix` described above and default to the server root. For example:

```go
app := cli.NewRouter("My API", "1.0.0")
```http
Link: </schemas/Note.json>; rel="describedby"
```

// Do resource/operation setup here...
Object resources (i.e. not arrays) can also optionally return a `$schema` property with such a link, which enables the described-by relationship to outlive the HTTP request (i.e. saving the body to a file for later editing) and enables some editors like [VSCode](https://code.visualstudio.com/docs/languages/json#_mapping-in-the-json) to provide code completion and validation as you type.

app.Run()
```json
{
"$schema": "http://localhost:8888/schemas/Note.json",
"title": "I am a note title",
"contents": "Example note contents",
"labels": ["todo"]
}
```

Then run the service:
Operations which accept objects as input will ignore the `$schema` property, so it is safe to submit back to the API.

```sh
$ go run yourservice.go --help
```
This feature can be disabled if desired by using `app.DisableSchemaProperty`.

## GraphQL

Expand Down Expand Up @@ -981,6 +996,24 @@ Result:
220 complexity
```

## CLI

The `cli` package provides a convenience layer to create a simple CLI for your server, which lets a user set the host, port, TLS settings, etc when running your service.

```go
app := cli.NewRouter("My API", "1.0.0")

// Do resource/operation setup here...

app.Run()
```

Then run the service:

```sh
$ go run yourservice.go --help
```

## CLI Runtime Arguments & Configuration

The CLI can be configured in multiple ways. In order of decreasing precedence:
Expand Down
67 changes: 61 additions & 6 deletions context.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"github.com/danielgtaylor/huma/negotiation"
"github.com/fxamacker/cbor/v2"
"github.com/goccy/go-yaml"
"github.com/mitchellh/mapstructure"
)

// allowedHeaders is a list of built-in headers that are always allowed without
Expand Down Expand Up @@ -70,10 +71,13 @@ type Context interface {
type hcontext struct {
context.Context
http.ResponseWriter
r *http.Request
errors []error
op *Operation
closed bool
r *http.Request
errors []error
op *Operation
closed bool
docsPrefix string
urlPrefix string
disableSchemaProperty bool
}

func (c *hcontext) WithValue(key, value interface{}) Context {
Expand Down Expand Up @@ -193,8 +197,24 @@ func (c *hcontext) WriteModel(status int, model interface{}) {
c.writeModel(ct, status, model)
}

// URLPrefix returns the prefix to use for non-relative URL links.
func (c *hcontext) URLPrefix() string {
if c.urlPrefix != "" {
return c.urlPrefix
}

scheme := "https"
if strings.HasPrefix(c.r.Host, "localhost") {
scheme = "http"
}

return scheme + "://" + c.r.Host
}

func (c *hcontext) writeModel(ct string, status int, model interface{}) {
// Is this allowed? Find the right response.
modelRef := ""
modelType := reflect.TypeOf(model)
if c.op != nil {
responses := []Response{}
names := []string{}
Expand All @@ -215,14 +235,49 @@ func (c *hcontext) writeModel(ct string, status int, model interface{}) {

found := false
for _, r := range responses {
if r.model == reflect.TypeOf(model) {
if r.model == modelType {
found = true
modelRef = r.modelRef
break
}
}

if !found {
panic(fmt.Errorf("Invalid model %s, expecting %s for %s %s", reflect.TypeOf(model), strings.Join(names, ", "), c.r.Method, c.r.URL.Path))
panic(fmt.Errorf("Invalid model %s, expecting %s for %s %s", modelType, strings.Join(names, ", "), c.r.Method, c.r.URL.Path))
}
}

// If possible, insert a link relation header to the JSON Schema describing
// this response. If it's an object (not an array), then we can also try
// inserting the `$schema` key to make editing & validation easier.
parts := strings.Split(modelRef, "/")
if len(parts) > 0 {
id := parts[len(parts)-1]

link := c.Header().Get("Link")
if link != "" {
link += ", "
}
link += "<" + c.docsPrefix + "/schemas/" + id + ".json>; rel=\"describedby\""
c.Header().Set("Link", link)

if !c.disableSchemaProperty && modelType != nil && modelType.Kind() == reflect.Struct {
tmp := map[string]interface{}{}
decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{
TagName: "json",
Result: &tmp,
})
if err != nil {
panic(fmt.Errorf("Unable to initialize struct decoder: %w", err))
}
err = decoder.Decode(model)
if err != nil {
panic(fmt.Errorf("Unable to convert struct to map: %w", err))
}
if tmp["$schema"] == nil {
tmp["$schema"] = c.URLPrefix() + c.docsPrefix + "/schemas/" + id + ".json"
}
model = tmp
}
}

Expand Down
2 changes: 1 addition & 1 deletion context_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import (
"net/http/httptest"
"testing"

"github.com/fxamacker/cbor"
"github.com/fxamacker/cbor/v2"
"github.com/goccy/go-yaml"
"github.com/stretchr/testify/assert"
)
Expand Down
36 changes: 16 additions & 20 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,36 +3,32 @@ module github.com/danielgtaylor/huma
go 1.13

require (
github.com/Jeffail/gabs/v2 v2.6.0
github.com/andybalholm/brotli v1.0.0
github.com/Jeffail/gabs/v2 v2.6.1
github.com/andybalholm/brotli v1.0.4
github.com/benbjohnson/clock v1.3.0 // indirect
github.com/danielgtaylor/casing v0.0.0-20210126043903-4e55e6373ac3
github.com/fatih/structs v1.1.0
github.com/fsnotify/fsnotify v1.4.9 // indirect
github.com/fxamacker/cbor v1.5.1
github.com/fxamacker/cbor/v2 v2.2.0
github.com/fxamacker/cbor/v2 v2.4.0
github.com/go-chi/chi v4.1.2+incompatible
github.com/goccy/go-yaml v1.8.1
github.com/goccy/go-yaml v1.9.5
github.com/graphql-go/graphql v0.8.0
github.com/graphql-go/handler v0.2.3
github.com/koron-go/gqlcost v0.2.2
github.com/magiconair/properties v1.8.2 // indirect
github.com/mattn/go-isatty v0.0.12
github.com/mitchellh/mapstructure v1.3.3 // indirect
github.com/magiconair/properties v1.8.6 // indirect
github.com/mattn/go-isatty v0.0.14
github.com/mitchellh/mapstructure v1.4.3
github.com/opentracing/opentracing-go v1.2.0
github.com/pelletier/go-toml v1.8.0 // indirect
github.com/spf13/afero v1.3.4 // indirect
github.com/spf13/cast v1.3.1 // indirect
github.com/spf13/cobra v1.0.0
github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/spf13/viper v1.7.1
github.com/spf13/afero v1.8.2 // indirect
github.com/spf13/cobra v1.4.0
github.com/spf13/viper v1.10.1
github.com/stretchr/testify v1.7.0
github.com/tent/http-link-go v0.0.0-20130702225549-ac974c61c2f9
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
github.com/xeipuuv/gojsonschema v1.2.0
go.uber.org/zap v1.15.0
golang.org/x/sys v0.0.0-20200826173525-f9321e4c35a6 // indirect
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
gopkg.in/ini.v1 v1.60.1 // indirect
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.8.0 // indirect
go.uber.org/zap v1.21.0
golang.org/x/sys v0.0.0-20220318055525-2edf467146b5 // indirect
gopkg.in/ini.v1 v1.66.4 // indirect
launchpad.net/gocheck v0.0.0-20140225173054-000000000087 // indirect
)
Loading

0 comments on commit bf3a8a3

Please sign in to comment.