这是indexloc提供的服务,不要输入任何密码
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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"}})
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

non-blocking: there's a 'JSONEq' that could be useful here. Something like:

assert.JSONEq(t, `[{"name": "menu-category", ...}]`, openapi)`

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I considered using that, but unfortunately, the output of the gabs.Search ends at the operation level. This results in openapi containing the full spec for the get operation (ex. responses, additional properties for a parameter). JSONEq only returns true if the entire JSON payload matches (ignoring order). Given we're only interested in using the name to check order was correctly preserved, I decided with a temp type to only parse the necessary field.

}