Skip to content

Instantly share code, notes, and snippets.

@smyrman
Last active October 18, 2017 09:00
Show Gist options
  • Save smyrman/a0a015f5763f98343d23afcef8b9a0f8 to your computer and use it in GitHub Desktop.
Save smyrman/a0a015f5763f98343d23afcef8b9a0f8 to your computer and use it in GitHub Desktop.
rest-layer OpenAPI
// Package openapi provides tools for generating JSON that conforms with the
// OpenAPI specification verison 3. Note that the implementation is minimal,
// and a lot of optional fields have been skipped. Also, the package removes
// leeway and enforces consistency on which fields must be specified as full
// objects and where references must be used instead.
//
// https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md
package openapi
package openapi
import (
"encoding/json"
"fmt"
"net/http"
)
// NewHandler returns a http.Handler that serves doc as JSON.
func NewHandler(doc *Doc) (http.Handler, error) {
docJSON, err := json.Marshal(doc)
if err != nil {
return nil, err
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
switch r.Method {
case http.MethodHead, http.MethodGet:
h.Set("Access-Control-Allow-Origin", "*")
h.Set("Content-Type", ContentTypeJSON)
w.WriteHeader(http.StatusOK)
if r.Method == http.MethodGet {
w.Write(docJSON)
fmt.Fprintln(w)
}
default:
h["Allow"] = []string{http.MethodHead, http.MethodGet}
w.WriteHeader(http.StatusMethodNotAllowed)
}
if fl, ok := w.(http.Flusher); ok {
fl.Flush()
}
}), nil
}
package openapi
import "encoding/json"
// Values for use as keys in Content-Type maps such as Request.Content and
// Response.Content.
const (
ContentTypeJSON = "application/json"
)
// Ref describes a JSON Schema RFC3986 reference object.
type Ref struct {
Ref string `json:"$ref"`
}
// Doc is the root document object of the OpenAPI document.
type Doc struct {
OpenAPIVersion version `json:"openapi"`
Info Info `json:"info"`
Servers []Server `json:"servers,omitempty"`
Paths map[string]PathItem `json:"paths"`
Security []SecurityRequirement `json:"security,omitempty"`
Tags []Tag `json:"tags,omitempty"`
Components struct {
Schemas map[string]interface{} `json:"schemas,omitempty"`
Responses map[string]Response `json:"responses,omitempty"`
Parameters map[string]Parameter `json:"parameters,omitempty"`
Headers map[string]Header `json:"headers,omitempty"`
SecuritySchemes map[string]json.RawMessage `json:"securitySchemes,omitempty"`
} `json:"components"`
}
type version struct{}
// Info holds information about the API that a Doc describes.
type Info struct {
Title string `json:"title"`
Description string `json:"description,omitempty"`
Version string `json:"version"`
}
// Server instance description.
type Server struct {
URL string `json:"url"`
}
// SecurityRequirement keys should reference a Doc.Components.SecuritySchemes
// key and either list required scopes (a.k.a. required permissions) or supply
// an empty list if not particular scope is needed.
type SecurityRequirement map[string][]string
// Tag adds metadata to a group of operations with the given tag name.
type Tag struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
}
// MarshalJSON implements json.Marshaler.
func (v version) MarshalJSON() ([]byte, error) {
return []byte(`"3.0.0"`), nil
}
// Raw JSON values for Doc.Components.SecuritySchemes.
var (
SecuritySchemeBasic = json.RawMessage(`{"type": "http", "scheme": "basic"}`)
SecuritySchemeJWT = json.RawMessage(`{"type": "http", "scheme": "bearer", "bearerFormat": "JWT"}`)
)
// PathItem describes the operations available on a single path. A Path Item MAY
// be empty, due to ACL constraints. The path itself is still exposed to the
// documentation viewer but they will not know which operations and parameters
// are available.
type PathItem struct {
Summary string `json:"summary,omitempty"`
Description string `json:"description,omitempty"`
Parameters []Ref `json:"parameters,omitempty"`
Get *Operation `json:"get,omitempty"`
Put *Operation `json:"put,omitempty"`
Post *Operation `json:"post,omitempty"`
Delete *Operation `json:"delete,omitempty"`
Options *Operation `json:"options,omitempty"`
Head *Operation `json:"head,omitempty"`
Patch *Operation `json:"patch,omitempty"`
Trace *Operation `json:"trace,omitempty"`
}
// Operation describes a single operation on a path.
type Operation struct {
Summary string `json:"summary,omitempty"`
OperationID string `json:"operationId,omitempty"`
Description string `json:"description,omitempty"`
Tags []string `json:"tags,omitempty"`
Parameters []ParameterOrRef `json:"parameters,omitempty"`
RequestBody *Request `json:"requestBody,omitempty"`
Responses map[string]ResponseOrRef `json:"responses"`
}
// Parameter describes a single operation parameter.
type Parameter struct {
Description string `json:"description,omitempty"`
Name string `json:"name"`
In string `json:"in"`
Required bool `json:"required,omitempty"`
Schema interface{} `json:"schema,omitempty"`
}
// ParameterOrRef allows either a reference or a parameter to be specified.
type ParameterOrRef struct {
Ref Ref
Parameter *Parameter
}
// MarshalJSON implements json.Marshaler.
func (pr ParameterOrRef) MarshalJSON() ([]byte, error) {
if pr.Parameter == nil {
return json.Marshal(pr.Ref)
}
return json.Marshal(pr.Parameter)
}
// Request describes the purpose and content schema of a request per
// Content-Type.
type Request struct {
Description string `json:"description"`
Content map[string]Content `json:"content"`
}
// Response describes the purpose and content of a response per Content-Type.
type Response struct {
Description string `json:"description"`
Headers map[string]Ref `json:"headers,omitempty"`
Content map[string]Content `json:"content,omitempty"`
}
// ResponseOrRef allows a response or reference to be defined.
type ResponseOrRef struct {
Ref Ref
Response *Response
}
// MarshalJSON implements json.Marshaler.
func (rr ResponseOrRef) MarshalJSON() ([]byte, error) {
if rr.Response == nil {
return json.Marshal(rr.Ref)
}
return json.Marshal(rr.Response)
}
// Header descrives a response Header's purpose and schema.
type Header struct {
Description string `json:"description,omitempty"`
Schema interface{} `json:"schema,omitempty"`
}
// Content includes a schema or schema reference.
type Content struct {
Schema interface{} `json:"schema,omitempty"`
}
package openapi
import (
"bytes"
"encoding/json"
"fmt"
"strings"
"github.com/rs/rest-layer/schema"
"github.com/rs/rest-layer/schema/encoding/jsonschema"
"github.com/rs/rest-layer/resource"
)
// STYLE NOTE: For the generation functions in this file, OpenAPI descriptions
// have been set to end with periods (full sentences), while sumaries have not
// (consider them sub-headers).
// RestlayerDoc attempts to build an openapi.json document structure from idx
// based on the API that would be produced by rest-layer/rest.NewHandler(idx)
func RestlayerDoc(api Info, idx resource.Index) (*Doc, error) {
if cmp, ok := idx.(resource.Compiler); ok {
if err := cmp.Compile(); err != nil {
return nil, err
}
}
doc := Doc{
Info: api,
Paths: make(map[string]PathItem),
}
setStaticComponents(&doc)
for _, rsc := range idx.GetResources() {
if err := addResource(&doc, nil, rsc); err != nil {
return nil, err
}
}
return &doc, nil
}
// setStaticComponents sets a number of fixed rest-layer components to doc that
// can be referenced by path items. All existing resources are cleared!
func setStaticComponents(doc *Doc) {
doc.Components.Headers = map[string]Header{
"Date": {
Description: "The time this request was served.",
Schema: json.RawMessage(`{"type": "string", "format": "date-time"}`),
},
"Etag": {
Description: "Provides [concurrency-control](http://rest-layer.io/#data-integrity-and-concurrency-control) down to the storage layer.",
Schema: json.RawMessage(`{"type": "string"}`),
},
"Last-Modified": {
Description: "When this resource was last modified.",
Schema: json.RawMessage(`{"type": "string", "format": "date-time"}`),
},
"X-Total": {
Description: "Total number of entries matching the supplied filter.",
Schema: json.RawMessage(`{"type": "integer"}`),
},
}
doc.Components.Parameters = map[string]Parameter{
"filter": {
Description: "[Filter](http://rest-layer.io/#filtering) which entries to show. Allows a MongoDB-like query syntax.",
Name: "filter",
In: "query",
Schema: json.RawMessage(`{"type": "object"}`),
},
"fields": {
Description: "[Select](http://rest-layer.io/#field-selection) which fields to show, including [embedding](http://rest-layer.io/#embedding) of related resources.",
Name: "fields",
In: "query",
Schema: json.RawMessage(`{"type": "string"}`),
},
"limit": {
Description: "Limit maximum entries per [page](http://rest-layer.io/#paginatio).",
Name: "limit",
In: "query",
Schema: json.RawMessage(`{"type": "integer", "minimum": 0}`),
},
"skip": {
Description: "[Skip](http://rest-layer.io/#skipping) the first N entries.",
Name: "skip",
In: "query",
Schema: json.RawMessage(`{"type": "integer"}`),
},
"page": {
Description: "The [page](http://rest-layer.io/#pagination) number to display, starting at 1.",
Name: "page",
In: "query",
Schema: json.RawMessage(`{"type": "integer", "default": 1, "minimum": 1}`),
},
"total": {
Description: "Force total number of entries to be included in the response header. This could have performance implications.",
Name: "total",
In: "query",
Schema: json.RawMessage(`{"type": "integer", "enum": [0, 1]}`),
},
}
doc.Components.Schemas = map[string]interface{}{
"Error": json.RawMessage(`{"type": "object", "required": ["code", "message"], "properties": {` +
`"code": {"type": "integer", "description": "HTTP Status code"}, ` +
`"message": {"type": "string", "description": "error message"}` +
`}, "additionalProperties": true}`),
"ValidationError": json.RawMessage(`{"type": "object", "required": ["code", "message"], "properties": {` +
`"code": {"type": "integer", "description": "HTTP Status code", "enum": [422]}, ` +
`"issues": {"type": "object", "description": "error message", "extraProperties": {"type": "string"}}, ` +
`"message": {"type": "string", "description": "error message"}` +
`}, "additionalProperties": false}`),
}
doc.Components.Responses = map[string]Response{
"Error": Response{
Description: "Generic error structure, returned on all errors.",
Content: map[string]Content{
"application/json": {Schema: Ref{"#/components/schemas/Error"}},
},
},
"ValidationError": Response{
Description: "Returned when the request body content does not validate.",
Content: map[string]Content{
"application/json": {Schema: Ref{"#/components/schemas/ValidationError"}},
},
},
}
}
type pathParent struct {
path string
item PathItem
}
// addResources recursively adds components and paths from rsc to doc. Will
// reference components set by setStaticComponents which must be called first!
func addResource(doc *Doc, parent *pathParent, rsc *resource.Resource) error {
ref := newResourceRefs(doc, rsc)
schema := rsc.Schema()
// Add resource tag.
doc.Tags = append(doc.Tags, Tag{
Name: ref.TagName(),
Description: schema.Description,
})
// Set components from resource.
doc.Components.Schemas[ref.listName] = map[string]interface{}{
"type": "array",
"items": ref.ItemSchema(),
}
sJSON, err := schemaJSON(&schema)
if err != nil {
return err
}
doc.Components.Schemas[ref.itemName] = json.RawMessage(sJSON)
doc.Components.Parameters[ref.idName] = Parameter{
Description: fmt.Sprint(ref.itemName, " ID"),
Name: ref.idName,
In: "path",
Required: true,
Schema: ref.IDParameterSchema(),
}
// Add paths from resource.
cfg := rsc.Conf()
item := itemPathItem(&cfg, ref)
list := listPathItem(&cfg, ref)
if parent != nil {
for _, param := range parent.item.Parameters {
key := strings.TrimPrefix(param.Ref, "#/components/parameters/")
if doc.Components.Parameters[key].In == "path" {
item.Parameters = append(item.Parameters, param)
list.Parameters = append(list.Parameters, param)
}
}
}
listPath := "/" + ref.itemName
if parent != nil {
listPath = parent.path + listPath
}
itemPath := fmt.Sprint(listPath, "/{", ref.idName, "}")
doc.Paths[listPath] = *list
doc.Paths[itemPath] = *item
// Add sub-resources.
for _, subRsc := range rsc.GetResources() {
if err := addResource(doc, &pathParent{itemPath, *item}, subRsc); err != nil {
return err
}
}
return nil
}
// resourceRefs is used to get and store unique and human-friendly references
// for REST-ful resources with a list path, item path and an id path parameter.
type resourceRefs struct {
listName string
itemName string
idName string
}
func newResourceRefs(doc *Doc, rsc *resource.Resource) *resourceRefs {
refs := resourceRefs{}
if n := rsc.Name(); strings.HasSuffix(n, "ies") {
refs.listName = n
refs.itemName = uniqueKey(doc.Components.Schemas, n[0:len(n)-3]+"y")
} else if strings.HasSuffix(n, "s") {
refs.listName = n
refs.itemName = uniqueKey(doc.Components.Schemas, n[0:len(n)-1])
} else {
refs.listName = uniqueKey(doc.Components.Schemas, n+"List")
refs.itemName = uniqueKey(doc.Components.Schemas, n)
}
refs.idName = refs.itemName + "Id"
return &refs
}
func (refs *resourceRefs) TagName() string {
return strings.Title(refs.itemName)
}
func (refs *resourceRefs) ListSchema() Ref {
return Ref{"#/components/schemas/" + refs.listName}
}
func (refs *resourceRefs) ItemSchema() Ref {
return Ref{"#/components/schemas/" + refs.itemName}
}
func (refs *resourceRefs) IDParameterSchema() Ref {
return Ref{"#/components/schemas/" + refs.itemName + "/properties/id"}
}
func (refs *resourceRefs) IDParameter() Ref {
return Ref{"#/components/parameters/" + refs.idName}
}
func (refs *resourceRefs) ListOperationID(verb string) string {
return verb + strings.Title(refs.listName)
}
func (refs *resourceRefs) ItemOperationID(verb string) string {
return verb + strings.Title(refs.itemName)
}
func uniqueKey(m map[string]interface{}, key string) string {
newKey := key
for i := 1; i < 100; i++ {
if _, ok := m[newKey]; !ok {
return newKey
}
newKey = fmt.Sprintf("%s%.2d", key, i)
}
panic("uniqueRef failed after to many attempts")
}
func schemaJSON(s *schema.Schema) ([]byte, error) {
var b bytes.Buffer
enc := jsonschema.NewEncoder(&b)
err := enc.Encode(s)
return b.Bytes(), err
}
func listPathItem(cfg *resource.Conf, ref *resourceRefs) *PathItem {
list := PathItem{
Summary: fmt.Sprint("List view for ", ref.listName),
Parameters: []Ref{},
}
listParams := []ParameterOrRef{
{Ref: Ref{"#/components/parameters/filter"}},
{Ref: Ref{"#/components/parameters/fields"}},
{Ref: Ref{"#/components/parameters/limit"}},
{Ref: Ref{"#/components/parameters/page"}},
{Ref: Ref{"#/components/parameters/skip"}},
{Ref: Ref{"#/components/parameters/total"}},
}
if cfg.IsModeAllowed(resource.List) {
list.Get = &Operation{
Summary: fmt.Sprint("List ", ref.listName),
OperationID: ref.ListOperationID("list"),
Tags: []string{ref.TagName()},
Parameters: listParams,
Responses: map[string]ResponseOrRef{
"200": {Response: &Response{
Description: fmt.Sprint("List of ", ref.listName, "."),
Headers: map[string]Ref{
"Date": Ref{"#/components/headers/Date"},
"X-Total": Ref{"#/components/headers/X-Total"},
},
Content: map[string]Content{
ContentTypeJSON: {Schema: ref.ListSchema()},
},
}},
"default": {Ref: Ref{"#/components/responses/Error"}},
},
}
}
if cfg.IsModeAllowed(resource.Create) {
list.Post = &Operation{
Summary: fmt.Sprint("Create new ", ref.itemName),
OperationID: ref.ItemOperationID("create"),
Tags: []string{ref.TagName()},
RequestBody: &Request{
Description: fmt.Sprint("Payload of new ", ref.itemName, "."),
Content: map[string]Content{
ContentTypeJSON: {Schema: ref.ItemSchema()},
},
},
Responses: map[string]ResponseOrRef{
"201": {Response: &Response{
Description: fmt.Sprint("Payload of ", ref.itemName, "."),
Headers: map[string]Ref{
"Etag": Ref{"#/components/headers/Etag"},
"Last-Modified": Ref{"#/components/headers/Last-Modified"},
},
Content: map[string]Content{
ContentTypeJSON: {Schema: ref.ItemSchema()},
},
}},
"422": {Ref: Ref{"#/components/responses/ValidationError"}},
"default": {Ref: Ref{"#/components/responses/Error"}},
},
}
}
if cfg.IsModeAllowed(resource.Clear) {
list.Delete = &Operation{
Summary: fmt.Sprint("Delete all ", ref.listName),
OperationID: ref.ListOperationID("clear"),
Tags: []string{ref.TagName()},
Responses: map[string]ResponseOrRef{
"204": {Response: &Response{
Description: "Operation was successfull, no data to return.",
Headers: map[string]Ref{
"Date": Ref{"#/components/headers/Date"},
"X-Total": Ref{"#/components/headers/X-Total"},
},
}},
"default": {Ref: Ref{"#/components/responses/Error"}},
},
}
}
return &list
}
func itemPathItem(cfg *resource.Conf, ref *resourceRefs) *PathItem {
item := PathItem{
Summary: fmt.Sprint("Item view for ", ref.listName),
Parameters: []Ref{ref.IDParameter()},
}
if cfg.IsModeAllowed(resource.Read) {
item.Get = &Operation{
Summary: fmt.Sprint("Show ", ref.itemName),
OperationID: ref.ItemOperationID("show"),
Tags: []string{ref.TagName()},
Responses: map[string]ResponseOrRef{
"200": {Response: &Response{
Description: fmt.Sprint("Payload of ", ref.itemName, "."),
Headers: map[string]Ref{
"Etag": Ref{"#/components/headers/Etag"},
"Last-Modified": Ref{"#/components/headers/Last-Modified"},
},
Content: map[string]Content{
ContentTypeJSON: {Schema: ref.ItemSchema()},
},
}},
"default": {Ref: Ref{"#/components/responses/Error"}},
},
}
}
if cfg.IsModeAllowed(resource.Replace) {
item.Put = &Operation{
Summary: fmt.Sprint("Replace ", ref.itemName),
OperationID: ref.ItemOperationID("replace"),
Tags: []string{ref.TagName()},
RequestBody: &Request{
Description: fmt.Sprint("Payload of ", ref.itemName, "."),
Content: map[string]Content{
ContentTypeJSON: {Schema: ref.ItemSchema()},
},
},
Responses: map[string]ResponseOrRef{
"200": {Response: &Response{
Description: fmt.Sprint("Payload of ", ref.itemName, "."),
Headers: map[string]Ref{
"Etag": Ref{"#/components/headers/Etag"},
"Last-Modified": Ref{"#/components/headers/Last-Modified"},
},
Content: map[string]Content{
ContentTypeJSON: {Schema: ref.ItemSchema()},
},
}},
"422": {Ref: Ref{"#/components/responses/ValidationError"}},
"default": {Ref: Ref{"#/components/responses/Error"}},
},
}
}
if cfg.IsModeAllowed(resource.Update) {
item.Patch = &Operation{
Summary: fmt.Sprint("Patch ", ref.itemName),
OperationID: ref.ItemOperationID("patch"),
Tags: []string{ref.TagName()},
RequestBody: &Request{
Description: fmt.Sprint("payload of ", ref.itemName),
Content: map[string]Content{
ContentTypeJSON: {Schema: ref.ItemSchema()},
},
},
Responses: map[string]ResponseOrRef{
"200": {Response: &Response{
Description: fmt.Sprint("Payload of ", ref.itemName, "."),
Headers: map[string]Ref{
"Etag": Ref{"#/components/headers/Etag"},
"Last-Modified": Ref{"#/components/headers/Last-Modified"},
},
Content: map[string]Content{
ContentTypeJSON: {Schema: ref.ItemSchema()},
},
}},
"422": {Ref: Ref{"#/components/responses/ValidationError"}},
"default": {Ref: Ref{"#/components/responses/Error"}},
},
}
}
if cfg.IsModeAllowed(resource.Delete) {
item.Delete = &Operation{
Summary: fmt.Sprint("Delete ", ref.itemName),
OperationID: ref.ItemOperationID("delete"),
Tags: []string{ref.TagName()},
Responses: map[string]ResponseOrRef{
"204": {Response: &Response{
Description: "Operation was successfull, no data to return.",
}},
"default": {Ref: Ref{"#/components/responses/Error"}},
},
}
}
return &item
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment