Skip to content

Commit

Permalink
Merge pull request #71 from danielgtaylor/iwong/openapi-ordering
Browse files Browse the repository at this point in the history
fix: stable OpenAPI property and param ordering
  • Loading branch information
iwong-isp authored Sep 30, 2022
2 parents 5ed156c + 1af10e9 commit 1519e49
Show file tree
Hide file tree
Showing 5 changed files with 296 additions and 8 deletions.
228 changes: 228 additions & 0 deletions examples/bookstore/bookstore.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
package main

import (
"net/http"
"sync"
"time"

"github.com/danielgtaylor/huma"
"github.com/danielgtaylor/huma/cli"
"github.com/danielgtaylor/huma/middleware"
"github.com/danielgtaylor/huma/responses"
)

// GenreSummary is used to list genres. It does not include the (potentially)
// large genre content.
type GenreSummary struct {
ID string `json:"id" doc:"Genre ID"`
Description string `json:"description" doc:"Description"`
Created time.Time `json:"created" doc:"Created date/time as ISO8601"`
}

type GenrePutRequest struct {
Description string `json:"description" doc:"Description"`
}

// GenreIDParam gets the genre ID from the URI path.
type GenreIDParam struct {
GenreID string `path:"genre-id" pattern:"^[a-zA-Z0-9._-]{1,32}$"`
}

// Genre records some content text for later reference.
type Genre struct {
ID string `json:"id" doc:"Genre ID"`
Books []Book `json:"books" doc:"Books"`
Description string `json:"description" doc:"Description"`
Created time.Time `json:"created" readOnly:"true" doc:"Created date/time as ISO8601"`
}

type Book struct {
ID string `json:"id" doc:"Book ID"`
Title string `json:"title" doc:"Title"`
Author string `json:"author" doc:"Author"`
Published time.Time `json:"published" doc:"Created date/time as ISO8601"`
}

type BookPutRequest struct {
Title string `json:"title" doc:"Title"`
Author string `json:"author" doc:"Author"`
Published time.Time `json:"published" doc:"Created date/time as ISO8601"`
}

type BookIDParam struct {
BookID string `path:"book-id" pattern:"^[a-zA-Z0-9._-]{1,32}$"`
}

// We'll use an in-memory DB (a goroutine-safe map). Don't do this in
// production code!
var memoryDB = sync.Map{}

func main() {
// Create a new router and give our API a title and version.
app := cli.NewRouter("BookStore API", "1.0.0")
app.ServerLink("Development server", "http://localhost:8888")

genres := app.Resource("/v1/genres")
genres.Get("list-genres", "Returns a list of all genres",
responses.OK().Model([]*GenreSummary{}),
).Run(func(ctx huma.Context) {
// Create a list of summaries from all the genres.
summaries := make([]*GenreSummary, 0)

memoryDB.Range(func(k, v interface{}) bool {
summaries = append(summaries, &GenreSummary{
ID: k.(string),
Description: v.(Genre).Description,
Created: v.(Genre).Created,
})
return true
})

ctx.WriteModel(http.StatusOK, summaries)
})

// Add an `id` path parameter to create a genre resource.
genre := genres.SubResource("/{genre-id}")

genre.Put("put-genre", "Create or update a genre",
responses.NoContent(),
).Run(func(ctx huma.Context, input struct {
GenreIDParam
Body GenrePutRequest
}) {
middleware.GetLogger(ctx).Info("Creating a new genre")

// Set the created time to now and then save the genre in the DB.
new := Genre{
ID: input.GenreID,
Description: input.Body.Description,
Created: time.Now(),
Books: []Book{},
}
memoryDB.Store(input.GenreID, new)
})

genre.Get("get-genre", "Get a genre by its ID",
responses.OK().Model(Genre{}),
responses.NotFound(),
).Run(func(ctx huma.Context, input GenreIDParam) {
if g, ok := memoryDB.Load(input.GenreID); ok {
// Genre with that ID exists!
ctx.WriteModel(http.StatusOK, g.(Genre))
return
}

ctx.WriteError(http.StatusNotFound, "Genre "+input.GenreID+" not found")
})

genre.Delete("delete-genre", "Delete a genre by its ID",
responses.NoContent(),
responses.NotFound(),
).Run(func(ctx huma.Context, input GenreIDParam) {
if _, ok := memoryDB.Load(input.GenreID); ok {
// Genre with that ID exists!
memoryDB.Delete(input.GenreID)
ctx.WriteHeader(http.StatusNoContent)
return
}

ctx.WriteError(http.StatusNotFound, "Genre "+input.GenreID+" not found")
})

books := genre.SubResource("/books")
books.Tags("Books by Genre")

books.Get("list-books", "Returns a list of all books for a genre",
[]huma.Response{
responses.OK().Model([]Book{}),
responses.NotFound(),
}...,
).Run(func(ctx huma.Context, input struct {
GenreIDParam
}) {

if g, ok := memoryDB.Load(input.GenreID); ok {
ctx.WriteModel(http.StatusOK, g.(Genre).Books)
return
}

ctx.WriteError(http.StatusNotFound, "Genre "+input.GenreID+" not found")
})

book := books.SubResource("/{book-id}")
book.Put("put-book", "Create or update a book",
responses.NoContent(),
).Run(func(ctx huma.Context, input struct {
GenreIDParam
BookIDParam
Body BookPutRequest
}) {
middleware.GetLogger(ctx).Info("Creating a new book")

if g, ok := memoryDB.Load(input.GenreID); !ok {
// Genre with that ID doesn't exists!
ctx.WriteError(http.StatusNotFound, "Genre "+input.GenreID+" not found")
return
} else {
genre := g.(Genre)
genre.Books = append(genre.Books, Book{
Title: input.Body.Title,
Author: input.Body.Author,
ID: input.BookID,
Published: input.Body.Published,
})

memoryDB.Store(input.GenreID, genre)
}

})

book.Get("get-book", "Get a book by its ID",
responses.OK().Model(Book{}),
responses.NotFound(),
).Run(func(ctx huma.Context, input struct {
GenreIDParam
BookIDParam
}) {
if g, ok := memoryDB.Load(input.GenreID); !ok {
// Genre with that ID exists!
ctx.WriteError(http.StatusNotFound, "Genre "+input.GenreID+" not found")
return
} else {
for _, book := range g.(Genre).Books {
if book.ID == input.BookID {
ctx.WriteModel(http.StatusOK, book)
return
}
}
}

ctx.WriteError(http.StatusNotFound, "Book "+input.BookID+" not found")
})

book.Delete("delete-book", "Delete a book by its ID",
responses.NoContent(),
responses.NotFound(),
).Run(func(ctx huma.Context, input struct {
GenreIDParam
BookIDParam
}) {
if g, ok := memoryDB.Load(input.GenreID); !ok {
// Genre with that ID exists!
ctx.WriteError(http.StatusNotFound, "Genre "+input.GenreID+" not found")
return
} else {
for _, book := range g.(Genre).Books {
if book.ID == input.BookID {
ctx.WriteHeader(http.StatusNoContent)
return
}
}
}

ctx.WriteError(http.StatusNotFound, "Book "+input.BookID+" not found")
})

// Run the app!
app.Run()
}
9 changes: 6 additions & 3 deletions operation.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ type Operation struct {
summary string
description string
params map[string]oaParam
paramsOrder []string
defaultContentType string
requests map[string]*request
responses []Response
Expand Down Expand Up @@ -92,7 +93,8 @@ func (o *Operation) toOpenAPI(components *oaComponents) *gabs.Container {
}

// Request params
for _, param := range o.params {
for _, paramKey := range o.paramsOrder {
param := o.params[paramKey]
if param.Internal {
// Skip documenting internal-only params.
continue
Expand Down Expand Up @@ -239,8 +241,9 @@ func (o *Operation) Run(handler interface{}) {
input := t.In(1)

// Get parameters
o.params = getParamInfo(input)
for k, v := range o.params {
o.params, o.paramsOrder = getParamInfo(input)
for _, k := range o.paramsOrder {
v := o.params[k]
if v.In == inPath {
// Confirm each declared input struct path parameter is actually a part
// of the declared resource path.
Expand Down
3 changes: 2 additions & 1 deletion patch.go
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,8 @@ func generatePatch(resource *Resource, get *Operation, put *Operation) {
id: "patch-" + name,
summary: "Patch " + name,
description: "Partial update operation supporting both JSON Merge Patch & JSON Patch updates.",
params: get.params,
params: put.params,
paramsOrder: put.paramsOrder,
requests: map[string]*request{
"application/merge-patch+json": {
override: true,
Expand Down
14 changes: 10 additions & 4 deletions resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -445,8 +445,9 @@ func resolveFields(ctx *hcontext, path string, input reflect.Value) {
}

// getParamInfo recursively gets info about params from an input struct. It
// returns a map of parameter name => parameter object.
func getParamInfo(t reflect.Type) map[string]oaParam {
// returns a map of parameter name => parameter object and the order the
// parameters were received.
func getParamInfo(t reflect.Type) (map[string]oaParam, []string) {
if t.Kind() == reflect.Ptr {
t = t.Elem()
}
Expand All @@ -456,13 +457,17 @@ func getParamInfo(t reflect.Type) map[string]oaParam {
}

params := map[string]oaParam{}
paramOrder := []string{}
for i := 0; i < t.NumField(); i++ {
f := t.Field(i)

if f.Anonymous {
// Embedded struct
for k, v := range getParamInfo(f.Type) {
embedded, eOrder := getParamInfo(f.Type)
for _, k := range eOrder {
v := embedded[k]
params[k] = v
paramOrder = append(paramOrder, k)
}
continue
}
Expand Down Expand Up @@ -517,7 +522,8 @@ func getParamInfo(t reflect.Type) map[string]oaParam {
p.typ = f.Type

params[p.Name] = p
paramOrder = append(paramOrder, p.Name)
}

return params
return params, paramOrder
}
50 changes: 50 additions & 0 deletions router_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -605,3 +605,53 @@ func TestRequestContentTypes(t *testing.T) {
assert.Equal(t, http.StatusOK, w.Code, w.Body.String())
assert.JSONEq(t, `{"name": "one two"}`, w.Body.String())
}

func TestOpenAPIOrdering(t *testing.T) {
app := newTestRouter()

type Response struct {
Description string `json:"description"`
Category string `json:"category"`
ID string `json:"id"`
Name string `json:"name"`
}

app.Resource("/menu/{menu-category}/item/{item-id}").Get("cafe menu", "ISP Cafe",
NewResponse(http.StatusOK, "test").Model(Response{}),
).Run(func(ctx Context, input struct {
Category string `path:"menu-category"`
ItemID string `path:"item-id"`
}) {
ctx.WriteModel(http.StatusOK, Response{
Category: input.Category,
ID: input.ItemID,
Name: "Apple Tea",
Description: "Green tea with apples",
})
})

w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/menu/drinks/item/apple-tea", nil)
req.Header.Set("Authorization", "dummy")
req.Host = "example.com"
app.ServeHTTP(w, req)

// JSON response should be lexicographically sorted
assert.Equal(t, http.StatusOK, w.Code)
expectedResp := `{"$schema":"https://example.com/schemas/Response.json","category":"drinks","description":"Green tea with apples","id":"apple-tea","name":"Apple Tea"}`
assert.Equal(t, expectedResp, w.Body.String())

// Parameters should match insertion order
openapi := app.OpenAPI().Search("paths", "/menu/{menu-category}/item/{item-id}", "get").Bytes()
type parameters struct {
Name string `json:"name"`
}

type opschema struct {
Parameters []parameters `json:"parameters"`
}

var p opschema
json.Unmarshal(openapi, &p)
assert.Equal(t, p.Parameters, []parameters{{Name: "menu-category"}, {Name: "item-id"}})
}

0 comments on commit 1519e49

Please sign in to comment.