├── 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 | [](https://godoc.org/github.com/jakecoffman/crud)
4 | [](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 | 
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 |
--------------------------------------------------------------------------------