├── screenshot.png ├── adapters ├── gin-adapter │ ├── README.md │ ├── gin-adapter_test.go │ ├── gin-adapter.go │ └── example │ │ └── main.go ├── echo-adapter │ ├── example │ │ ├── README.md │ │ ├── main.go │ │ └── widgets │ │ │ ├── handlers.go │ │ │ └── spec.go │ ├── echo-adapter_test.go │ └── echo-adapter.go └── gorilla-adapter │ ├── example │ └── main.go │ └── gorilla-adapter.go ├── .gitignore ├── .github ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── workflows │ └── go.yml └── dependabot.yml ├── option └── options.go ├── spec_test.go ├── router_test.go ├── swaggerui.html ├── go.mod ├── spec.go ├── README.md ├── swagger.go ├── adapter_test.go ├── _example └── main.go ├── adapter.go ├── prehandler.go ├── router.go ├── go.sum ├── LICENSE ├── field_test.go ├── field.go └── prehandler_test.go /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jakecoffman/crud/HEAD/screenshot.png -------------------------------------------------------------------------------- /adapters/gin-adapter/README.md: -------------------------------------------------------------------------------- 1 | ## Gin example 2 | 3 | This is a full example using Gin. 4 | -------------------------------------------------------------------------------- /adapters/echo-adapter/example/README.md: -------------------------------------------------------------------------------- 1 | ## Echo example 2 | 3 | This is a full exaple using echo. 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 2 | *.o 3 | *.a 4 | *.so 5 | 6 | # Folders 7 | _obj 8 | _test 9 | 10 | # Architecture specific extensions/prefixes 11 | *.[568vq] 12 | [568vq].out 13 | 14 | *.cgo1.go 15 | *.cgo2.c 16 | _cgo_defun.c 17 | _cgo_gotypes.go 18 | _cgo_export.* 19 | 20 | _testmain.go 21 | 22 | *.exe 23 | *.test 24 | *.prof 25 | 26 | swagger.json 27 | -------------------------------------------------------------------------------- /adapters/gin-adapter/gin-adapter_test.go: -------------------------------------------------------------------------------- 1 | package adapter 2 | 3 | import "testing" 4 | 5 | func TestSwaggerToGin(t *testing.T) { 6 | if "/widgets/:id" != swaggerToGinPattern("/widgets/{id}") { 7 | t.Error(swaggerToGinPattern("/widgets/{id}")) 8 | } 9 | if "/widgets/:id/sub/:subId" != swaggerToGinPattern("/widgets/{id}/sub/{subId}") { 10 | t.Error(swaggerToGinPattern("/widgets/{id}/sub/{subId}")) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: 'enhancement' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Go 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - name: Set up Go 17 | uses: actions/setup-go@v5 18 | with: 19 | go-version-file: 'go.mod' 20 | 21 | - name: Build 22 | run: go build -v ./... 23 | 24 | - name: Test 25 | run: go test -v ./... 26 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: monthly 7 | - package-ecosystem: "gomod" 8 | directory: "/" # Location of package manifests 9 | schedule: 10 | interval: "monthly" 11 | allow: 12 | # Allow both direct and indirect updates for all packages 13 | - dependency-type: "all" 14 | open-pull-requests-limit: 0 15 | groups: 16 | all: 17 | applies-to: security-updates 18 | patterns: 19 | - "*" 20 | -------------------------------------------------------------------------------- /adapters/echo-adapter/example/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/jakecoffman/crud" 5 | "github.com/jakecoffman/crud/adapters/echo-adapter" 6 | "github.com/jakecoffman/crud/adapters/echo-adapter/example/widgets" 7 | "log" 8 | ) 9 | 10 | func main() { 11 | r := crud.NewRouter("Widget API", "1.0.0", adapter.New()) 12 | 13 | if err := r.Add(widgets.Routes...); err != nil { 14 | log.Fatal(err) 15 | } 16 | 17 | log.Println("Serving http://127.0.0.1:8080") 18 | err := r.Serve("127.0.0.1:8080") 19 | if err != nil { 20 | log.Println(err) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /option/options.go: -------------------------------------------------------------------------------- 1 | package option 2 | 3 | // Option configures a router option. Use the convenience constructors below. 4 | type Option struct { 5 | StripUnknown *bool 6 | AllowUnknown *bool 7 | } 8 | 9 | // StripUnknown will remove unknown fields if true, leave them if false. Defaults to true. 10 | func StripUnknown(v bool) Option { 11 | return Option{StripUnknown: &v} 12 | } 13 | 14 | // AllowUnknown false will cause the validation to fail if it encounters an unknown field. Defaults to true. 15 | func AllowUnknown(v bool) Option { 16 | return Option{AllowUnknown: &v} 17 | } 18 | -------------------------------------------------------------------------------- /spec_test.go: -------------------------------------------------------------------------------- 1 | package crud 2 | 3 | import "testing" 4 | 5 | func TestSpec_Valid(t *testing.T) { 6 | specs := []Spec{ 7 | {}, 8 | { 9 | Method: "GET", 10 | Path: "/1", 11 | Validate: Validate{Path: Object(map[string]Field{ 12 | "id": Number(), 13 | })}, 14 | }, 15 | { 16 | Method: "GET", 17 | Path: "/{id}", 18 | }, 19 | { 20 | Method: "GET", 21 | Path: "/3/{id}/4/{ok}", 22 | Validate: Validate{Path: Object(map[string]Field{ 23 | "id": Number(), 24 | })}, 25 | }, 26 | } 27 | 28 | for _, spec := range specs { 29 | if err := spec.Valid(); err == nil { 30 | t.Errorf("expected error for path %v", spec.Path) 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /router_test.go: -------------------------------------------------------------------------------- 1 | package crud 2 | 3 | import "testing" 4 | 5 | func TestDuplicateRouteError(t *testing.T) { 6 | r := NewRouter("", "", &TestAdapter{}) 7 | 8 | if err := r.Add(Spec{ 9 | Method: "GET", 10 | Path: "/widgets/{id}", 11 | Validate: Validate{Path: Object(map[string]Field{ 12 | "id": Number(), 13 | })}, 14 | }); err != nil { 15 | t.Errorf("expected no error, got %v", err.Error()) 16 | } 17 | 18 | // duplicate of the one above 19 | if err := r.Add(Spec{ 20 | Method: "GET", 21 | Path: "/widgets/{id}", 22 | Validate: Validate{Path: Object(map[string]Field{ 23 | "id": Number(), 24 | })}, 25 | }); err == nil { 26 | t.Errorf("expected error") 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: 'bug' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | 12 | A clear and concise description of what the bug is. 13 | 14 | Please include: 15 | - The crud version from `go.mod` 16 | - What router and adapter you are using 17 | 18 | If this is more of a question, please use Discussions instead, don't open an issue. 19 | 20 | **To reproduce the bug** 21 | 22 | Please provide a minimal example of how to reproduce the issue such that maintainers can copy and paste it or clone it and see the issue themselves. 23 | 24 | **Any other details** 25 | 26 | Anything else you'd like to mention about this? 27 | -------------------------------------------------------------------------------- /adapters/echo-adapter/example/widgets/handlers.go: -------------------------------------------------------------------------------- 1 | package widgets 2 | 3 | import ( 4 | "encoding/json" 5 | "github.com/labstack/echo/v4" 6 | "strconv" 7 | ) 8 | 9 | func ok(c echo.Context) error { 10 | return c.JSON(200, c.Request().URL.Query()) 11 | } 12 | 13 | func okPath(c echo.Context) error { 14 | // crud has already validated that ID is a number, no need to check error 15 | id, _ := strconv.Atoi(c.Param("id")) 16 | return c.JSON(200, id) 17 | } 18 | 19 | func fakeAuthPreHandler(next echo.HandlerFunc) echo.HandlerFunc { 20 | return func(c echo.Context) error { 21 | if c.Request().Header.Get("Authentication") != "password" { 22 | return echo.NewHTTPError(401, "Authentication header must be 'password'") 23 | } 24 | return next(c) 25 | } 26 | } 27 | 28 | func bindAndOk(c echo.Context) error { 29 | var widget struct { 30 | json.RawMessage 31 | } 32 | if err := c.Bind(&widget); err != nil { 33 | return echo.NewHTTPError(400, err.Error()) 34 | } 35 | return c.JSON(200, widget) 36 | } 37 | -------------------------------------------------------------------------------- /adapters/echo-adapter/echo-adapter_test.go: -------------------------------------------------------------------------------- 1 | package adapter 2 | 3 | import ( 4 | "github.com/jakecoffman/crud" 5 | "github.com/jakecoffman/crud/adapters/echo-adapter/example/widgets" 6 | "net/http/httptest" 7 | "testing" 8 | ) 9 | 10 | func TestSwaggerToEcho(t *testing.T) { 11 | if "/widgets/:id" != swaggerToEchoPattern("/widgets/{id}") { 12 | t.Error(swaggerToEchoPattern("/widgets/{id}")) 13 | } 14 | if "/widgets/:id/sub/:subId" != swaggerToEchoPattern("/widgets/{id}/sub/{subId}") { 15 | t.Error(swaggerToEchoPattern("/widgets/{id}/sub/{subId}")) 16 | } 17 | } 18 | 19 | func TestExampleServer(t *testing.T) { 20 | adapter := New() 21 | router := crud.NewRouter("Widget API", "1.0.0", adapter) 22 | 23 | if err := router.Add(widgets.Routes...); err != nil { 24 | t.Fatal(err) 25 | } 26 | 27 | t.Run("GET /widgets", func(t *testing.T) { 28 | r := httptest.NewRequest("GET", "/widgets?limit=100", nil) 29 | w := httptest.NewRecorder() 30 | adapter.Echo.ServeHTTP(w, r) 31 | 32 | if w.Result().StatusCode != 400 { 33 | t.Error(w.Result().StatusCode) 34 | } 35 | 36 | r = httptest.NewRequest("GET", "/widgets?limit=25", nil) 37 | w = httptest.NewRecorder() 38 | adapter.Echo.ServeHTTP(w, r) 39 | 40 | if w.Result().StatusCode != 200 { 41 | t.Error(w.Result().StatusCode) 42 | } 43 | }) 44 | } 45 | -------------------------------------------------------------------------------- /swaggerui.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Swagger API 6 | 7 | 8 | 9 | 10 | 28 | 29 | 30 | 31 |
32 | 33 | 34 | 35 | 56 | 57 | 58 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/jakecoffman/crud 2 | 3 | go 1.23.0 4 | 5 | // It's worth noting here that only the router that YOU depend on will 6 | // end up in the binary. So if you use gorilla/mux since it has no 7 | // additional dependencies, you are NOT also including gin and echo 8 | // since they are listed here. Go will not include them in your binary. 9 | 10 | require ( 11 | github.com/gin-gonic/gin v1.9.1 12 | github.com/gorilla/mux v1.8.1 13 | github.com/labstack/echo/v4 v4.11.4 14 | ) 15 | 16 | require ( 17 | github.com/bytedance/sonic v1.10.2 // indirect 18 | github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect 19 | github.com/chenzhuoyu/iasm v0.9.0 // indirect 20 | github.com/gabriel-vasile/mimetype v1.4.3 // indirect 21 | github.com/gin-contrib/sse v0.1.0 // indirect 22 | github.com/go-playground/locales v0.14.1 // indirect 23 | github.com/go-playground/universal-translator v0.18.1 // indirect 24 | github.com/go-playground/validator/v10 v10.17.0 // indirect 25 | github.com/goccy/go-json v0.10.2 // indirect 26 | github.com/golang-jwt/jwt v3.2.2+incompatible // indirect 27 | github.com/json-iterator/go v1.1.12 // indirect 28 | github.com/klauspost/cpuid/v2 v2.2.6 // indirect 29 | github.com/labstack/gommon v0.4.2 // indirect 30 | github.com/leodido/go-urn v1.4.0 // indirect 31 | github.com/mattn/go-colorable v0.1.13 // indirect 32 | github.com/mattn/go-isatty v0.0.20 // indirect 33 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 34 | github.com/modern-go/reflect2 v1.0.2 // indirect 35 | github.com/pelletier/go-toml/v2 v2.1.1 // indirect 36 | github.com/twitchyliquid64/golang-asm v0.15.1 // indirect 37 | github.com/ugorji/go/codec v1.2.12 // indirect 38 | github.com/valyala/bytebufferpool v1.0.0 // indirect 39 | github.com/valyala/fasttemplate v1.2.2 // indirect 40 | golang.org/x/arch v0.7.0 // indirect 41 | golang.org/x/crypto v0.36.0 // indirect 42 | golang.org/x/net v0.38.0 // indirect 43 | golang.org/x/sys v0.31.0 // indirect 44 | golang.org/x/text v0.23.0 // indirect 45 | golang.org/x/time v0.5.0 // indirect 46 | google.golang.org/protobuf v1.33.0 // indirect 47 | gopkg.in/yaml.v3 v3.0.1 // indirect 48 | ) 49 | -------------------------------------------------------------------------------- /adapters/echo-adapter/example/widgets/spec.go: -------------------------------------------------------------------------------- 1 | package widgets 2 | 3 | import "github.com/jakecoffman/crud" 4 | 5 | var tags = []string{"Widgets"} 6 | 7 | var Routes = []crud.Spec{{ 8 | Method: "GET", 9 | Path: "/widgets", 10 | Handler: ok, 11 | Description: "Lists widgets", 12 | Tags: tags, 13 | Validate: crud.Validate{ 14 | Query: crud.Object(map[string]crud.Field{ 15 | "limit": crud.Number().Required().Min(0).Max(25).Description("Records to return"), 16 | "ids": crud.Array().Items(crud.Number()), 17 | }), 18 | }, 19 | }, { 20 | Method: "POST", 21 | Path: "/widgets", 22 | PreHandlers: fakeAuthPreHandler, 23 | Handler: bindAndOk, 24 | Description: "Adds a widget", 25 | Tags: tags, 26 | Validate: crud.Validate{ 27 | Header: crud.Object(map[string]crud.Field{ 28 | "Authentication": crud.String().Required().Description("Must be 'password'"), 29 | }), 30 | Body: crud.Object(map[string]crud.Field{ 31 | "name": crud.String().Required().Example("Bob"), 32 | "arrayMatey": crud.Array().Items(crud.Number()), 33 | }), 34 | }, 35 | Responses: map[string]crud.Response{ 36 | "200": { 37 | Schema: crud.JsonSchema{ 38 | Type: crud.KindObject, 39 | Properties: map[string]crud.JsonSchema{ 40 | "hello": {Type: crud.KindString}, 41 | }, 42 | }, 43 | Description: "OK", 44 | }, 45 | }, 46 | }, { 47 | Method: "GET", 48 | Path: "/widgets/{id}", 49 | Handler: okPath, 50 | Description: "Updates a widget", 51 | Tags: tags, 52 | Validate: crud.Validate{ 53 | Path: crud.Object(map[string]crud.Field{ 54 | "id": crud.Number().Required(), 55 | }), 56 | }, 57 | }, { 58 | Method: "PUT", 59 | Path: "/widgets/{id}", 60 | Handler: bindAndOk, 61 | Description: "Updates a widget", 62 | Tags: tags, 63 | Validate: crud.Validate{ 64 | Path: crud.Object(map[string]crud.Field{ 65 | "id": crud.Number().Required(), 66 | }), 67 | Body: crud.Object(map[string]crud.Field{ 68 | "name": crud.String().Required(), 69 | }), 70 | }, 71 | }, { 72 | Method: "DELETE", 73 | Path: "/widgets/{id}", 74 | Handler: okPath, 75 | Description: "Deletes a widget", 76 | Tags: tags, 77 | Validate: crud.Validate{ 78 | Path: crud.Object(map[string]crud.Field{ 79 | "id": crud.Number().Required(), 80 | }), 81 | }, 82 | }, 83 | } 84 | -------------------------------------------------------------------------------- /spec.go: -------------------------------------------------------------------------------- 1 | package crud 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | // Spec is used to generate swagger paths and automatic handler validation 9 | type Spec struct { 10 | // Method is the http method of the route, e.g. GET, POST 11 | Method string 12 | // Path is the URL path of the routes, w.g. /widgets/{id} 13 | Path string 14 | // PreHandlers run before validation. Good for authorization 15 | PreHandlers interface{} 16 | // Handler runs after validation. This is where you take over 17 | Handler interface{} 18 | // Description is the longer text that will appear in the Swagger under the endpoint 19 | Description string 20 | // Tags are how the Swagger groups paths together, e.g. []string{"Widgets"} 21 | Tags []string 22 | // Summary is a short description of what an endpoint does in the Swagger 23 | Summary string 24 | // Validate is used to automatically validate the various inputs to the endpoint 25 | Validate Validate 26 | // Responses specifies the responses in Swagger. If none provided a default is used. 27 | Responses map[string]Response 28 | } 29 | 30 | var methods = map[string]struct{}{ 31 | "get": {}, 32 | "post": {}, 33 | "put": {}, 34 | "delete": {}, 35 | "options": {}, 36 | "trace": {}, 37 | "patch": {}, 38 | } 39 | 40 | // Valid returns errors if the spec itself isn't valid. This helps finds bugs early. 41 | func (s Spec) Valid() error { 42 | if _, ok := methods[strings.ToLower(s.Method)]; !ok { 43 | return fmt.Errorf("invalid method '%v'", s.Method) 44 | } 45 | 46 | params := pathParms(s.Path) 47 | if len(params) > 0 && !s.Validate.Path.Initialized() { 48 | return fmt.Errorf("path '%v' contains params but no path validation provided", s.Path) 49 | } 50 | 51 | if s.Validate.Path.Initialized() { 52 | if len(params) > 0 && s.Validate.Path.kind != KindObject { 53 | return fmt.Errorf("path must be an object") 54 | } 55 | // not ideal complexity but path params should be pretty small n 56 | for name := range s.Validate.Path.obj { 57 | var found bool 58 | for _, param := range params { 59 | if name == param { 60 | found = true 61 | break 62 | } 63 | } 64 | if !found { 65 | return fmt.Errorf("missing path param '%v' in url: '%v'", name, s.Path) 66 | } 67 | } 68 | 69 | for _, param := range params { 70 | if _, ok := s.Validate.Path.obj[param]; !ok { 71 | return fmt.Errorf("missing path validation '%v' in url: '%v'", param, s.Path) 72 | } 73 | } 74 | } 75 | 76 | return nil 77 | } 78 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## crud 2 | 3 | [![GoDoc](https://godoc.org/github.com/jakecoffman/crud?status.svg)](https://godoc.org/github.com/jakecoffman/crud) 4 | [![Go](https://github.com/jakecoffman/crud/actions/workflows/go.yml/badge.svg)](https://github.com/jakecoffman/crud/actions/workflows/go.yml) 5 | 6 | An OpenAPI v2 builder and validation library for building HTTP/REST APIs. 7 | 8 | No additional dependencies besides the router you choose. 9 | 10 | ### Status 11 | 12 | Version 1.0 is stable, version 2 will support OpenAPI v3. 13 | 14 | ### Why 15 | 16 | OpenAPI is great, but up until now your options to use it are: 17 | 18 | 1. Write YAML by hand and then make your server match your spec. 19 | 2. Write YAML by hand and generate your server. 20 | 3. Generate YAML from comments in your code. 21 | 22 | None of these options seems like a great idea. 23 | 24 | This project takes another approach: make a specification in Go code using type-safe builders where possible. The OpenAPI spec is generated from this and validation is done before your handler gets called. 25 | 26 | This reduces boilerplate that you have to write and gives you nice documentation too! 27 | 28 | ### Examples 29 | 30 | - [ServeMux Example](_example/main.go) 31 | - [Gin-Gonic Example](adapters/gin-adapter/example) 32 | - [Echo Example](adapters/echo-adapter/example) 33 | - [Gorilla Mux Example](adapters/gorilla-adapter/example) 34 | 35 | ### Getting started 36 | 37 | Check the example directory under the adapters for a simple example. 38 | 39 | Start by getting the package `go get github.com/jakecoffman/crud` 40 | 41 | Then in your `main.go`: 42 | 43 | 1. Create a router with `NewRouter`, use an adapter from the `adapters` sub-package or write you own. 44 | 2. Add routes with `Add`. 45 | 3. Then call `Serve`. 46 | 47 | Routes are specifications that look like this: 48 | 49 | ```go 50 | crud.Spec{ 51 | Method: "PATCH", 52 | Path: "/widgets/{id}", 53 | PreHandlers: Auth, 54 | Handler: CreateHandler, 55 | Description: "Adds a widget", 56 | Tags: []string{"Widgets"}, 57 | Validate: crud.Validate{ 58 | Path: crud.Object(map[string]crud.Field{ 59 | "id": crud.Number().Required().Description("ID of the widget"), 60 | }), 61 | Body: crud.Object(map[string]crud.Field{ 62 | "owner": crud.String().Required().Example("Bob").Description("Widget owner's name"), 63 | "quantity": crud.Integer().Min(1).Default(1).Description("The amount requested") 64 | }), 65 | }, 66 | } 67 | ``` 68 | 69 | This will add a route `/widgets/:id` that responds to the PATCH method. It generates swagger and serves it at the root of the web application. It validates that the ID in the path is a number, so you don't have to. It also validates that the body is an object and has an "owner" property that is a string, again so you won't have to. 70 | 71 | It mounts the swagger-ui at `/` and loads up the generated swagger.json: 72 | 73 | ![screenshot](/screenshot.png?raw=true "Swagger") 74 | 75 | The `PreHandlers` run before validation, and the `Handler` runs after validation is successful. 76 | 77 | -------------------------------------------------------------------------------- /adapters/gin-adapter/gin-adapter.go: -------------------------------------------------------------------------------- 1 | package adapter 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "github.com/gin-gonic/gin" 8 | "github.com/jakecoffman/crud" 9 | "io/ioutil" 10 | "net/url" 11 | "reflect" 12 | ) 13 | 14 | type Adapter struct { 15 | Engine *gin.Engine 16 | } 17 | 18 | func New() *Adapter { 19 | return &Adapter{ 20 | Engine: gin.New(), 21 | } 22 | } 23 | 24 | func (a *Adapter) Install(r *crud.Router, spec *crud.Spec) error { 25 | handlers := []gin.HandlerFunc{wrap(r, spec)} 26 | 27 | switch v := spec.PreHandlers.(type) { 28 | case nil: 29 | // ok 30 | case []gin.HandlerFunc: 31 | handlers = append(handlers, v...) 32 | case gin.HandlerFunc: 33 | handlers = append(handlers, v) 34 | case func(*gin.Context): 35 | handlers = append(handlers, v) 36 | default: 37 | return fmt.Errorf("unexpected PreHandlers type: %v", reflect.TypeOf(spec.Handler)) 38 | } 39 | 40 | switch v := spec.Handler.(type) { 41 | case nil: 42 | return fmt.Errorf("handler must not be nil") 43 | case gin.HandlerFunc: 44 | handlers = append(handlers, v) 45 | case func(*gin.Context): 46 | handlers = append(handlers, v) 47 | default: 48 | return fmt.Errorf("handler must be gin.HandlerFunc or func(*gin.Context), got %v", reflect.TypeOf(spec.Handler)) 49 | } 50 | 51 | a.Engine.Handle(spec.Method, swaggerToGinPattern(spec.Path), handlers...) 52 | return nil 53 | } 54 | 55 | func (a *Adapter) Serve(swagger *crud.Swagger, addr string) error { 56 | a.Engine.GET("/swagger.json", func(c *gin.Context) { 57 | c.JSON(200, swagger) 58 | }) 59 | 60 | a.Engine.GET("/", func(c *gin.Context) { 61 | c.Header("content-type", "text/html") 62 | _, err := c.Writer.Write(crud.SwaggerUiTemplate) 63 | if err != nil { 64 | panic(err) 65 | } 66 | }) 67 | 68 | return a.Engine.Run(addr) 69 | } 70 | 71 | // converts swagger endpoints /widget/{id} to gin endpoints /widget/:id 72 | func swaggerToGinPattern(swaggerUrl string) string { 73 | return crud.SwaggerPathPattern.ReplaceAllString(swaggerUrl, ":$1") 74 | } 75 | 76 | func wrap(r *crud.Router, spec *crud.Spec) gin.HandlerFunc { 77 | return func(c *gin.Context) { 78 | val := spec.Validate 79 | var query url.Values 80 | var body interface{} 81 | var path map[string]string 82 | 83 | if val.Path.Initialized() { 84 | path = map[string]string{} 85 | for _, param := range c.Params { 86 | path[param.Key] = param.Value 87 | } 88 | } 89 | 90 | if val.Body.Initialized() && val.Body.Kind() != crud.KindFile { 91 | if err := c.Bind(&body); err != nil { 92 | c.AbortWithStatusJSON(400, err.Error()) 93 | return 94 | } 95 | defer func() { 96 | data, _ := json.Marshal(body) 97 | c.Request.Body = ioutil.NopCloser(bytes.NewReader(data)) 98 | }() 99 | } 100 | 101 | if val.Query.Initialized() { 102 | query = c.Request.URL.Query() 103 | defer func() { 104 | c.Request.URL.RawQuery = query.Encode() 105 | }() 106 | } 107 | 108 | if err := r.Validate(val, query, body, path); err != nil { 109 | c.AbortWithStatusJSON(400, err.Error()) 110 | } 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /swagger.go: -------------------------------------------------------------------------------- 1 | package crud 2 | 3 | type Swagger struct { 4 | Swagger string `json:"swagger"` 5 | Info Info `json:"info"` 6 | BasePath string `json:"basePath,omitempty"` 7 | 8 | Paths map[string]*Path `json:"paths"` 9 | Definitions map[string]JsonSchema `json:"definitions"` 10 | } 11 | 12 | type Info struct { 13 | Title string `json:"title"` 14 | Version string `json:"version"` 15 | } 16 | 17 | type JsonSchema struct { 18 | Type string `json:"type,omitempty"` 19 | Format string `json:"format,omitempty"` 20 | Properties map[string]JsonSchema `json:"properties,omitempty"` 21 | Items *JsonSchema `json:"items,omitempty"` 22 | Required []string `json:"required,omitempty"` 23 | Example interface{} `json:"example,omitempty"` 24 | Description string `json:"description,omitempty"` 25 | Minimum float64 `json:"minimum,omitempty"` 26 | Maximum float64 `json:"maximum,omitempty"` 27 | Enum []interface{} `json:"enum,omitempty"` 28 | Default interface{} `json:"default,omitempty"` 29 | Pattern string `json:"pattern,omitempty"` 30 | } 31 | 32 | type Path struct { 33 | Get *Operation `json:"get,omitempty"` 34 | Post *Operation `json:"post,omitempty"` 35 | Put *Operation `json:"put,omitempty"` 36 | Delete *Operation `json:"delete,omitempty"` 37 | Patch *Operation `json:"patch,omitempty"` 38 | Options *Operation `json:"options,omitempty"` 39 | } 40 | 41 | type Operation struct { 42 | Tags []string `json:"tags,omitempty"` 43 | Parameters []Parameter `json:"parameters,omitempty"` 44 | Responses map[string]Response `json:"responses"` 45 | Description string `json:"description"` 46 | Summary string `json:"summary"` 47 | } 48 | 49 | type Parameter struct { 50 | In string `json:"in"` 51 | Name string `json:"name"` 52 | 53 | // one of path, query, header, body, or form 54 | Type string `json:"type,omitempty"` 55 | Schema *Ref `json:"schema,omitempty"` 56 | 57 | Required *bool `json:"required,omitempty"` 58 | Description string `json:"description,omitempty"` 59 | Minimum *float64 `json:"minimum,omitempty"` 60 | Maximum *float64 `json:"maximum,omitempty"` 61 | Enum []interface{} `json:"enum,omitempty"` 62 | Pattern string `json:"pattern,omitempty"` 63 | Default interface{} `json:"default,omitempty"` 64 | Items *JsonSchema `json:"items,omitempty"` 65 | CollectionFormat string `json:"collectionFormat,omitempty"` 66 | } 67 | 68 | type Ref struct { 69 | Ref string `json:"$ref,omitempty"` 70 | } 71 | 72 | type Response struct { 73 | Schema JsonSchema `json:"schema"` 74 | Description string `json:"description"` 75 | 76 | Example interface{} `json:"interface,omitempty"` 77 | Ref *Ref `json:"$ref,omitempty"` 78 | Headers map[string]string `json:"headers,omitempty"` 79 | } 80 | 81 | var defaultResponse = map[string]Response{ 82 | "default": { 83 | Schema: JsonSchema{Type: "string"}, 84 | Description: "Successful", 85 | }, 86 | } 87 | -------------------------------------------------------------------------------- /adapters/gin-adapter/example/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | "github.com/jakecoffman/crud" 6 | "github.com/jakecoffman/crud/adapters/gin-adapter" 7 | "log" 8 | ) 9 | 10 | func main() { 11 | r := crud.NewRouter("Widget API", "1.0.0", adapter.New()) 12 | 13 | if err := r.Add(Routes...); err != nil { 14 | log.Fatal(err) 15 | } 16 | 17 | log.Println("Serving http://127.0.0.1:8080") 18 | err := r.Serve("127.0.0.1:8080") 19 | if err != nil { 20 | log.Println(err) 21 | } 22 | } 23 | 24 | var tags = []string{"Widgets"} 25 | 26 | var Routes = []crud.Spec{{ 27 | Method: "GET", 28 | Path: "/widgets", 29 | Handler: ok, 30 | Description: "Lists widgets", 31 | Tags: tags, 32 | Validate: crud.Validate{ 33 | Query: crud.Object(map[string]crud.Field{ 34 | "limit": crud.Number().Required().Min(0).Max(25).Description("Records to return"), 35 | "ids": crud.Array().Items(crud.Number()), 36 | }), 37 | }, 38 | }, { 39 | Method: "POST", 40 | Path: "/widgets", 41 | Handler: bindAndRespond, 42 | Description: "Adds a widget", 43 | Tags: tags, 44 | Validate: crud.Validate{ 45 | Body: crud.Object(map[string]crud.Field{ 46 | "name": crud.String().Required().Example("Bob"), 47 | "arrayMatey": crud.Array().Items(crud.Number()), 48 | "date-time": crud.DateTime(), 49 | "date": crud.Date(), 50 | "nested": crud.Object(map[string]crud.Field{ 51 | "hello": crud.String().Pattern("[a-z]").Required(), 52 | "deeper": crud.Object(map[string]crud.Field{ 53 | "number": crud.Number().Required(), 54 | }).Required(), 55 | }), 56 | }).Unknown(false), 57 | }, 58 | Responses: map[string]crud.Response{ 59 | "200": { 60 | Schema: crud.JsonSchema{ 61 | Type: crud.KindObject, 62 | Properties: map[string]crud.JsonSchema{ 63 | "hello": {Type: crud.KindString}, 64 | }, 65 | }, 66 | Description: "OK", 67 | }, 68 | }, 69 | }, { 70 | Method: "GET", 71 | Path: "/widgets/{id}", 72 | Handler: ok, 73 | Description: "Updates a widget", 74 | Tags: tags, 75 | Validate: crud.Validate{ 76 | Path: crud.Object(map[string]crud.Field{ 77 | "id": crud.Number().Required(), 78 | }), 79 | }, 80 | }, { 81 | Method: "PUT", 82 | Path: "/widgets/{id}", 83 | Handler: bindAndRespond, 84 | Description: "Updates a widget", 85 | Tags: tags, 86 | Validate: crud.Validate{ 87 | Path: crud.Object(map[string]crud.Field{ 88 | "id": crud.Number().Required(), 89 | }), 90 | Body: crud.Object(map[string]crud.Field{ 91 | "name": crud.String().Required(), 92 | }), 93 | }, 94 | }, { 95 | Method: "DELETE", 96 | Path: "/widgets/{id}", 97 | Handler: ok, 98 | Description: "Deletes a widget", 99 | Tags: tags, 100 | Validate: crud.Validate{ 101 | Path: crud.Object(map[string]crud.Field{ 102 | "id": crud.Number().Required(), 103 | }), 104 | }, 105 | }, 106 | } 107 | 108 | func ok(c *gin.Context) { 109 | c.JSON(200, c.Request.URL.Query()) 110 | } 111 | 112 | func bindAndRespond(c *gin.Context) { 113 | var widget interface{} 114 | if err := c.Bind(&widget); err != nil { 115 | return 116 | } 117 | c.JSON(200, widget) 118 | } 119 | -------------------------------------------------------------------------------- /adapter_test.go: -------------------------------------------------------------------------------- 1 | package crud 2 | 3 | import ( 4 | "io" 5 | "net/http" 6 | "net/http/httptest" 7 | "strings" 8 | "testing" 9 | ) 10 | 11 | func TestNewServeMuxAdapter(t *testing.T) { 12 | t.Run("validates path parameters", func(t *testing.T) { 13 | adapter := NewServeMuxAdapter() 14 | router := NewRouter("title", "1.0", adapter) 15 | err := router.Add(Spec{ 16 | Method: "GET", 17 | Path: "/widgets/{id}", 18 | Handler: func(w http.ResponseWriter, r *http.Request) {}, 19 | Validate: Validate{ 20 | Path: Object(map[string]Field{ 21 | "id": Number().Required().Max(10), 22 | }), 23 | }, 24 | }) 25 | if err != nil { 26 | t.Fatal(err) 27 | } 28 | 29 | r := httptest.NewRequest("GET", "/widgets/11", nil) 30 | w := httptest.NewRecorder() 31 | adapter.Engine.ServeHTTP(w, r) 32 | 33 | if w.Code != http.StatusBadRequest { 34 | t.Errorf("expected status code %d, got %d", http.StatusBadRequest, w.Code) 35 | } 36 | if !strings.Contains(w.Body.String(), `"path validation failed for field id: maximum exceeded"`) { 37 | t.Errorf("unexpected body %q", w.Body.String()) 38 | } 39 | }) 40 | 41 | t.Run("strips values from the body", func(t *testing.T) { 42 | adapter := NewServeMuxAdapter() 43 | router := NewRouter("title", "1.0", adapter) 44 | err := router.Add(Spec{ 45 | Method: "POST", 46 | Path: "/widgets", 47 | Handler: func(w http.ResponseWriter, r *http.Request) { 48 | // reflect the body back for ease of testing 49 | io.Copy(w, r.Body) 50 | }, 51 | Validate: Validate{ 52 | Body: Object(map[string]Field{ 53 | "value": String().Required(), 54 | }), 55 | }, 56 | }) 57 | if err != nil { 58 | t.Fatal(err) 59 | } 60 | 61 | r := httptest.NewRequest("POST", "/widgets", strings.NewReader(`{"value": "hello", "unexpected": 1}`)) 62 | w := httptest.NewRecorder() 63 | adapter.Engine.ServeHTTP(w, r) 64 | 65 | if w.Code != http.StatusOK { 66 | t.Errorf("expected status code %d, got %d", http.StatusOK, w.Code) 67 | } 68 | // unexpected property should be stripped 69 | if w.Body.String() != `{"value":"hello"}` { 70 | t.Errorf("unexpected body %q", w.Body.String()) 71 | } 72 | }) 73 | 74 | t.Run("unexpected URL parameters are stripped", func(t *testing.T) { 75 | adapter := NewServeMuxAdapter() 76 | router := NewRouter("title", "1.0", adapter) 77 | err := router.Add(Spec{ 78 | Method: "GET", 79 | Path: "/widgets", 80 | Handler: func(w http.ResponseWriter, r *http.Request) { 81 | // reflect the parameters back for ease of testing 82 | io.WriteString(w, r.URL.RawQuery) 83 | }, 84 | Validate: Validate{ 85 | Query: Object(map[string]Field{ 86 | "limit": Number().Required(), 87 | }), 88 | }, 89 | }) 90 | if err != nil { 91 | t.Fatal(err) 92 | } 93 | 94 | r := httptest.NewRequest("GET", "/widgets?limit=1&hello=world", nil) 95 | w := httptest.NewRecorder() 96 | adapter.Engine.ServeHTTP(w, r) 97 | 98 | if w.Code != http.StatusOK { 99 | t.Errorf("expected status code %d, got %d", http.StatusOK, w.Code) 100 | } 101 | // unexpected property should be stripped 102 | if w.Body.String() != `limit=1` { 103 | t.Errorf("unexpected body %q", w.Body.String()) 104 | } 105 | }) 106 | } 107 | -------------------------------------------------------------------------------- /adapters/gorilla-adapter/example/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "github.com/gorilla/mux" 6 | "github.com/jakecoffman/crud" 7 | "github.com/jakecoffman/crud/adapters/gorilla-adapter" 8 | "log" 9 | "math/rand" 10 | "net/http" 11 | ) 12 | 13 | func main() { 14 | r := crud.NewRouter("Widget API", "1.0.0", adapter.New()) 15 | 16 | if err := r.Add(Routes...); err != nil { 17 | log.Fatal(err) 18 | } 19 | 20 | log.Println("Serving http://127.0.0.1:8080") 21 | err := r.Serve("127.0.0.1:8080") 22 | if err != nil { 23 | log.Println(err) 24 | } 25 | } 26 | 27 | var tags = []string{"Widgets"} 28 | 29 | var Routes = []crud.Spec{{ 30 | Method: "GET", 31 | Path: "/widgets", 32 | Handler: ok, 33 | Description: "Lists widgets", 34 | Tags: tags, 35 | Validate: crud.Validate{ 36 | Query: crud.Object(map[string]crud.Field{ 37 | "limit": crud.Number().Required().Min(0).Max(25).Description("Records to return"), 38 | "ids": crud.Array().Items(crud.Number()), 39 | }), 40 | }, 41 | }, { 42 | Method: "POST", 43 | Path: "/widgets", 44 | PreHandlers: fakeAuthPreHandler, 45 | Handler: bindAndOk, 46 | Description: "Adds a widget", 47 | Tags: tags, 48 | Validate: crud.Validate{ 49 | Body: crud.Object(map[string]crud.Field{ 50 | "name": crud.String().Required().Example("Bob"), 51 | "arrayMatey": crud.Array().Items(crud.Number()), 52 | }), 53 | }, 54 | Responses: map[string]crud.Response{ 55 | "200": { 56 | Schema: crud.JsonSchema{ 57 | Type: crud.KindObject, 58 | Properties: map[string]crud.JsonSchema{ 59 | "hello": {Type: crud.KindString}, 60 | }, 61 | }, 62 | Description: "OK", 63 | }, 64 | }, 65 | }, { 66 | Method: "GET", 67 | Path: "/widgets/{id}", 68 | Handler: ok, 69 | Description: "Updates a widget", 70 | Tags: tags, 71 | Validate: crud.Validate{ 72 | Path: crud.Object(map[string]crud.Field{ 73 | "id": crud.Number().Required(), 74 | }), 75 | }, 76 | }, { 77 | Method: "PUT", 78 | Path: "/widgets/{id}", 79 | Handler: bindAndOk, 80 | Description: "Updates a widget", 81 | Tags: tags, 82 | Validate: crud.Validate{ 83 | Path: crud.Object(map[string]crud.Field{ 84 | "id": crud.Number().Required(), 85 | }), 86 | Body: crud.Object(map[string]crud.Field{ 87 | "name": crud.String().Required(), 88 | }), 89 | }, 90 | }, { 91 | Method: "DELETE", 92 | Path: "/widgets/{id}", 93 | Handler: ok, 94 | Description: "Deletes a widget", 95 | Tags: tags, 96 | Validate: crud.Validate{ 97 | Path: crud.Object(map[string]crud.Field{ 98 | "id": crud.Number().Required(), 99 | }), 100 | }, 101 | }, 102 | } 103 | 104 | func fakeAuthPreHandler(next http.Handler) http.Handler { 105 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 106 | if rand.Intn(2) == 0 { 107 | w.WriteHeader(http.StatusTeapot) 108 | _, _ = w.Write([]byte("Random rejection from PreHandler")) 109 | return 110 | } 111 | next.ServeHTTP(w, r) 112 | }) 113 | } 114 | 115 | func ok(w http.ResponseWriter, r *http.Request) { 116 | _ = json.NewEncoder(w).Encode(mux.Vars(r)) 117 | } 118 | 119 | func bindAndOk(w http.ResponseWriter, r *http.Request) { 120 | var widget interface{} 121 | if err := json.NewDecoder(r.Body).Decode(&widget); err != nil { 122 | w.WriteHeader(400) 123 | _ = json.NewEncoder(w).Encode(err.Error()) 124 | return 125 | } 126 | _ = json.NewEncoder(w).Encode(widget) 127 | } 128 | -------------------------------------------------------------------------------- /_example/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "github.com/jakecoffman/crud" 6 | "log" 7 | "math/rand" 8 | "net/http" 9 | ) 10 | 11 | // This example uses the built-in ServeMux with added functionality in Go 1.22. 12 | // To see examples using other routers go to the adapaters directory. 13 | 14 | func main() { 15 | r := crud.NewRouter("Widget API", "1.0.0", crud.NewServeMuxAdapter()) 16 | 17 | if err := r.Add(Routes...); err != nil { 18 | log.Fatal(err) 19 | } 20 | 21 | log.Println("Serving http://127.0.0.1:8080") 22 | err := r.Serve("127.0.0.1:8080") 23 | if err != nil { 24 | log.Println(err) 25 | } 26 | } 27 | 28 | var tags = []string{"Widgets"} 29 | 30 | var Routes = []crud.Spec{{ 31 | Method: "GET", 32 | Path: "/widgets", 33 | Handler: ok, 34 | Description: "Lists widgets", 35 | Tags: tags, 36 | Validate: crud.Validate{ 37 | Query: crud.Object(map[string]crud.Field{ 38 | "limit": crud.Number().Required().Min(0).Max(25).Description("Records to return"), 39 | "ids": crud.Array().Items(crud.Number()), 40 | }), 41 | }, 42 | }, { 43 | Method: "POST", 44 | Path: "/widgets", 45 | PreHandlers: fakeAuthPreHandler, 46 | Handler: bindAndOk, 47 | Description: "Adds a widget", 48 | Tags: tags, 49 | Validate: crud.Validate{ 50 | Body: crud.Object(map[string]crud.Field{ 51 | "name": crud.String().Required().Example("Bob"), 52 | "arrayMatey": crud.Array().Items(crud.Number()), 53 | }), 54 | }, 55 | Responses: map[string]crud.Response{ 56 | "200": { 57 | Schema: crud.JsonSchema{ 58 | Type: crud.KindObject, 59 | Properties: map[string]crud.JsonSchema{ 60 | "hello": {Type: crud.KindString}, 61 | }, 62 | }, 63 | Description: "OK", 64 | }, 65 | }, 66 | }, { 67 | Method: "GET", 68 | Path: "/widgets/{id}", 69 | Handler: ok, 70 | Description: "Updates a widget", 71 | Tags: tags, 72 | Validate: crud.Validate{ 73 | Path: crud.Object(map[string]crud.Field{ 74 | "id": crud.Number().Required(), 75 | }), 76 | }, 77 | }, { 78 | Method: "PUT", 79 | Path: "/widgets/{id}", 80 | Handler: bindAndOk, 81 | Description: "Updates a widget", 82 | Tags: tags, 83 | Validate: crud.Validate{ 84 | Path: crud.Object(map[string]crud.Field{ 85 | "id": crud.Number().Required(), 86 | }), 87 | Body: crud.Object(map[string]crud.Field{ 88 | "name": crud.String().Required(), 89 | }), 90 | }, 91 | }, { 92 | Method: "DELETE", 93 | Path: "/widgets/{id}", 94 | Handler: ok, 95 | Description: "Deletes a widget", 96 | Tags: tags, 97 | Validate: crud.Validate{ 98 | Path: crud.Object(map[string]crud.Field{ 99 | "id": crud.Number().Required(), 100 | }), 101 | }, 102 | }, 103 | } 104 | 105 | func fakeAuthPreHandler(next http.Handler) http.Handler { 106 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 107 | if rand.Intn(2) == 0 { 108 | w.WriteHeader(http.StatusTeapot) 109 | _, _ = w.Write([]byte("Random rejection from PreHandler")) 110 | return 111 | } 112 | next.ServeHTTP(w, r) 113 | }) 114 | } 115 | 116 | func ok(w http.ResponseWriter, r *http.Request) { 117 | _ = json.NewEncoder(w).Encode(r.URL.Query()) 118 | } 119 | 120 | func bindAndOk(w http.ResponseWriter, r *http.Request) { 121 | var widget interface{} 122 | if err := json.NewDecoder(r.Body).Decode(&widget); err != nil { 123 | w.WriteHeader(400) 124 | _ = json.NewEncoder(w).Encode(err.Error()) 125 | return 126 | } 127 | _ = json.NewEncoder(w).Encode(widget) 128 | } 129 | -------------------------------------------------------------------------------- /adapters/echo-adapter/echo-adapter.go: -------------------------------------------------------------------------------- 1 | package adapter 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "github.com/jakecoffman/crud" 8 | "github.com/labstack/echo/v4" 9 | "github.com/labstack/echo/v4/middleware" 10 | "io/ioutil" 11 | "net/url" 12 | "reflect" 13 | ) 14 | 15 | type Adapter struct { 16 | Echo *echo.Echo 17 | } 18 | 19 | func New() *Adapter { 20 | e := echo.New() 21 | e.Use(middleware.Logger()) 22 | e.Use(middleware.Recover()) 23 | 24 | return &Adapter{ 25 | Echo: e, 26 | } 27 | } 28 | 29 | func (a *Adapter) Install(r *crud.Router, spec *crud.Spec) error { 30 | middlewares := []echo.MiddlewareFunc{wrap(r, spec)} 31 | 32 | switch v := spec.PreHandlers.(type) { 33 | case nil: 34 | // ok 35 | case []echo.MiddlewareFunc: 36 | middlewares = append(middlewares, v...) 37 | case echo.MiddlewareFunc: 38 | middlewares = append(middlewares, v) 39 | case func(echo.HandlerFunc) echo.HandlerFunc: 40 | middlewares = append(middlewares, v) 41 | default: 42 | return fmt.Errorf("unexpected PreHandlers type: %v", reflect.TypeOf(spec.Handler)) 43 | } 44 | 45 | var handler echo.HandlerFunc 46 | switch v := spec.Handler.(type) { 47 | case nil: 48 | return fmt.Errorf("handler must not be nil") 49 | case echo.HandlerFunc: 50 | handler = v 51 | case func(echo.Context) error: 52 | handler = v 53 | default: 54 | return fmt.Errorf("handler must be echo.HandlerFunc or func(echo.Context) error, got %v", reflect.TypeOf(spec.Handler)) 55 | } 56 | 57 | a.Echo.Add(spec.Method, swaggerToEchoPattern(spec.Path), handler, middlewares...) 58 | return nil 59 | } 60 | 61 | func (a *Adapter) Serve(swagger *crud.Swagger, addr string) error { 62 | a.Echo.GET("/swagger.json", func(c echo.Context) error { 63 | return c.JSON(200, swagger) 64 | }) 65 | 66 | a.Echo.GET("/", func(c echo.Context) error { 67 | return c.HTML(200, string(crud.SwaggerUiTemplate)) 68 | }) 69 | 70 | return a.Echo.Start(addr) 71 | } 72 | 73 | // converts swagger endpoints /widget/{id} to echo endpoints /widget/:id 74 | func swaggerToEchoPattern(swaggerUrl string) string { 75 | return crud.SwaggerPathPattern.ReplaceAllString(swaggerUrl, ":$1") 76 | } 77 | 78 | func wrap(r *crud.Router, spec *crud.Spec) echo.MiddlewareFunc { 79 | return func(next echo.HandlerFunc) echo.HandlerFunc { 80 | return func(c echo.Context) error { 81 | val := spec.Validate 82 | var query url.Values 83 | var body interface{} 84 | var path map[string]string 85 | 86 | // need this scope so the defers run before next is called 87 | err := func() error { 88 | if val.Path.Initialized() { 89 | path = map[string]string{} 90 | for _, key := range c.ParamNames() { 91 | path[key] = c.Param(key) 92 | } 93 | } 94 | 95 | if val.Body.Initialized() && val.Body.Kind() != crud.KindFile { 96 | if err := json.NewDecoder(c.Request().Body).Decode(&body); err != nil { 97 | _ = c.JSON(400, err.Error()) 98 | return fmt.Errorf("failed to bind body %w", err) 99 | } 100 | 101 | defer func() { 102 | data, err := json.Marshal(body) 103 | if err != nil { 104 | panic(err) 105 | } 106 | c.Request().Body = ioutil.NopCloser(bytes.NewReader(data)) 107 | }() 108 | } 109 | 110 | if val.Query.Initialized() { 111 | query = c.Request().URL.Query() 112 | defer func() { 113 | c.Request().URL.RawQuery = query.Encode() 114 | }() 115 | } 116 | 117 | if err := r.Validate(val, query, body, path); err != nil { 118 | _ = c.JSON(400, err.Error()) 119 | return err 120 | } 121 | 122 | return nil 123 | }() 124 | 125 | if err != nil { 126 | return err 127 | } 128 | 129 | return next(c) 130 | } 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /adapters/gorilla-adapter/gorilla-adapter.go: -------------------------------------------------------------------------------- 1 | package adapter 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "github.com/gorilla/mux" 8 | "github.com/jakecoffman/crud" 9 | "io/ioutil" 10 | "net/http" 11 | "net/url" 12 | "reflect" 13 | ) 14 | 15 | type Adapter struct { 16 | Engine *mux.Router 17 | } 18 | 19 | func New() *Adapter { 20 | return &Adapter{ 21 | Engine: mux.NewRouter(), 22 | } 23 | } 24 | 25 | func (a *Adapter) Install(r *crud.Router, spec *crud.Spec) error { 26 | handlers := []mux.MiddlewareFunc{ 27 | validateHandlerMiddleware(r, spec), 28 | } 29 | 30 | switch v := spec.PreHandlers.(type) { 31 | case nil: 32 | case []mux.MiddlewareFunc: 33 | handlers = append(handlers, v...) 34 | case mux.MiddlewareFunc: 35 | handlers = append(handlers, v) 36 | case func(http.Handler) http.Handler: 37 | handlers = append(handlers, v) 38 | default: 39 | return fmt.Errorf("PreHandlers must be mux.MiddlewareFunc, got: %v", reflect.TypeOf(spec.Handler)) 40 | } 41 | 42 | var finalHandler http.Handler 43 | switch v := spec.Handler.(type) { 44 | case nil: 45 | return fmt.Errorf("handler must not be nil") 46 | case http.HandlerFunc: 47 | finalHandler = v 48 | case func(http.ResponseWriter, *http.Request): 49 | finalHandler = http.HandlerFunc(v) 50 | case http.Handler: 51 | finalHandler = v 52 | default: 53 | return fmt.Errorf("handler must be http.HandlerFunc, got %v", reflect.TypeOf(spec.Handler)) 54 | } 55 | 56 | // without Subrouter, Use would affect all other routes too 57 | subRouter := a.Engine.Path(spec.Path).Methods(spec.Method).Subrouter() 58 | subRouter.Use(handlers...) 59 | subRouter.Handle("", finalHandler) 60 | 61 | return nil 62 | } 63 | 64 | func (a *Adapter) Serve(swagger *crud.Swagger, addr string) error { 65 | a.Engine.HandleFunc("/swagger.json", func(w http.ResponseWriter, r *http.Request) { 66 | _ = json.NewEncoder(w).Encode(swagger) 67 | }) 68 | 69 | a.Engine.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 70 | w.Header().Set("content-type", "text/html") 71 | _, err := w.Write(crud.SwaggerUiTemplate) 72 | if err != nil { 73 | panic(err) 74 | } 75 | }) 76 | 77 | return http.ListenAndServe(addr, a.Engine) 78 | } 79 | 80 | func validateHandlerMiddleware(router *crud.Router, spec *crud.Spec) mux.MiddlewareFunc { 81 | return func(next http.Handler) http.Handler { 82 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 83 | val := spec.Validate 84 | var query url.Values 85 | var body interface{} 86 | var path map[string]string 87 | 88 | if val.Path.Initialized() { 89 | path = map[string]string{} 90 | for key, value := range mux.Vars(r) { 91 | path[key] = value 92 | } 93 | } 94 | 95 | var rewriteBody bool 96 | if val.Body.Initialized() && val.Body.Kind() != crud.KindFile { 97 | if err := json.NewDecoder(r.Body).Decode(&body); err != nil { 98 | w.WriteHeader(400) 99 | _ = json.NewEncoder(w).Encode("failure decoding body: " + err.Error()) 100 | return 101 | } 102 | rewriteBody = true 103 | } 104 | 105 | var rewriteQuery bool 106 | if val.Query.Initialized() { 107 | query = r.URL.Query() 108 | rewriteQuery = true 109 | } 110 | 111 | if err := router.Validate(val, query, body, path); err != nil { 112 | w.WriteHeader(400) 113 | _ = json.NewEncoder(w).Encode(err.Error()) 114 | return 115 | } 116 | 117 | // Validate can strip values that are not valid, so we rewrite them 118 | // after validation is complete. Can't use defer as in other adapters 119 | // because next.ServeHTTP calls the next handler and defer hasn't 120 | // run yet. 121 | if rewriteBody { 122 | data, _ := json.Marshal(body) 123 | _ = r.Body.Close() 124 | r.Body = ioutil.NopCloser(bytes.NewReader(data)) 125 | } 126 | if rewriteQuery { 127 | r.URL.RawQuery = query.Encode() 128 | } 129 | 130 | next.ServeHTTP(w, r) 131 | }) 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /adapter.go: -------------------------------------------------------------------------------- 1 | package crud 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "net/url" 10 | "reflect" 11 | ) 12 | 13 | type ServeMuxAdapter struct { 14 | Engine *http.ServeMux 15 | } 16 | 17 | func NewServeMuxAdapter() *ServeMuxAdapter { 18 | return &ServeMuxAdapter{ 19 | Engine: http.NewServeMux(), 20 | } 21 | } 22 | 23 | type MiddlewareFunc func(http.Handler) http.Handler 24 | 25 | func (a *ServeMuxAdapter) Install(r *Router, spec *Spec) error { 26 | middlewares := []MiddlewareFunc{ 27 | validateHandlerMiddleware(r, spec), 28 | } 29 | 30 | switch v := spec.PreHandlers.(type) { 31 | case nil: 32 | case []MiddlewareFunc: 33 | middlewares = append(middlewares, v...) 34 | case MiddlewareFunc: 35 | middlewares = append(middlewares, v) 36 | case func(http.Handler) http.Handler: 37 | middlewares = append(middlewares, v) 38 | default: 39 | return fmt.Errorf("PreHandlers must be MiddlewareFunc, got: %v", reflect.TypeOf(spec.Handler)) 40 | } 41 | 42 | var finalHandler http.Handler 43 | switch v := spec.Handler.(type) { 44 | case nil: 45 | return fmt.Errorf("handler must not be nil") 46 | case http.HandlerFunc: 47 | finalHandler = v 48 | case func(http.ResponseWriter, *http.Request): 49 | finalHandler = http.HandlerFunc(v) 50 | case http.Handler: 51 | finalHandler = v 52 | default: 53 | return fmt.Errorf("handler must be http.HandlerFunc, got %v", reflect.TypeOf(spec.Handler)) 54 | } 55 | 56 | // install the route, use a subrouter so the "use" is scoped 57 | path := fmt.Sprintf("%s %s", spec.Method, spec.Path) 58 | subrouter := http.NewServeMux() 59 | subrouter.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { 60 | handler := finalHandler 61 | for i := len(middlewares) - 1; i >= 0; i-- { 62 | handler = middlewares[i](handler) 63 | } 64 | handler.ServeHTTP(w, r) 65 | }) 66 | a.Engine.Handle(path, subrouter) 67 | 68 | return nil 69 | } 70 | 71 | func (a *ServeMuxAdapter) Serve(swagger *Swagger, addr string) error { 72 | a.Engine.HandleFunc("GET /swagger.json", func(w http.ResponseWriter, r *http.Request) { 73 | _ = json.NewEncoder(w).Encode(swagger) 74 | }) 75 | 76 | a.Engine.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) { 77 | w.Header().Set("content-type", "text/html") 78 | _, err := w.Write(SwaggerUiTemplate) 79 | if err != nil { 80 | panic(err) 81 | } 82 | }) 83 | 84 | return http.ListenAndServe(addr, a.Engine) 85 | } 86 | 87 | func validateHandlerMiddleware(router *Router, spec *Spec) MiddlewareFunc { 88 | return func(next http.Handler) http.Handler { 89 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 90 | val := spec.Validate 91 | var query url.Values 92 | var body interface{} 93 | var path map[string]string 94 | 95 | if val.Path.Initialized() { 96 | path = map[string]string{} 97 | for name := range val.Path.obj { 98 | path[name] = r.PathValue(name) 99 | } 100 | } 101 | 102 | var rewriteBody bool 103 | if val.Body.Initialized() && val.Body.Kind() != KindFile { 104 | if err := json.NewDecoder(r.Body).Decode(&body); err != nil { 105 | w.WriteHeader(400) 106 | _ = json.NewEncoder(w).Encode("failure decoding body: " + err.Error()) 107 | return 108 | } 109 | rewriteBody = true 110 | } 111 | 112 | var rewriteQuery bool 113 | if val.Query.Initialized() { 114 | query = r.URL.Query() 115 | rewriteQuery = true 116 | } 117 | 118 | if err := router.Validate(val, query, body, path); err != nil { 119 | w.WriteHeader(400) 120 | _ = json.NewEncoder(w).Encode(err.Error()) 121 | return 122 | } 123 | 124 | // Validate can strip values that are not valid, so we rewrite them 125 | // after validation is complete. Can't use defer as in other adapters 126 | // because next.ServeHTTP calls the next handler and defer hasn't 127 | // run yet. 128 | if rewriteBody { 129 | data, _ := json.Marshal(body) 130 | _ = r.Body.Close() 131 | r.Body = io.NopCloser(bytes.NewReader(data)) 132 | } 133 | if rewriteQuery { 134 | r.URL.RawQuery = query.Encode() 135 | } 136 | 137 | next.ServeHTTP(w, r) 138 | }) 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /prehandler.go: -------------------------------------------------------------------------------- 1 | package crud 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | "strconv" 7 | ) 8 | 9 | // Validate checks the spec against the inputs and returns an error if it finds one. 10 | func (r *Router) Validate(val Validate, query url.Values, body interface{}, path map[string]string) error { 11 | if val.Query.kind == KindObject { // not sure how any other type makes sense 12 | 13 | // reject unknown values 14 | if (val.Query.unknown == nil && r.allowUnknown == false) || !val.Query.isAllowUnknown() { 15 | for key := range query { 16 | if _, ok := val.Query.obj[key]; !ok { 17 | return fmt.Errorf("unexpected query parameter %s: %w", key, errUnknown) 18 | } 19 | } 20 | } 21 | 22 | // strip unknown values 23 | if (val.Query.strip == nil && r.stripUnknown == true) || val.Query.isStripUnknown() { 24 | for key := range query { 25 | if _, ok := val.Query.obj[key]; !ok { 26 | delete(query, key) 27 | } 28 | } 29 | } 30 | 31 | for field, schema := range val.Query.obj { 32 | // query values are always strings, so we must try to convert 33 | queryValue := query[field] 34 | 35 | if len(queryValue) == 0 { 36 | if schema.required != nil && *schema.required { 37 | return fmt.Errorf("query validation failed for field %v: %w", field, errRequired) 38 | } 39 | if schema._default != nil { 40 | query[field] = []string{fmt.Sprint(schema._default)} 41 | } 42 | continue 43 | } 44 | if len(queryValue) > 1 { 45 | if schema.kind != KindArray { 46 | return fmt.Errorf("query validation failed for field %v: %w", field, errWrongType) 47 | } 48 | } 49 | if schema.kind == KindArray { 50 | if schema.min != nil && float64(len(queryValue)) < *schema.min { 51 | return errMinimum 52 | } 53 | if schema.max != nil && float64(len(queryValue)) > *schema.max { 54 | return errMaximum 55 | } 56 | // sadly we have to convert to a []interface{} to simplify the validation code 57 | var intray []interface{} 58 | for _, v := range queryValue { 59 | intray = append(intray, v) 60 | } 61 | if schema.arr != nil { 62 | for _, v := range queryValue { 63 | convertedValue, err := convert(v, *schema.arr) 64 | if err != nil { 65 | return fmt.Errorf("query validation failed for field %v: %w", field, err) 66 | } 67 | if err = schema.arr.Validate(convertedValue); err != nil { 68 | return fmt.Errorf("query validation failed for field %v: %w", field, err) 69 | } 70 | } 71 | } 72 | } else { 73 | convertedValue, err := convert(queryValue[0], schema) 74 | if err != nil { 75 | return fmt.Errorf("query validation failed for field %v: %w", field, err) 76 | } 77 | if err = schema.Validate(convertedValue); err != nil { 78 | return fmt.Errorf("query validation failed for field %v: %w", field, err) 79 | } 80 | } 81 | } 82 | } 83 | 84 | if val.Body.Initialized() && val.Body.kind != KindFile { 85 | // use router defaults if the object doesn't have anything set 86 | f := val.Body 87 | if f.strip == nil { 88 | f = f.Strip(r.stripUnknown) 89 | } 90 | if f.unknown == nil { 91 | f = f.Unknown(r.allowUnknown) 92 | } 93 | // ensure Required() since it's confusing and error-prone otherwise 94 | f = f.Required() 95 | if err := f.Validate(body); err != nil { 96 | return err 97 | } 98 | } 99 | 100 | if val.Path.kind == KindObject { 101 | for field, schema := range val.Path.obj { 102 | param := path[field] 103 | 104 | convertedValue, err := convert(param, schema) 105 | if err != nil { 106 | return fmt.Errorf("path validation failed for field %v: %w", field, err) 107 | } 108 | if err = schema.Validate(convertedValue); err != nil { 109 | return fmt.Errorf("path validation failed for field %v: %w", field, err) 110 | } 111 | } 112 | } 113 | 114 | return nil 115 | } 116 | 117 | // For certain types of data passed like Query and Header, the value is always 118 | // a string. So this function attempts to convert the string into the desired field kind. 119 | func convert(inputValue string, schema Field) (interface{}, error) { 120 | // don't try to convert if the field is empty 121 | if inputValue == "" { 122 | if schema.required != nil && *schema.required { 123 | return nil, errRequired 124 | } 125 | return nil, nil 126 | } 127 | var convertedValue interface{} 128 | switch schema.kind { 129 | case KindBoolean: 130 | if inputValue == "true" { 131 | convertedValue = true 132 | } else if inputValue == "false" { 133 | convertedValue = false 134 | } else { 135 | return nil, errWrongType 136 | } 137 | case KindString: 138 | convertedValue = inputValue 139 | case KindNumber: 140 | var err error 141 | convertedValue, err = strconv.ParseFloat(inputValue, 64) 142 | if err != nil { 143 | return nil, errWrongType 144 | } 145 | case KindInteger: 146 | var err error 147 | convertedValue, err = strconv.Atoi(inputValue) 148 | if err != nil { 149 | return nil, errWrongType 150 | } 151 | default: 152 | return nil, fmt.Errorf("unknown kind: %v", schema.kind) 153 | } 154 | return convertedValue, nil 155 | } 156 | -------------------------------------------------------------------------------- /router.go: -------------------------------------------------------------------------------- 1 | package crud 2 | 3 | import ( 4 | _ "embed" 5 | "fmt" 6 | "github.com/jakecoffman/crud/option" 7 | "regexp" 8 | "strings" 9 | ) 10 | 11 | // Router is the main object that is used to generate swagger and holds the underlying router. 12 | type Router struct { 13 | // Swagger is exposed so the user can edit additional optional fields. 14 | Swagger Swagger 15 | 16 | // The underlying router being used behind Adapter interface. 17 | adapter Adapter 18 | 19 | // used for automatically incrementing the model name, e.g. Model 1, Model 2. 20 | modelCounter int 21 | 22 | // options 23 | stripUnknown bool 24 | allowUnknown bool 25 | } 26 | 27 | type Adapter interface { 28 | Install(router *Router, spec *Spec) error 29 | Serve(swagger *Swagger, addr string) error 30 | } 31 | 32 | // NewRouter initializes a router. 33 | func NewRouter(title, version string, adapter Adapter, options ...option.Option) *Router { 34 | r := &Router{ 35 | Swagger: Swagger{ 36 | Swagger: "2.0", 37 | Info: Info{Title: title, Version: version}, 38 | Paths: map[string]*Path{}, 39 | Definitions: map[string]JsonSchema{}, 40 | }, 41 | adapter: adapter, 42 | modelCounter: 1, 43 | stripUnknown: true, 44 | allowUnknown: true, 45 | } 46 | for _, o := range options { 47 | if o.StripUnknown != nil { 48 | r.stripUnknown = *o.StripUnknown 49 | } else if o.AllowUnknown != nil { 50 | r.allowUnknown = *o.AllowUnknown 51 | } 52 | } 53 | return r 54 | } 55 | 56 | // Add routes to the swagger spec and installs a handler with built-in validation. Some validation of the 57 | // route itself occurs on Add so this is the kind of error that can be returned. 58 | func (r *Router) Add(specs ...Spec) error { 59 | for i := range specs { 60 | spec := specs[i] 61 | 62 | if err := spec.Valid(); err != nil { 63 | return err 64 | } 65 | 66 | if _, ok := r.Swagger.Paths[spec.Path]; !ok { 67 | r.Swagger.Paths[spec.Path] = &Path{} 68 | } 69 | path := r.Swagger.Paths[spec.Path] 70 | var operation *Operation 71 | switch strings.ToLower(spec.Method) { 72 | case "get": 73 | if path.Get != nil { 74 | return fmt.Errorf("duplicate GET on route %v", spec.Path) 75 | } 76 | path.Get = &Operation{} 77 | operation = path.Get 78 | case "post": 79 | if path.Post != nil { 80 | return fmt.Errorf("duplicate POST on route %v", spec.Path) 81 | } 82 | path.Post = &Operation{} 83 | operation = path.Post 84 | case "put": 85 | if path.Put != nil { 86 | return fmt.Errorf("duplicate PUT on route %v", spec.Path) 87 | } 88 | path.Put = &Operation{} 89 | operation = path.Put 90 | case "patch": 91 | if path.Patch != nil { 92 | return fmt.Errorf("duplicate PATCH on route %v", spec.Path) 93 | } 94 | path.Patch = &Operation{} 95 | operation = path.Patch 96 | case "options": 97 | if path.Options != nil { 98 | return fmt.Errorf("duplicate PATCH on route %v", spec.Path) 99 | } 100 | path.Options = &Operation{} 101 | operation = path.Options 102 | case "delete": 103 | if path.Delete != nil { 104 | return fmt.Errorf("duplicate DELETE on route %v", spec.Path) 105 | } 106 | path.Delete = &Operation{} 107 | operation = path.Delete 108 | default: 109 | panic("Unhandled method " + spec.Method) 110 | } 111 | operation.Responses = defaultResponse 112 | if spec.Responses != nil { 113 | operation.Responses = spec.Responses 114 | } 115 | operation.Tags = spec.Tags 116 | operation.Description = spec.Description 117 | operation.Summary = spec.Summary 118 | 119 | if spec.Validate.Path.Initialized() { 120 | params := spec.Validate.Path.ToSwaggerParameters("path") 121 | operation.Parameters = append(operation.Parameters, params...) 122 | } 123 | if spec.Validate.Query.Initialized() { 124 | params := spec.Validate.Query.ToSwaggerParameters("query") 125 | operation.Parameters = append(operation.Parameters, params...) 126 | } 127 | if spec.Validate.Header.Initialized() { 128 | params := spec.Validate.Header.ToSwaggerParameters("header") 129 | operation.Parameters = append(operation.Parameters, params...) 130 | } 131 | if spec.Validate.FormData.Initialized() { 132 | params := spec.Validate.FormData.ToSwaggerParameters("formData") 133 | operation.Parameters = append(operation.Parameters, params...) 134 | } 135 | if spec.Validate.Body.Initialized() { 136 | modelName := fmt.Sprintf("Model-%v", r.modelCounter) 137 | parameter := Parameter{ 138 | In: "body", 139 | Name: "body", 140 | Schema: &Ref{fmt.Sprint("#/definitions/", modelName)}, 141 | } 142 | r.Swagger.Definitions[modelName] = spec.Validate.Body.ToJsonSchema() 143 | r.modelCounter++ 144 | operation.Parameters = append(operation.Parameters, parameter) 145 | } 146 | 147 | if err := r.adapter.Install(r, &spec); err != nil { 148 | return err 149 | } 150 | } 151 | return nil 152 | } 153 | 154 | // Validate are optional fields that will be used during validation. Leave unneeded 155 | // properties nil and they will be ignored. 156 | type Validate struct { 157 | Query Field 158 | Body Field 159 | Path Field 160 | FormData Field 161 | Header Field 162 | } 163 | 164 | // Serve installs the swagger and the swagger-ui and runs the server. 165 | func (r *Router) Serve(addr string) error { 166 | return r.adapter.Serve(&r.Swagger, addr) 167 | } 168 | 169 | // SwaggerPathPattern regex captures swagger path params. 170 | var SwaggerPathPattern = regexp.MustCompile("\\{([^}]+)\\}") 171 | 172 | func pathParms(swaggerUrl string) (params []string) { 173 | for _, p := range SwaggerPathPattern.FindAllString(swaggerUrl, -1) { 174 | params = append(params, p[1:len(p)-1]) 175 | } 176 | return 177 | } 178 | 179 | // SwaggerUiTemplate contains the html for swagger UI. 180 | //go:embed swaggerui.html 181 | var SwaggerUiTemplate []byte 182 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= 2 | github.com/bytedance/sonic v1.10.0-rc/go.mod h1:ElCzW+ufi8qKqNW0FY314xriJhyJhuoJ3gFZdAHF7NM= 3 | github.com/bytedance/sonic v1.10.2 h1:GQebETVBxYB7JGWJtLBi07OVzWwt+8dWA00gEVW2ZFE= 4 | github.com/bytedance/sonic v1.10.2/go.mod h1:iZcSUejdk5aukTND/Eu/ivjQuEL0Cu9/rf50Hi0u/g4= 5 | github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= 6 | github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= 7 | github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d h1:77cEq6EriyTZ0g/qfRdp61a3Uu/AWrgIq2s0ClJV1g0= 8 | github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d/go.mod h1:8EPpVsBuRksnlj1mLy4AWzRNQYxauNi62uWcE3to6eA= 9 | github.com/chenzhuoyu/iasm v0.9.0 h1:9fhXjVzq5hUy2gkhhgHl95zG2cEAhw9OSGs8toWWAwo= 10 | github.com/chenzhuoyu/iasm v0.9.0/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog= 11 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 12 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 13 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 14 | github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= 15 | github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= 16 | github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= 17 | github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= 18 | github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= 19 | github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= 20 | github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= 21 | github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= 22 | github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= 23 | github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= 24 | github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= 25 | github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= 26 | github.com/go-playground/validator/v10 v10.17.0 h1:SmVVlfAOtlZncTxRuinDPomC2DkXJ4E5T9gDA0AIH74= 27 | github.com/go-playground/validator/v10 v10.17.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= 28 | github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= 29 | github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= 30 | github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= 31 | github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= 32 | github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= 33 | github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 34 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 35 | github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= 36 | github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= 37 | github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 38 | github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 39 | github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= 40 | github.com/klauspost/cpuid/v2 v2.2.6 h1:ndNyv040zDGIDh8thGkXYjnFtiN02M1PVVF+JE/48xc= 41 | github.com/klauspost/cpuid/v2 v2.2.6/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= 42 | github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= 43 | github.com/labstack/echo/v4 v4.11.4 h1:vDZmA+qNeh1pd/cCkEicDMrjtrnMGQ1QFI9gWN1zGq8= 44 | github.com/labstack/echo/v4 v4.11.4/go.mod h1:noh7EvLwqDsmh/X/HWKPUl1AjzJrhyptRyEbQJfxen8= 45 | github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= 46 | github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= 47 | github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= 48 | github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= 49 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 50 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 51 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 52 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 53 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 54 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 55 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= 56 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 57 | github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= 58 | github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 59 | github.com/pelletier/go-toml/v2 v2.1.1 h1:LWAJwfNvjQZCFIDKWYQaM62NcYeYViCmWIwmOStowAI= 60 | github.com/pelletier/go-toml/v2 v2.1.1/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= 61 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 62 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 63 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 64 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 65 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 66 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 67 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 68 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 69 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 70 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 71 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= 72 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 73 | github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= 74 | github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= 75 | github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= 76 | github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= 77 | github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= 78 | github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= 79 | github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= 80 | github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= 81 | golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= 82 | golang.org/x/arch v0.7.0 h1:pskyeJh/3AmoQ8CPE95vxHLqp1G1GfGNXTmcl9NEKTc= 83 | golang.org/x/arch v0.7.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= 84 | golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= 85 | golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= 86 | golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= 87 | golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= 88 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 89 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 90 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 91 | golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= 92 | golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 93 | golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= 94 | golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= 95 | golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= 96 | golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 97 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= 98 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 99 | google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= 100 | google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= 101 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 102 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 103 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 104 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 105 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 106 | nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= 107 | rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= 108 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /field_test.go: -------------------------------------------------------------------------------- 1 | package crud 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "testing" 7 | "time" 8 | ) 9 | 10 | func TestField_Max_Panic(t *testing.T) { 11 | defer func() { 12 | if r := recover(); r == nil { 13 | t.Errorf("The code did not panic") 14 | } 15 | }() 16 | 17 | String().Min(2).Max(1) 18 | } 19 | 20 | func TestField_Min_Panic(t *testing.T) { 21 | defer func() { 22 | if r := recover(); r == nil { 23 | t.Errorf("The code did not panic") 24 | } 25 | }() 26 | 27 | String().Max(1).Min(2) 28 | } 29 | 30 | func TestField_Pattern_Panic(t *testing.T) { 31 | defer func() { 32 | if r := recover(); r == nil { 33 | t.Errorf("The code did not panic") 34 | } 35 | }() 36 | 37 | String().Pattern("[") 38 | } 39 | 40 | func TestField_String(t *testing.T) { 41 | table := []struct { 42 | Field Field 43 | Input interface{} 44 | Expected error 45 | }{ 46 | { 47 | Field: String(), 48 | Input: nil, 49 | Expected: nil, 50 | }, 51 | { 52 | Field: String().Required(), 53 | Input: nil, 54 | Expected: errRequired, 55 | }, 56 | { 57 | Field: String().Required(), 58 | Input: 7, 59 | Expected: errWrongType, 60 | }, 61 | { 62 | Field: String().Required(), 63 | Input: "", 64 | Expected: errRequired, 65 | }, 66 | { 67 | Field: String().Required().Allow(""), 68 | Input: "", 69 | Expected: nil, 70 | }, 71 | { 72 | Field: String().Required(), 73 | Input: "anything", 74 | Expected: nil, 75 | }, 76 | { 77 | Field: String().Min(1), 78 | Input: "", 79 | Expected: errMinimum, 80 | }, 81 | { 82 | Field: String().Min(1), 83 | Input: "1", 84 | Expected: nil, 85 | }, 86 | { 87 | Field: String().Max(1), 88 | Input: "1", 89 | Expected: nil, 90 | }, 91 | { 92 | Field: String().Max(1), 93 | Input: "12", 94 | Expected: errMaximum, 95 | }, 96 | { 97 | Field: String().Enum("hi"), 98 | Input: "", 99 | Expected: errEnumNotFound, 100 | }, 101 | { 102 | Field: String().Enum("hi"), 103 | Input: "hi", 104 | Expected: nil, 105 | }, 106 | { 107 | Field: String().Pattern("[a-c]"), 108 | Input: "abc", 109 | Expected: nil, 110 | }, 111 | { 112 | Field: String().Pattern("[a-c]"), 113 | Input: "def", 114 | Expected: errPattern, 115 | }, 116 | } 117 | 118 | for i, test := range table { 119 | if v := test.Field.Validate(test.Input); v != test.Expected { 120 | t.Errorf("%v: For input '%v', expected '%v' got '%v'", i, test.Input, test.Expected, v) 121 | } 122 | } 123 | } 124 | 125 | func TestField_Integer(t *testing.T) { 126 | table := []struct { 127 | Field Field 128 | Input interface{} 129 | Expected error 130 | }{ 131 | { 132 | Field: Integer(), 133 | Input: 7.2, 134 | Expected: errWrongType, 135 | }, 136 | { 137 | Field: Integer(), 138 | Input: "7", 139 | Expected: errWrongType, 140 | }, 141 | { 142 | Field: Integer(), 143 | Input: 7., // Allowed since body will contain float64 with JSON 144 | Expected: nil, 145 | }, 146 | { 147 | Field: Integer(), 148 | Input: nil, 149 | Expected: nil, 150 | }, 151 | { 152 | Field: Integer().Required(), 153 | Input: nil, 154 | Expected: errRequired, 155 | }, 156 | { 157 | Field: Integer().Required(), 158 | Input: 0, 159 | Expected: nil, 160 | }, 161 | { 162 | Field: Integer().Min(1), 163 | Input: 0, 164 | Expected: errMinimum, 165 | }, 166 | { 167 | Field: Integer().Min(1), 168 | Input: 1, 169 | Expected: nil, 170 | }, 171 | { 172 | Field: Integer().Max(1), 173 | Input: 1, 174 | Expected: nil, 175 | }, 176 | { 177 | Field: Integer().Max(1), 178 | Input: 12, 179 | Expected: errMaximum, 180 | }, 181 | { 182 | Field: Integer().Enum(1, 2, 3), 183 | Input: 4, 184 | Expected: errEnumNotFound, 185 | }, 186 | { 187 | Field: Integer().Enum(1, 2, 3), 188 | Input: 2, 189 | Expected: nil, 190 | }, 191 | } 192 | 193 | for i, test := range table { 194 | if v := test.Field.Validate(test.Input); v != test.Expected { 195 | t.Errorf("%v: For input '%v', expected '%v' got '%v'", i, test.Input, test.Expected, v) 196 | } 197 | } 198 | } 199 | 200 | func TestField_Float64(t *testing.T) { 201 | table := []struct { 202 | Field Field 203 | Input interface{} 204 | Expected error 205 | }{ 206 | { 207 | Field: Number(), 208 | Input: "7", 209 | Expected: errWrongType, 210 | }, 211 | { 212 | Field: Number(), 213 | Input: nil, 214 | Expected: nil, 215 | }, 216 | { 217 | Field: Number().Required(), 218 | Input: nil, 219 | Expected: errRequired, 220 | }, 221 | { 222 | Field: Number().Required(), 223 | Input: 0., 224 | Expected: nil, 225 | }, 226 | { 227 | Field: Number().Min(1.1), 228 | Input: 0., 229 | Expected: errMinimum, 230 | }, 231 | { 232 | Field: Number().Min(1.1), 233 | Input: 1.1, 234 | Expected: nil, 235 | }, 236 | { 237 | Field: Number().Max(1.2), 238 | Input: 1.2, 239 | Expected: nil, 240 | }, 241 | { 242 | Field: Number().Max(1), 243 | Input: 12., 244 | Expected: errMaximum, 245 | }, 246 | { 247 | Field: Number().Enum(1., 2.), 248 | Input: 3., 249 | Expected: errEnumNotFound, 250 | }, 251 | { 252 | Field: Number().Enum(1., 2.), 253 | Input: 1., 254 | Expected: nil, 255 | }, 256 | } 257 | 258 | for i, test := range table { 259 | if v := test.Field.Validate(test.Input); v != test.Expected { 260 | t.Errorf("%v: For input '%v', expected '%v' got '%v'", i, test.Input, test.Expected, v) 261 | } 262 | } 263 | } 264 | 265 | func TestField_Boolean(t *testing.T) { 266 | table := []struct { 267 | Field Field 268 | Input interface{} 269 | Expected error 270 | }{ 271 | { 272 | Field: Boolean(), 273 | Input: "true", 274 | Expected: errWrongType, 275 | }, 276 | { 277 | Field: Boolean(), 278 | Input: nil, 279 | Expected: nil, 280 | }, 281 | { 282 | Field: Boolean().Required(), 283 | Input: nil, 284 | Expected: errRequired, 285 | }, 286 | { 287 | Field: Boolean().Required(), 288 | Input: true, 289 | Expected: nil, 290 | }, 291 | { 292 | Field: Boolean(), 293 | Input: false, 294 | Expected: nil, 295 | }, 296 | { 297 | Field: Boolean().Enum(true), 298 | Input: false, 299 | Expected: errEnumNotFound, 300 | }, 301 | { 302 | Field: Boolean().Enum(true), 303 | Input: true, 304 | Expected: nil, 305 | }, 306 | } 307 | 308 | for i, test := range table { 309 | if v := test.Field.Validate(test.Input); v != test.Expected { 310 | t.Errorf("%v: For input '%v', expected '%v' got '%v'", i, test.Input, test.Expected, v) 311 | } 312 | } 313 | } 314 | 315 | func TestField_Array(t *testing.T) { 316 | table := []struct { 317 | Field Field 318 | Input interface{} 319 | Expected error 320 | }{ 321 | { 322 | Field: Array(), 323 | Input: true, 324 | Expected: errWrongType, 325 | }, 326 | { 327 | Field: Array(), 328 | Input: nil, 329 | Expected: nil, 330 | }, 331 | { 332 | Field: Array().Required(), 333 | Input: nil, 334 | Expected: errRequired, 335 | }, 336 | { 337 | Field: Array().Required(), 338 | Input: []interface{}{}, 339 | Expected: nil, 340 | }, 341 | { 342 | Field: Array(), 343 | Input: []interface{}{1}, 344 | Expected: nil, 345 | }, 346 | { 347 | Field: Array().Min(1), 348 | Input: []interface{}{}, 349 | Expected: errMinimum, 350 | }, 351 | { 352 | Field: Array().Min(1), 353 | Input: []interface{}{1}, 354 | Expected: nil, 355 | }, 356 | { 357 | Field: Array().Max(1), 358 | Input: []interface{}{1, 2}, 359 | Expected: errMaximum, 360 | }, 361 | { 362 | Field: Array().Max(1), 363 | Input: []interface{}{1}, 364 | Expected: nil, 365 | }, 366 | { 367 | Field: Array().Items(String()), 368 | Input: []interface{}{}, 369 | Expected: nil, 370 | }, 371 | { 372 | Field: Array().Items(String().Required()), 373 | Input: []interface{}{""}, 374 | Expected: errRequired, 375 | }, 376 | { 377 | Field: Array().Items(String().Required()), 378 | Input: []interface{}{"hi"}, 379 | Expected: nil, 380 | }, 381 | } 382 | 383 | for i, test := range table { 384 | if v := test.Field.Validate(test.Input); v != test.Expected { 385 | t.Errorf("%v: For input '%v', expected '%v' got '%v'", i, test.Input, test.Expected, v) 386 | } 387 | } 388 | } 389 | 390 | func TestField_Object(t *testing.T) { 391 | table := []struct { 392 | Field Field 393 | Input interface{} 394 | Expected error 395 | }{ 396 | { 397 | Field: Object(map[string]Field{}), 398 | Input: "", 399 | Expected: errWrongType, 400 | }, 401 | { 402 | Field: Object(map[string]Field{}), 403 | Input: nil, 404 | Expected: nil, 405 | }, 406 | { 407 | Field: Object(map[string]Field{}).Required(), 408 | Input: nil, 409 | Expected: errRequired, 410 | }, 411 | { 412 | Field: Object(map[string]Field{}).Required(), 413 | Input: map[string]interface{}{}, 414 | Expected: nil, 415 | }, 416 | { 417 | Field: Object(map[string]Field{ 418 | "nested": Integer().Required().Min(1), 419 | }), 420 | Input: map[string]interface{}{}, 421 | Expected: errRequired, 422 | }, 423 | { 424 | Field: Object(map[string]Field{ 425 | "nested": Integer().Required().Min(1), 426 | }), 427 | Input: map[string]interface{}{ 428 | "nested": 1.1, 429 | }, 430 | Expected: errWrongType, 431 | }, 432 | { 433 | Field: Object(map[string]Field{ 434 | "nested": Integer().Required().Min(1), 435 | }), 436 | Input: map[string]interface{}{ 437 | "nested": 1., 438 | }, 439 | Expected: nil, 440 | }, 441 | } 442 | 443 | for i, test := range table { 444 | if v := test.Field.Validate(test.Input); !errors.Is(v, test.Expected) { 445 | t.Errorf("%v: For input '%v', expected '%v' got '%v'", i, test.Input, test.Expected, v) 446 | } 447 | } 448 | } 449 | 450 | // Tests children inheriting their parent's settings 451 | func TestField_Object_Setting_Inheritance(t *testing.T) { 452 | obj := Object(map[string]Field{ 453 | "child": Object(map[string]Field{}), 454 | }).Strip(false) 455 | 456 | input := map[string]interface{}{ 457 | "child": map[string]interface{}{ 458 | "grandchild": true, 459 | }, 460 | "another": "hi", 461 | } 462 | 463 | err := obj.Validate(input) 464 | if err != nil { 465 | t.Errorf(err.Error()) 466 | } 467 | 468 | if v, ok := input["another"]; !ok { 469 | t.Errorf("another is missing") 470 | } else { 471 | if v != "hi" { 472 | t.Error("expected hi got", v) 473 | } 474 | } 475 | 476 | if v, ok := input["child"]; !ok { 477 | t.Errorf("child missing") 478 | } else { 479 | child := v.(map[string]interface{}) 480 | if v, ok := child["grandchild"]; !ok { 481 | t.Errorf("grandchild missing") 482 | } else if v != true { 483 | t.Error("grandchild expected true, got", v) 484 | } 485 | } 486 | 487 | obj = Object(map[string]Field{ 488 | "child": Object(map[string]Field{}).Strip(true), 489 | }).Strip(false) 490 | 491 | err = obj.Validate(input) 492 | if err != nil { 493 | t.Errorf(err.Error()) 494 | } 495 | 496 | if v, ok := input["another"]; !ok { 497 | t.Errorf("another is missing") 498 | } else { 499 | if v != "hi" { 500 | t.Error("expected hi got", v) 501 | } 502 | } 503 | 504 | if v, ok := input["child"]; !ok { 505 | t.Errorf("child missing") 506 | } else { 507 | child := v.(map[string]interface{}) 508 | if _, ok := child["grandchild"]; ok { 509 | t.Errorf("expected grandchild to be stripped, but it still exists") 510 | } 511 | } 512 | } 513 | 514 | // Tests children inheriting their parent's settings 515 | func TestField_Array_Setting_Inheritance(t *testing.T) { 516 | obj := Array().Items(Object(map[string]Field{})).Strip(false) 517 | 518 | input := []interface{}{ 519 | map[string]interface{}{ 520 | "hello": "world", 521 | }, 522 | } 523 | 524 | err := obj.Validate(input) 525 | if err != nil { 526 | t.Errorf(err.Error()) 527 | } 528 | 529 | if fmt.Sprint(input) != "[map[hello:world]]" { 530 | t.Errorf(fmt.Sprint(input)) 531 | } 532 | 533 | obj = Array().Items(Object(map[string]Field{}).Strip(true)).Strip(false) 534 | 535 | err = obj.Validate(input) 536 | if err != nil { 537 | t.Errorf(err.Error()) 538 | } 539 | 540 | if fmt.Sprint(input) != "[map[]]" { 541 | t.Errorf(fmt.Sprint(input)) 542 | } 543 | } 544 | 545 | func TestField_Validate_DateTime(t *testing.T) { 546 | dt := DateTime() 547 | err := dt.Validate("q") 548 | switch v := err.(type) { 549 | case *time.ParseError: 550 | default: 551 | t.Errorf("Expected a ParseError, got %s", v) 552 | } 553 | 554 | if err = dt.Validate(time.Now().Format(time.RFC3339)); err != nil { 555 | t.Errorf("Expected no error, got %s", err) 556 | } 557 | } 558 | 559 | func TestField_Validate_Date(t *testing.T) { 560 | dt := Date() 561 | err := dt.Validate("q") 562 | switch v := err.(type) { 563 | case *time.ParseError: 564 | default: 565 | t.Errorf("Expected a ParseError, got %s", v) 566 | } 567 | 568 | if err = dt.Validate(time.Now().Format(fullDate)); err != nil { 569 | t.Errorf("Expected no error, got %s", err) 570 | } 571 | } 572 | 573 | func TestToSwaggerParameters(t *testing.T) { 574 | t.Run("sorts the objects by name", func(t *testing.T) { 575 | field := Object(map[string]Field{ 576 | "b": String(), 577 | "a": String(), 578 | }) 579 | swaggerParams := field.ToSwaggerParameters("query") 580 | 581 | if swaggerParams[0].Name != "a" { 582 | t.Errorf("Expected 'a' to be first, got %s", swaggerParams[0].Name) 583 | } 584 | 585 | if swaggerParams[1].Name != "b" { 586 | t.Errorf("Expected 'b' to be second, got %s", swaggerParams[1].Name) 587 | } 588 | }) 589 | } 590 | -------------------------------------------------------------------------------- /field.go: -------------------------------------------------------------------------------- 1 | package crud 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | "regexp" 7 | "slices" 8 | "strings" 9 | "time" 10 | ) 11 | 12 | // Field allows specification of swagger or json schema types using the builder pattern. 13 | type Field struct { 14 | kind string 15 | format string 16 | obj map[string]Field 17 | max *float64 18 | min *float64 19 | pattern *regexp.Regexp 20 | required *bool 21 | example interface{} 22 | description string 23 | enum enum 24 | _default interface{} 25 | arr *Field 26 | allow enum 27 | strip *bool 28 | unknown *bool 29 | } 30 | 31 | func (f Field) String() string { 32 | var str strings.Builder 33 | str.WriteString(fmt.Sprintf("{Field: '%v'", f.kind)) 34 | if f.required != nil { 35 | str.WriteString(" required") 36 | } 37 | str.WriteString("}") 38 | return str.String() 39 | } 40 | 41 | // Initialized returns true if the field has been initialized with Number, String, etc. 42 | // When the Swagger is being built, often an uninitialized field will be ignored. 43 | func (f Field) Initialized() bool { 44 | return f.kind != "" 45 | } 46 | 47 | // Kind returns the kind of the field. 48 | func (f Field) Kind() string { 49 | return f.kind 50 | } 51 | 52 | type enum []interface{} 53 | 54 | func (e enum) has(needle interface{}) bool { 55 | for _, value := range e { 56 | if value == needle { 57 | return true 58 | } 59 | } 60 | return false 61 | } 62 | 63 | var ( 64 | errRequired = fmt.Errorf("value is required") 65 | errWrongType = fmt.Errorf("wrong type passed") 66 | errMaximum = fmt.Errorf("maximum exceeded") 67 | errMinimum = fmt.Errorf("minimum exceeded") 68 | errEnumNotFound = fmt.Errorf("value not in enum") 69 | errUnknown = fmt.Errorf("unknown value") 70 | errPattern = fmt.Errorf("value does not match pattern") 71 | ) 72 | 73 | // Validate is used in the validation middleware to tell if the value passed 74 | // into the controller meets the restrictions set on the field. 75 | func (f *Field) Validate(value interface{}) error { 76 | if value == nil && f.required != nil && *f.required { 77 | return errRequired 78 | } 79 | if value == nil { 80 | return nil 81 | } 82 | 83 | switch v := value.(type) { 84 | case int: 85 | if f.kind != KindInteger { 86 | return errWrongType 87 | } 88 | if f.max != nil && float64(v) > *f.max { 89 | return errMaximum 90 | } 91 | if f.min != nil && float64(v) < *f.min { 92 | return errMinimum 93 | } 94 | case float64: 95 | if f.kind == KindInteger { 96 | // since JSON is unmarshalled as float64 always 97 | if float64(int(v)) != v { 98 | return errWrongType 99 | } 100 | } else if f.kind != KindNumber { 101 | return errWrongType 102 | } 103 | if f.max != nil && v > *f.max { 104 | return errMaximum 105 | } 106 | if f.min != nil && v < *f.min { 107 | return errMinimum 108 | } 109 | case string: 110 | if f.kind != KindString { 111 | return errWrongType 112 | } 113 | if f.required != nil && *f.required && v == "" && !f.allow.has("") { 114 | return errRequired 115 | } 116 | if f.max != nil && len(v) > int(*f.max) { 117 | return errMaximum 118 | } 119 | if f.min != nil && len(v) < int(*f.min) { 120 | return errMinimum 121 | } 122 | if f.pattern != nil && !f.pattern.MatchString(v) { 123 | return errPattern 124 | } 125 | switch f.format { 126 | case FormatDateTime: 127 | _, err := time.Parse(time.RFC3339, v) 128 | if err != nil { 129 | return err 130 | } 131 | case FormatDate: 132 | _, err := time.Parse(fullDate, v) 133 | if err != nil { 134 | return err 135 | } 136 | } 137 | case bool: 138 | if f.kind != KindBoolean { 139 | return errWrongType 140 | } 141 | case []interface{}: 142 | if f.kind != KindArray { 143 | return errWrongType 144 | } 145 | if f.min != nil && float64(len(v)) < *f.min { 146 | return errMinimum 147 | } 148 | if f.max != nil && float64(len(v)) > *f.max { 149 | return errMaximum 150 | } 151 | if f.arr != nil { 152 | // child fields inherit parent's settings, unless specified on child 153 | if f.arr.strip == nil { 154 | f.arr.strip = f.strip 155 | } 156 | if f.arr.unknown == nil { 157 | f.arr.unknown = f.unknown 158 | } 159 | for _, item := range v { 160 | if err := f.arr.Validate(item); err != nil { 161 | return err 162 | } 163 | } 164 | } 165 | case map[string]interface{}: 166 | if f.kind != KindObject { 167 | return errWrongType 168 | } 169 | return validateObject("", f, v) 170 | default: 171 | return fmt.Errorf("unhandled type %v", v) 172 | } 173 | 174 | if f.enum != nil && !f.enum.has(value) { 175 | return errEnumNotFound 176 | } 177 | 178 | return nil 179 | } 180 | 181 | // validateObject is a recursive function that validates the field values in the object. It also 182 | // performs stripping of values, or erroring when unexpected fields are present, depending on the 183 | // options on the fields. 184 | func validateObject(name string, field *Field, input interface{}) error { 185 | switch v := input.(type) { 186 | case nil: 187 | if field.required != nil && *field.required { 188 | return fmt.Errorf("object validation failed for field %v: %w", name, errRequired) 189 | } 190 | case string, bool: 191 | if err := field.Validate(v); err != nil { 192 | return fmt.Errorf("object validation failed for field %v: %w", name, err) 193 | } 194 | case float64: 195 | if field.kind == KindInteger { 196 | // JSON doesn't have integers, so Go treats these fields as float64. 197 | // Need to convert to integer before validating it. 198 | if v != float64(int64(v)) { 199 | return fmt.Errorf("object validation failed for field %v: %w", name, errWrongType) 200 | } 201 | if err := field.Validate(int(v)); err != nil { 202 | return fmt.Errorf("object validation failed for field %v: %w", name, err) 203 | } 204 | } else { 205 | if err := field.Validate(v); err != nil { 206 | return fmt.Errorf("object validation failed for field %v: %w", name, err) 207 | } 208 | } 209 | case []interface{}: 210 | if err := field.Validate(v); err != nil { 211 | return fmt.Errorf("object validation failed for field %v: %w", name, err) 212 | } 213 | if field.arr != nil { 214 | for i, item := range v { 215 | if err := validateObject(fmt.Sprintf("%v[%v]", name, i), field.arr, item); err != nil { 216 | return err 217 | } 218 | } 219 | } 220 | case map[string]interface{}: 221 | if !field.isAllowUnknown() { 222 | for key := range v { 223 | if _, ok := field.obj[key]; !ok { 224 | return fmt.Errorf("unknown field in object: %v %w", key, errUnknown) 225 | } 226 | } 227 | } 228 | 229 | if field.isStripUnknown() { 230 | for key := range v { 231 | if _, ok := field.obj[key]; !ok { 232 | delete(v, key) 233 | } 234 | } 235 | } 236 | 237 | for childName, childField := range field.obj { 238 | // child fields inherit parent's settings, unless specified on child 239 | if childField.strip == nil { 240 | childField.strip = field.strip 241 | } 242 | if childField.unknown == nil { 243 | childField.unknown = field.unknown 244 | } 245 | 246 | newV := v[childName] 247 | if newV == nil && childField.required != nil && *childField.required { 248 | return fmt.Errorf("object validation failed for field %v.%v: %w", name, childName, errRequired) 249 | } else if newV == nil && childField._default != nil { 250 | v[childName] = childField._default 251 | } else if err := validateObject(name+"."+childName, &childField, v[childName]); err != nil { 252 | return err 253 | } 254 | } 255 | default: 256 | return fmt.Errorf("object validation failed for type %v: %w", reflect.TypeOf(v), errWrongType) 257 | } 258 | return nil 259 | } 260 | 261 | // These kinds correlate to swagger and json types. 262 | const ( 263 | KindNumber = "number" 264 | KindString = "string" 265 | KindBoolean = "boolean" 266 | KindObject = "object" 267 | KindArray = "array" 268 | KindFile = "file" 269 | KindInteger = "integer" 270 | ) 271 | 272 | // Number creates a field with floating point type 273 | func Number() Field { 274 | return Field{kind: KindNumber} 275 | } 276 | 277 | // String creates a field with string type 278 | func String() Field { 279 | return Field{kind: KindString} 280 | } 281 | 282 | // DateTime creates a field with dateTime type 283 | func DateTime() Field { 284 | return Field{kind: KindString, format: FormatDateTime} 285 | } 286 | 287 | // https://xml2rfc.tools.ietf.org/public/rfc/html/rfc3339.html#anchor14 288 | const fullDate = "2006-01-02" 289 | 290 | // Date creates a field with date type 291 | func Date() Field { 292 | return Field{kind: KindString, format: FormatDate} 293 | } 294 | 295 | // Boolean creates a field with boolean type 296 | func Boolean() Field { 297 | return Field{kind: KindBoolean} 298 | } 299 | 300 | // Object creates a field with object type 301 | func Object(obj map[string]Field) Field { 302 | return Field{kind: KindObject, obj: obj} 303 | } 304 | 305 | // Array creates a field with array type 306 | func Array() Field { 307 | return Field{kind: KindArray} 308 | } 309 | 310 | // File creates a field with file type 311 | func File() Field { 312 | return Field{kind: KindFile} 313 | } 314 | 315 | // Integer creates a field with integer type 316 | func Integer() Field { 317 | return Field{kind: KindInteger} 318 | } 319 | 320 | // Min specifies a minimum value for this field 321 | func (f Field) Min(min float64) Field { 322 | f.min = &min 323 | if f.max != nil && *f.max < min { 324 | panic("min cannot be larger than max") 325 | } 326 | return f 327 | } 328 | 329 | // Max specifies a maximum value for this field 330 | func (f Field) Max(max float64) Field { 331 | f.max = &max 332 | if f.min != nil && *f.min > max { 333 | panic("min cannot be larger than max") 334 | } 335 | return f 336 | } 337 | 338 | // Pattern specifies a regex pattern value for this field 339 | func (f Field) Pattern(pattern string) Field { 340 | f.pattern = regexp.MustCompile(pattern) 341 | return f 342 | } 343 | 344 | // Required specifies the field must be provided. Can't be used with Default. 345 | func (f Field) Required() Field { 346 | if f._default != nil { 347 | panic("required and default cannot be used together") 348 | } 349 | 350 | required := true 351 | f.required = &required 352 | return f 353 | } 354 | 355 | // Example specifies an example value for the swagger to display 356 | func (f Field) Example(ex interface{}) Field { 357 | f.example = ex 358 | return f 359 | } 360 | 361 | // Description specifies a human-readable explanation of the field 362 | func (f Field) Description(description string) Field { 363 | f.description = description 364 | return f 365 | } 366 | 367 | // Default specifies a default value to use if the field is nil. Can't be used with Required. 368 | func (f Field) Default(value interface{}) Field { 369 | if f.required != nil && *f.required { 370 | panic("default and required cannot be used together") 371 | } 372 | 373 | switch value.(type) { 374 | case int: 375 | if f.kind != KindInteger { 376 | panic("wrong type passed default") 377 | } 378 | case float64: 379 | if f.kind != KindNumber { 380 | panic("wrong type passed default") 381 | } 382 | case string: 383 | if f.kind != KindString { 384 | panic("wrong type passed default") 385 | } 386 | case bool: 387 | if f.kind != KindBoolean { 388 | panic("wrong type passed default") 389 | } 390 | default: 391 | panic("default must be an int, float64, bool or string") 392 | } 393 | f._default = value 394 | return f 395 | } 396 | 397 | // Enum restricts the field's values to the set of values specified 398 | func (f Field) Enum(values ...interface{}) Field { 399 | if f.kind == KindArray || f.kind == KindObject || f.kind == KindFile { 400 | panic("Enum cannot be used on arrays, objects, files") 401 | } 402 | f.enum = values 403 | return f 404 | } 405 | 406 | // Items specifies the type of elements in an array 407 | func (f Field) Items(item Field) Field { 408 | if f.kind != KindArray { 409 | panic("Items can only be used with array types") 410 | } 411 | f.arr = &item 412 | return f 413 | } 414 | 415 | const ( 416 | FormatDate = "date" 417 | FormatDateTime = "dateTime" 418 | ) 419 | 420 | // Format is used to set custom format types. Note that formats with special 421 | // validation in this library also have their own constructor. See DateTime for example. 422 | func (f Field) Format(format string) Field { 423 | f.format = format 424 | return f 425 | } 426 | 427 | // Allow lets you break rules 428 | // For example, String().Required() excludes "", unless you Allow("") 429 | func (f Field) Allow(values ...interface{}) Field { 430 | f.allow = append(f.allow, values...) 431 | return f 432 | } 433 | 434 | // Strip overrides the global "strip unknown" setting just for this field, and all children of this field 435 | func (f Field) Strip(strip bool) Field { 436 | f.strip = &strip 437 | return f 438 | } 439 | 440 | // Unknown overrides the global "allow unknown" setting just for this field, and all children of this field 441 | func (f Field) Unknown(allow bool) Field { 442 | f.unknown = &allow 443 | return f 444 | } 445 | 446 | // ToSwaggerParameters transforms a field into a slice of Parameter. 447 | func (f *Field) ToSwaggerParameters(in string) (parameters []Parameter) { 448 | switch f.kind { 449 | case KindArray: 450 | p := Parameter{ 451 | In: in, 452 | Type: f.kind, 453 | CollectionFormat: "multi", 454 | Required: f.required, 455 | Description: f.description, 456 | Default: f._default, 457 | } 458 | if f.arr != nil { 459 | items := f.arr.ToJsonSchema() 460 | p.Items = &items 461 | } 462 | parameters = append(parameters, p) 463 | case KindObject: 464 | for name, field := range f.obj { 465 | param := Parameter{ 466 | In: in, 467 | Name: name, 468 | Type: field.kind, 469 | Required: field.required, 470 | Description: field.description, 471 | Default: field._default, 472 | Enum: field.enum, 473 | Minimum: field.min, 474 | Maximum: field.max, 475 | } 476 | if field.pattern != nil { 477 | param.Pattern = field.pattern.String() 478 | } 479 | if field.kind == KindArray { 480 | if field.arr != nil { 481 | temp := field.arr.ToJsonSchema() 482 | param.Items = &temp 483 | } 484 | param.CollectionFormat = "multi" 485 | } 486 | parameters = append(parameters, param) 487 | } 488 | slices.SortFunc(parameters, func(a, b Parameter) int { 489 | if a.Name < b.Name { 490 | return -1 491 | } 492 | if a.Name > b.Name { 493 | return 1 494 | } 495 | return 0 496 | }) 497 | } 498 | return 499 | } 500 | 501 | // ToJsonSchema transforms a field into a Swagger Schema. 502 | // TODO this is an extension of JsonSchema, rename in v2 ToSchema() Schema 503 | func (f *Field) ToJsonSchema() JsonSchema { 504 | schema := JsonSchema{ 505 | Type: f.kind, 506 | } 507 | 508 | switch f.kind { 509 | case KindArray: 510 | if f.arr != nil { 511 | items := f.arr.ToJsonSchema() 512 | schema.Items = &items 513 | } 514 | case KindObject: 515 | populateProperties(f.obj, &schema) 516 | } 517 | return schema 518 | } 519 | 520 | // recursively fill in the schema 521 | func populateProperties(obj map[string]Field, schema *JsonSchema) { 522 | schema.Properties = map[string]JsonSchema{} 523 | for name, field := range obj { 524 | prop := JsonSchema{ 525 | Type: field.kind, 526 | Format: field.format, 527 | Example: field.example, 528 | Description: field.description, 529 | Default: field._default, 530 | } 531 | if field.example == nil { 532 | if field.kind == KindString { 533 | switch field.format { 534 | case FormatDateTime: 535 | prop.Example = time.Date(1970, 1, 1, 0, 0, 0, 0, time.Local).Format(time.RFC3339) 536 | case FormatDate: 537 | prop.Example = time.Date(1970, 1, 1, 0, 0, 0, 0, time.Local).Format(fullDate) 538 | } 539 | } 540 | } 541 | if field.required != nil && *field.required { 542 | schema.Required = append(schema.Required, name) 543 | } 544 | if field.min != nil { 545 | prop.Minimum = *field.min 546 | } 547 | if field.max != nil { 548 | prop.Maximum = *field.max 549 | } 550 | if field.pattern != nil { 551 | prop.Pattern = field.pattern.String() 552 | } 553 | if prop.Type == KindArray { 554 | if field.arr != nil { 555 | items := field.arr.ToJsonSchema() 556 | prop.Items = &items 557 | } 558 | } else if prop.Type == KindObject { 559 | populateProperties(field.obj, &prop) 560 | } 561 | schema.Properties[name] = prop 562 | } 563 | } 564 | 565 | func (f Field) isAllowUnknown() bool { 566 | if f.unknown == nil { 567 | return true // by default allow unknown 568 | } 569 | return *f.unknown 570 | } 571 | 572 | func (f Field) isStripUnknown() bool { 573 | if f.strip == nil { 574 | return true // by default strip 575 | } 576 | return *f.strip 577 | } 578 | -------------------------------------------------------------------------------- /prehandler_test.go: -------------------------------------------------------------------------------- 1 | package crud 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "github.com/jakecoffman/crud/option" 7 | "net/url" 8 | "testing" 9 | ) 10 | 11 | type TestAdapter struct{} 12 | 13 | func (t *TestAdapter) Install(router *Router, spec *Spec) error { 14 | return nil 15 | } 16 | 17 | func (t *TestAdapter) Serve(swagger *Swagger, addr string) error { 18 | return nil 19 | } 20 | 21 | func TestQueryValidation(t *testing.T) { 22 | r := NewRouter("", "", &TestAdapter{}) 23 | 24 | tests := []struct { 25 | Schema map[string]Field 26 | Input string 27 | Expected error 28 | }{ 29 | { 30 | Schema: map[string]Field{ 31 | "testquery": String(), 32 | }, 33 | Input: "", 34 | Expected: nil, 35 | }, { 36 | Schema: map[string]Field{ 37 | "testquery": String().Required(), 38 | }, 39 | Input: "", 40 | Expected: errRequired, 41 | }, { 42 | Schema: map[string]Field{ 43 | "testquery": String().Required(), 44 | }, 45 | Input: "testquery=", 46 | Expected: errRequired, 47 | }, { 48 | Schema: map[string]Field{ 49 | "testquery": String().Required(), 50 | }, 51 | Input: "testquery=ok", 52 | Expected: nil, 53 | }, { 54 | Schema: map[string]Field{ 55 | "testquery": Number(), 56 | }, 57 | Input: "", 58 | Expected: nil, 59 | }, { 60 | Schema: map[string]Field{ 61 | "testquery": Number().Required(), 62 | }, 63 | Input: "", 64 | Expected: errRequired, 65 | }, 66 | { 67 | Schema: map[string]Field{ 68 | "testquery": Number().Required(), 69 | }, 70 | Input: "testquery=1", 71 | Expected: nil, 72 | }, 73 | { 74 | Schema: map[string]Field{ 75 | "testquery": Number().Required(), 76 | }, 77 | Input: "testquery=1.1", 78 | Expected: nil, 79 | }, 80 | { 81 | Schema: map[string]Field{ 82 | "testquery": Number(), 83 | }, 84 | Input: "testquery=a", 85 | Expected: errWrongType, 86 | }, 87 | { 88 | Schema: map[string]Field{ 89 | "testquery": Boolean(), 90 | }, 91 | Input: "testquery=true", 92 | Expected: nil, 93 | }, 94 | { 95 | Schema: map[string]Field{ 96 | "testquery": Boolean(), 97 | }, 98 | Input: "testquery=false", 99 | Expected: nil, 100 | }, 101 | { 102 | Schema: map[string]Field{ 103 | "testquery": Boolean(), 104 | }, 105 | Input: "testquery=1", 106 | Expected: errWrongType, 107 | }, 108 | { 109 | Schema: map[string]Field{ 110 | "testquery": Integer(), 111 | }, 112 | Input: "testquery=1", 113 | Expected: nil, 114 | }, 115 | { 116 | Schema: map[string]Field{ 117 | "testquery": Integer().Max(1), 118 | }, 119 | Input: "testquery=2", 120 | Expected: errMaximum, 121 | }, 122 | { 123 | Schema: map[string]Field{ 124 | "testquery": Integer().Min(5), 125 | }, 126 | Input: "testquery=4", 127 | Expected: errMinimum, 128 | }, 129 | { 130 | Schema: map[string]Field{ 131 | "testquery": Integer(), 132 | }, 133 | Input: "testquery=1.1", 134 | Expected: errWrongType, 135 | }, 136 | { 137 | Schema: map[string]Field{ 138 | "testquery": Integer(), 139 | }, 140 | Input: "testquery=a", 141 | Expected: errWrongType, 142 | }, 143 | { 144 | Schema: map[string]Field{ 145 | "testquery": Integer().Enum(1, 2), 146 | }, 147 | Input: "testquery=2", 148 | Expected: nil, 149 | }, 150 | { 151 | Schema: map[string]Field{ 152 | "testquery": Integer().Enum(1, 2), 153 | }, 154 | Input: "testquery=3", 155 | Expected: errEnumNotFound, 156 | }, 157 | { 158 | Schema: map[string]Field{ 159 | "testquery": String().Enum("a"), 160 | }, 161 | Input: "testquery=a", 162 | Expected: nil, 163 | }, 164 | { 165 | Schema: map[string]Field{ 166 | "testquery": String().Enum("a"), 167 | }, 168 | Input: "testquery=b", 169 | Expected: errEnumNotFound, 170 | }, 171 | { 172 | Schema: map[string]Field{ 173 | "testarray": Array(), 174 | }, 175 | Input: "testarray=1&testarray=a", 176 | Expected: nil, 177 | }, 178 | { 179 | Schema: map[string]Field{ 180 | "testquery": Array().Items(Number()), 181 | }, 182 | Input: "testquery=1&testquery=2", 183 | Expected: nil, 184 | }, 185 | { 186 | Schema: map[string]Field{ 187 | "testquery": Array().Items(Number()), 188 | }, 189 | Input: "testquery=1&testquery=a", 190 | Expected: errWrongType, 191 | }, 192 | { 193 | Schema: map[string]Field{ 194 | "testquery": Array().Min(2), 195 | }, 196 | Input: "testquery=z", 197 | Expected: errMinimum, 198 | }, 199 | { 200 | Schema: map[string]Field{ 201 | "testquery": Array().Min(2), 202 | }, 203 | Input: "testquery=z&testquery=x", 204 | Expected: nil, 205 | }, 206 | { 207 | Schema: map[string]Field{ 208 | "testquery": Array(), 209 | }, 210 | Input: "testquery=d", 211 | Expected: nil, 212 | }, 213 | } 214 | 215 | for i, test := range tests { 216 | query, err := url.ParseQuery(test.Input) 217 | if err != nil { 218 | t.Fatal(err) 219 | } 220 | 221 | err = r.Validate(Validate{Query: Object(test.Schema)}, query, nil, nil) 222 | 223 | if !errors.Is(err, test.Expected) { 224 | t.Errorf("%v: expected '%v' got '%v'. input: '%v'. schema: '%v'", i, test.Expected, err, test.Input, test.Schema) 225 | } 226 | } 227 | } 228 | 229 | func TestQueryDefaults(t *testing.T) { 230 | r := NewRouter("", "", &TestAdapter{}) 231 | 232 | tests := []struct { 233 | Schema map[string]Field 234 | Input string 235 | Expected string 236 | }{ 237 | { 238 | Schema: map[string]Field{ 239 | "q": String().Default("hey"), 240 | }, 241 | Input: "", 242 | Expected: "q=hey", 243 | }, 244 | { 245 | Schema: map[string]Field{ 246 | "q1": String().Default("1"), 247 | "q2": String().Default("2"), 248 | }, 249 | Input: "", 250 | Expected: "q1=1&q2=2", 251 | }, 252 | } 253 | 254 | for i, test := range tests { 255 | query, err := url.ParseQuery(test.Input) 256 | if err != nil { 257 | t.Fatal(err) 258 | } 259 | 260 | err = r.Validate(Validate{Query: Object(test.Schema)}, query, nil, nil) 261 | 262 | if query.Encode() != test.Expected { 263 | t.Errorf("%v: expected '%v' got '%v'. input: '%v'. schema: '%v'", i, test.Expected, query.Encode(), test.Input, test.Schema) 264 | } 265 | if err != nil { 266 | t.Error(err) 267 | } 268 | } 269 | } 270 | 271 | func TestSimpleBodyValidation(t *testing.T) { 272 | r := NewRouter("", "", &TestAdapter{}) 273 | 274 | tests := []struct { 275 | Schema Field 276 | Input interface{} 277 | Expected error 278 | }{ 279 | { 280 | Schema: Number(), 281 | Input: float64(1), 282 | Expected: nil, 283 | }, 284 | { 285 | Schema: Number(), 286 | Input: 1, 287 | Expected: errWrongType, 288 | }, 289 | { 290 | Schema: Number(), 291 | Input: "a", 292 | Expected: errWrongType, 293 | }, 294 | { 295 | Schema: String(), 296 | Input: "2", 297 | Expected: nil, 298 | }, 299 | { 300 | Schema: Boolean(), 301 | Input: true, 302 | Expected: nil, 303 | }, 304 | { 305 | Schema: Boolean(), 306 | Input: false, 307 | Expected: nil, 308 | }, 309 | { 310 | Schema: Boolean(), 311 | Input: `1`, 312 | Expected: errWrongType, 313 | }, 314 | } 315 | 316 | for _, test := range tests { 317 | err := r.Validate(Validate{Body: test.Schema}, nil, test.Input, nil) 318 | 319 | if !errors.Is(err, test.Expected) { 320 | t.Errorf("expected '%v' got '%v'. input: '%v'. schema: '%v'", test.Expected, err, test.Input, test.Schema) 321 | } 322 | } 323 | } 324 | 325 | func TestBodyValidation(t *testing.T) { 326 | r := NewRouter("", "", &TestAdapter{}) 327 | 328 | tests := []struct { 329 | Schema map[string]Field 330 | Input string 331 | Expected error 332 | }{ 333 | { 334 | Schema: map[string]Field{ 335 | "int": Integer(), 336 | }, 337 | Input: `{}`, 338 | Expected: nil, 339 | }, 340 | { 341 | Schema: map[string]Field{ 342 | "int": Integer().Required(), 343 | }, 344 | Input: `{}`, 345 | Expected: errRequired, 346 | }, 347 | { 348 | Schema: map[string]Field{ 349 | "int": Integer().Required(), 350 | }, 351 | Input: `{"int":1}`, 352 | Expected: nil, 353 | }, 354 | { 355 | Schema: map[string]Field{ 356 | "int": Integer().Required(), 357 | }, 358 | Input: `{"int":1.9}`, 359 | Expected: errWrongType, 360 | }, 361 | { 362 | Schema: map[string]Field{ 363 | "float": Number().Required(), 364 | }, 365 | Input: `{"float":-1}`, 366 | Expected: nil, 367 | }, 368 | { 369 | Schema: map[string]Field{ 370 | "float": Number().Required(), 371 | }, 372 | Input: `{"float":1.1}`, 373 | Expected: nil, 374 | }, { 375 | Schema: map[string]Field{ 376 | "obj1": Object(map[string]Field{ 377 | "inner": Number().Required(), 378 | }), 379 | }, 380 | Input: `{"obj1":{"inner":1}}`, 381 | Expected: nil, 382 | }, { 383 | Schema: map[string]Field{ 384 | "obj2": Object(map[string]Field{ 385 | "inner": Number().Required(), 386 | }), 387 | }, 388 | Input: `{"obj2":{"inner":"not a number"}}`, 389 | Expected: errWrongType, 390 | }, { 391 | Schema: map[string]Field{ 392 | "arr1": Array(), 393 | }, 394 | Input: `{"arr1":[1,"a"]}`, 395 | Expected: nil, 396 | }, { 397 | Schema: map[string]Field{ 398 | "arr1": Array().Items(Number()), 399 | }, 400 | Input: `{"arr1":[1]}`, 401 | Expected: nil, 402 | }, { 403 | Schema: map[string]Field{ 404 | "arr2": Array().Items(Number()), 405 | }, 406 | Input: `{"arr2":["a"]}`, 407 | Expected: errWrongType, 408 | }, { 409 | Schema: map[string]Field{ 410 | "arr3": Array().Min(2), 411 | }, 412 | Input: `{"arr3":["a"]}`, 413 | Expected: errMinimum, 414 | }, { 415 | Schema: map[string]Field{ 416 | "complex1": Object(map[string]Field{ 417 | "array": Array().Required().Items(Object(map[string]Field{ 418 | "id": Number().Required(), 419 | })), 420 | }).Required(), 421 | }, 422 | Input: `{"complex1":{"array":[{"id":1}]}}`, 423 | Expected: nil, 424 | }, { 425 | Schema: map[string]Field{ 426 | "complex2": Object(map[string]Field{ 427 | "array": Array().Required().Items(Object(map[string]Field{ 428 | "id": Number().Required(), 429 | })), 430 | }).Required(), 431 | }, 432 | Input: `{"complex2":{"array":[{"id":"a"}]}}`, 433 | Expected: errWrongType, 434 | }, 435 | } 436 | 437 | for _, test := range tests { 438 | var input interface{} 439 | if err := json.Unmarshal([]byte(test.Input), &input); err != nil { 440 | t.Fatal(err) 441 | } 442 | 443 | err := r.Validate(Validate{Body: Object(test.Schema)}, nil, input, nil) 444 | 445 | if !errors.Is(err, test.Expected) { 446 | t.Errorf("expected '%v' got '%v'. input: '%v'. schema: '%v'", test.Expected, err, test.Input, test.Schema) 447 | } 448 | } 449 | } 450 | 451 | func TestBodyStripUnknown(t *testing.T) { 452 | r := NewRouter("", "", &TestAdapter{}, option.StripUnknown(true)) 453 | 454 | tests := []struct { 455 | Schema map[string]Field 456 | Input string 457 | Expected string 458 | }{ 459 | { 460 | Schema: map[string]Field{ 461 | "str": String(), 462 | }, 463 | Input: `{"str":"ok","unknown1":1}`, 464 | Expected: `{"str":"ok"}`, 465 | }, 466 | { 467 | Schema: map[string]Field{ 468 | "str2": String().Default("Hello"), 469 | }, 470 | Input: `{}`, 471 | Expected: `{"str2":"Hello"}`, 472 | }, 473 | { 474 | Schema: map[string]Field{ 475 | "int1": Integer().Default(1), 476 | }, 477 | Input: `{}`, 478 | Expected: `{"int1":1}`, 479 | }, 480 | } 481 | 482 | for _, test := range tests { 483 | var input interface{} 484 | if err := json.Unmarshal([]byte(test.Input), &input); err != nil { 485 | t.Fatal(err) 486 | } 487 | 488 | err := r.Validate(Validate{Body: Object(test.Schema)}, nil, input, nil) 489 | 490 | if err != nil { 491 | t.Error(err) 492 | continue 493 | } 494 | 495 | data, err := json.Marshal(input) 496 | if err != nil { 497 | t.Fatal(err) 498 | } 499 | 500 | if string(data) != test.Expected { 501 | t.Errorf("expected '%v' got '%v'. input: '%v'. schema: '%v'", test.Expected, string(data), test.Input, test.Schema) 502 | } 503 | } 504 | } 505 | 506 | func TestBodyErrorUnknown(t *testing.T) { 507 | r := NewRouter("", "", &TestAdapter{}, option.AllowUnknown(false)) 508 | 509 | tests := []struct { 510 | Schema map[string]Field 511 | Input string 512 | Expected string 513 | }{ 514 | { 515 | Schema: map[string]Field{ 516 | "str": String(), 517 | }, 518 | Input: `{"str":"ok","unknown1":1}`, 519 | Expected: `{"str":"ok"}`, 520 | }, 521 | } 522 | 523 | for i, test := range tests { 524 | var input interface{} 525 | if err := json.Unmarshal([]byte(test.Input), &input); err != nil { 526 | t.Fatal(err) 527 | } 528 | 529 | err := r.Validate(Validate{Body: Object(test.Schema)}, nil, input, nil) 530 | 531 | if !errors.Is(err, errUnknown) { 532 | t.Errorf("%v: expected '%v' got '%v'", i, errUnknown, err) 533 | continue 534 | } 535 | } 536 | } 537 | 538 | func TestPathValidation(t *testing.T) { 539 | r := NewRouter("", "", &TestAdapter{}) 540 | 541 | tests := []struct { 542 | Schema map[string]Field 543 | Input string 544 | Expected error 545 | }{ 546 | { 547 | Schema: map[string]Field{ 548 | "id": Integer(), 549 | }, 550 | Input: ``, 551 | Expected: nil, 552 | }, 553 | { 554 | Schema: map[string]Field{ 555 | "int": Integer().Required(), 556 | }, 557 | Input: ``, 558 | Expected: errRequired, 559 | }, 560 | { 561 | Schema: map[string]Field{ 562 | "id": Integer().Required(), 563 | }, 564 | Input: `a`, 565 | Expected: errWrongType, 566 | }, 567 | { 568 | Schema: map[string]Field{ 569 | "id": Integer().Required(), 570 | }, 571 | Input: `1`, 572 | Expected: nil, 573 | }, 574 | } 575 | 576 | for _, test := range tests { 577 | input := map[string]string{ 578 | "id": test.Input, 579 | } 580 | 581 | err := r.Validate(Validate{Path: Object(test.Schema)}, nil, nil, input) 582 | 583 | if errors.Unwrap(err) != test.Expected { 584 | t.Errorf("expected '%v' got '%v'. input: '%v'. schema: '%v'", test.Expected, err, test.Input, test.Schema) 585 | } 586 | } 587 | } 588 | 589 | func TestStrip_Body(t *testing.T) { 590 | r := NewRouter("", "", &TestAdapter{}, option.StripUnknown(true)) 591 | var input interface{} 592 | input = map[string]interface{}{ 593 | "id": "blah", 594 | } 595 | 596 | err := r.Validate(Validate{Body: Object(map[string]Field{})}, nil, input, nil) 597 | 598 | result := input.(map[string]interface{}) 599 | if err != nil { 600 | t.Errorf("Unexpected error %v", err) 601 | } 602 | if _, ok := result["id"]; ok { 603 | t.Errorf("expected the value to be stripped %v", result["id"]) 604 | } 605 | 606 | input = map[string]interface{}{ 607 | "id": "blah", 608 | } 609 | 610 | err = r.Validate(Validate{Body: Object(map[string]Field{}).Strip(false)}, nil, input, nil) 611 | result = input.(map[string]interface{}) 612 | if err != nil { 613 | t.Errorf("Unexpected error %v", err) 614 | } 615 | if _, ok := result["id"]; !ok { 616 | t.Errorf("expected the value not to be stripped") 617 | } 618 | } 619 | 620 | func TestUnknown_Body(t *testing.T) { 621 | r := NewRouter("", "", &TestAdapter{}, option.AllowUnknown(true)) 622 | var input interface{} 623 | input = map[string]interface{}{ 624 | "id": "blah", 625 | } 626 | 627 | err := r.Validate(Validate{Body: Object(map[string]Field{})}, nil, input, nil) 628 | 629 | if err != nil { 630 | t.Errorf("Unexpected error %v", err) 631 | } 632 | 633 | input = map[string]interface{}{ 634 | "id": "blah", 635 | } 636 | 637 | err = r.Validate(Validate{Body: Object(map[string]Field{}).Unknown(false)}, nil, input, nil) 638 | if err == nil { 639 | t.Errorf("Expected error") 640 | } 641 | } 642 | 643 | func TestUnknown_Query(t *testing.T) { 644 | r := NewRouter("", "", &TestAdapter{}, option.AllowUnknown(false)) 645 | query := url.Values{"unknown": []string{"value"}} 646 | spec := Object(map[string]Field{}) 647 | 648 | err := r.Validate(Validate{Query: spec}, query, nil, nil) 649 | if !errors.Is(err, errUnknown) { 650 | t.Errorf("Expected '%s' but got '%s'", errUnknown, err) 651 | } 652 | 653 | spec = spec.Unknown(true) 654 | err = r.Validate(Validate{Query: spec}, query, nil, nil) 655 | if err != nil { 656 | t.Error("Unexpected error", err) 657 | } 658 | } 659 | 660 | func TestStrip_Query(t *testing.T) { 661 | r := NewRouter("", "", &TestAdapter{}) 662 | query := url.Values{"unknown": []string{"value"}} 663 | spec := Object(map[string]Field{}).Strip(false) 664 | 665 | err := r.Validate(Validate{Query: spec}, query, nil, nil) 666 | if err != nil { 667 | t.Error("Unexpected error", err) 668 | } 669 | 670 | if _, ok := query["unknown"]; !ok { 671 | t.Error("Expected the value to not have been stripped") 672 | } 673 | 674 | spec = spec.Strip(true) 675 | err = r.Validate(Validate{Query: spec}, query, nil, nil) 676 | if err != nil { 677 | t.Error("Unexpected error", err) 678 | } 679 | 680 | if _, ok := query["unknown"]; ok { 681 | t.Error("Expected the value to have been stripped") 682 | } 683 | } 684 | 685 | func Test_BodyValidateRequiredAutomatically(t *testing.T) { 686 | r := NewRouter("", "", &TestAdapter{}, option.AllowUnknown(false)) 687 | 688 | err := r.Validate(Validate{Body: Object(map[string]Field{})}, nil, nil, nil) 689 | 690 | if !errors.Is(err, errRequired) { 691 | t.Error("Expected errRequired got", err) 692 | } 693 | } 694 | --------------------------------------------------------------------------------