From 0b4fbd0f9bd796e02839577af2d935216ed36ecd Mon Sep 17 00:00:00 2001 From: Ivy Wong <57681886+iwong-isp@users.noreply.github.com> Date: Fri, 30 Sep 2022 11:45:28 -0700 Subject: [PATCH 1/5] fix: stable openAPI param ordering --- operation.go | 9 ++++++--- patch.go | 3 ++- resolver.go | 14 ++++++++++---- 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/operation.go b/operation.go index 0108a6f1..b1a545a1 100644 --- a/operation.go +++ b/operation.go @@ -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 @@ -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 @@ -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. diff --git a/patch.go b/patch.go index 35b41407..2544023f 100644 --- a/patch.go +++ b/patch.go @@ -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, diff --git a/resolver.go b/resolver.go index f821cf81..305dc171 100644 --- a/resolver.go +++ b/resolver.go @@ -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() } @@ -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 } @@ -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 } From 3a1efce3100f01690d48009ceb0ec0ade6376e2a Mon Sep 17 00:00:00 2001 From: Ivy Wong <57681886+iwong-isp@users.noreply.github.com> Date: Fri, 30 Sep 2022 11:46:29 -0700 Subject: [PATCH 2/5] docs: add bookstore example --- examples/bookstore/bookstore.go | 228 ++++++++++++++++++++++++++++++++ 1 file changed, 228 insertions(+) create mode 100644 examples/bookstore/bookstore.go diff --git a/examples/bookstore/bookstore.go b/examples/bookstore/bookstore.go new file mode 100644 index 00000000..99af1d6b --- /dev/null +++ b/examples/bookstore/bookstore.go @@ -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() +} From baee03276968ca7ae777768ff60f6519986d99f1 Mon Sep 17 00:00:00 2001 From: Ivy Wong <57681886+iwong-isp@users.noreply.github.com> Date: Fri, 30 Sep 2022 11:47:17 -0700 Subject: [PATCH 3/5] fix: stable openAPI property ordering --- router.go | 5 ++++- router_test.go | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/router.go b/router.go index 3295bab9..22a080d5 100644 --- a/router.go +++ b/router.go @@ -348,7 +348,10 @@ func (r *Router) setupDocs() { if !r.mux.Match(chi.NewRouteContext(), http.MethodGet, r.OpenAPIPath()) { r.mux.Get(r.OpenAPIPath(), func(w http.ResponseWriter, req *http.Request) { w.Header().Set("Content-Type", "application/vnd.oai.openapi+json") - w.Write(spec.Bytes()) + + // Convert to JSON to take advantage of keys being sorted lexicographically + sBytes, _ := json.Marshal(spec) + w.Write(sBytes) }) } diff --git a/router_test.go b/router_test.go index 521ec785..e23f90a9 100644 --- a/router_test.go +++ b/router_test.go @@ -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"}}) +} From 44bed11fb0b20f5d4dd281de41f3328aaa828db3 Mon Sep 17 00:00:00 2001 From: Ivy Wong <57681886+iwong-isp@users.noreply.github.com> Date: Fri, 30 Sep 2022 14:01:25 -0700 Subject: [PATCH 4/5] fix: revert changing spec.Bytes --- router.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/router.go b/router.go index 22a080d5..d36a5023 100644 --- a/router.go +++ b/router.go @@ -349,9 +349,7 @@ func (r *Router) setupDocs() { r.mux.Get(r.OpenAPIPath(), func(w http.ResponseWriter, req *http.Request) { w.Header().Set("Content-Type", "application/vnd.oai.openapi+json") - // Convert to JSON to take advantage of keys being sorted lexicographically - sBytes, _ := json.Marshal(spec) - w.Write(sBytes) + w.Write(spec.Bytes()) }) } From 1af10e920093369b1ab51afffd66e6cd45ab2deb Mon Sep 17 00:00:00 2001 From: Ivy Wong <57681886+iwong-isp@users.noreply.github.com> Date: Fri, 30 Sep 2022 14:14:54 -0700 Subject: [PATCH 5/5] chore: remove extra white space --- router.go | 1 - 1 file changed, 1 deletion(-) diff --git a/router.go b/router.go index d36a5023..3295bab9 100644 --- a/router.go +++ b/router.go @@ -348,7 +348,6 @@ func (r *Router) setupDocs() { if !r.mux.Match(chi.NewRouteContext(), http.MethodGet, r.OpenAPIPath()) { r.mux.Get(r.OpenAPIPath(), func(w http.ResponseWriter, req *http.Request) { w.Header().Set("Content-Type", "application/vnd.oai.openapi+json") - w.Write(spec.Bytes()) }) }