├── .github └── workflows │ └── jekyll-gh-pages.yml ├── .gitignore ├── LICENSE ├── README.md ├── api.go ├── api_test.go ├── binary.go ├── cookie.go ├── cookie_test.go ├── docs ├── Gemfile ├── Gemfile.lock ├── LICENSE ├── README.md ├── _config.yml ├── index.md ├── json.md ├── middleware.md ├── openapi.md ├── params.md ├── requests.md ├── responses.md └── routing.md ├── form.go ├── form_test.go ├── go.mod ├── go.sum ├── header.go ├── header_test.go ├── json.go ├── json_test.go ├── middleware.go ├── middleware_test.go ├── oneof.go ├── oneof_test.go ├── openapi.go ├── params.go ├── path.go ├── path_test.go ├── query.go ├── query_test.go ├── requests.go ├── requests_test.go ├── responses.go ├── responses_test.go ├── route.go ├── text.go └── xml.go /.github/workflows/jekyll-gh-pages.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | 6 | # Sample workflow for building and deploying a Jekyll site to GitHub Pages 7 | name: Deploy Jekyll site to Pages 8 | 9 | on: 10 | push: 11 | branches: ["main"] 12 | paths: ["docs/**"] 13 | 14 | # Allows you to run this workflow manually from the Actions tab 15 | workflow_dispatch: 16 | 17 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 18 | permissions: 19 | contents: read 20 | pages: write 21 | id-token: write 22 | 23 | # Allow one concurrent deployment 24 | concurrency: 25 | group: "pages" 26 | cancel-in-progress: true 27 | 28 | jobs: 29 | # Build job 30 | build: 31 | runs-on: ubuntu-latest 32 | defaults: 33 | run: 34 | working-directory: docs 35 | steps: 36 | - name: Checkout 37 | uses: actions/checkout@v3 38 | - name: Setup Ruby 39 | uses: ruby/setup-ruby@v1 40 | with: 41 | ruby-version: '3.1' # Not needed with a .ruby-version file 42 | bundler-cache: true # runs 'bundle install' and caches installed gems automatically 43 | cache-version: 0 # Increment this number if you need to re-download cached gems 44 | working-directory: '${{ github.workspace }}/docs' 45 | - name: Setup Pages 46 | id: pages 47 | uses: actions/configure-pages@v3 48 | - name: Build with Jekyll 49 | # Outputs to the './_site' directory by default 50 | run: bundle exec jekyll build --baseurl "${{ steps.pages.outputs.base_path }}" 51 | env: 52 | JEKYLL_ENV: production 53 | - name: Upload artifact 54 | # Automatically uploads an artifact from the './_site' directory by default 55 | uses: actions/upload-pages-artifact@v1 56 | with: 57 | path: "docs/_site/" 58 | # Deployment job 59 | deploy: 60 | environment: 61 | name: github-pages 62 | url: ${{ steps.deployment.outputs.page_url }} 63 | runs-on: ubuntu-latest 64 | needs: build 65 | steps: 66 | - name: Deploy to GitHub Pages 67 | id: deployment 68 | uses: actions/deploy-pages@v2 69 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # If you prefer the allow list template instead of the deny list, see community template: 2 | # https://github.com/github/gitignore/blob/chimera/community/Golang/Go.AllowList.gitignore 3 | # 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | 11 | # Test binary, built with `go test -c` 12 | *.test 13 | 14 | # Output of the go coverage tool, specifically when used with LiteIDE 15 | *.out 16 | 17 | # Dependency directories (remove the comment below to include it) 18 | vendor/ 19 | 20 | # Go workspace file 21 | go.work 22 | 23 | # Docs 24 | docs/_site 25 | docs/.jekyll-cache -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 matt1484 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # chimera 2 | Chi-based Module for Easy REST APIs 3 | 4 | ## Overview 5 | `chimera` is designed for fast/easy API development based on OpenAPI with the following core features: 6 | - Automatic OpenAPI (3.1) docs from structs (no comments or files needed) 7 | - Automatic parsing of JSON/text/binary/form requests 8 | - Automatic serialization of JSON/text/binary responses 9 | - Automatic handling of request/response parameters (cookies/query/headers/path) 10 | - Middleware (with easy error handling) 11 | - Route groups (with isolated middleware) 12 | - Error handling as responses 13 | - Static file serving from directories 14 | 15 | An example of how some of this might look in practice is: 16 | 17 | ```go 18 | package main 19 | 20 | import "github.com/matt1484/chimera" 21 | 22 | type TestBody struct { 23 | Property string `json:"prop"` 24 | } 25 | 26 | type TestParams struct { 27 | Path string `param:"path,in=path"` 28 | Header string `param:"header,in=header"` 29 | } 30 | 31 | func main() { 32 | api := chimera.NewAPI() 33 | api.Use(func(req *http.Request, ctx chimera.RouteContext, next chimera.NextFunc) (chimera.ResponseWriter, error) { 34 | resp, err := next(req) 35 | return resp, err 36 | }) 37 | chimera.Get(api, "/test/{path}", func(req *chimera.JSON[TestBody, TestParams]) (*chimera.JSON[TestBody, chimera.Nil], error) { 38 | return &chimera.JSON[TestBody, TestParams]{ 39 | Body: req.Body, 40 | }, nil 41 | }) 42 | api.Start(":8000") 43 | } 44 | 45 | ``` 46 | By using struct tags, generics, reflection, and carefully designed interfaces `chimera` can infer and automate a lot of the typical tasks that go developers often manually do. 47 | 48 | This library relies heavily on `chi` to support the routing/grouping/middleware internally. While `chi` is all about being inline with the standard library, this library is more opinionated and instead goes for a more standard layout of development interfaces as seen in other languages/frameworks to support quicker development. A lof of its design was actually heavily inspired by `fastapi` and you can even see the parallels in this example here: 49 | 50 | ```python 51 | from typing import Annotated 52 | from fastapi import FastAPI, Header 53 | from pydantic import BaseModel 54 | 55 | class TestBody(BaseModel): 56 | prop: str 57 | 58 | api = fastapi.FastAPI() 59 | 60 | @api.middleware("http") 61 | def add_process_time_header(request: Request, next): 62 | response = next(request) 63 | return response 64 | 65 | @api.get("/test/{path}") 66 | def test(path: str, header: Annotated[str, Header()] = None, body: TestBody) -> TestBody 67 | return body 68 | ``` 69 | 70 | ## Docs 71 | There are docs on github pages hosted [here](https://matt1484.github.io/chimera/) 72 | 73 | ## TODO 74 | - Proper XML support 75 | - Multipart form support -------------------------------------------------------------------------------- /api.go: -------------------------------------------------------------------------------- 1 | package chimera 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "reflect" 8 | 9 | "github.com/go-chi/chi/v5" 10 | "github.com/invopop/jsonschema" 11 | "github.com/swaggest/swgui/v5emb" 12 | ) 13 | 14 | var ( 15 | default500Error = []byte("Unknown error occurred") 16 | ) 17 | 18 | // APIError is an error that can be converted to a response 19 | type APIError struct { 20 | StatusCode int 21 | Body []byte 22 | Header http.Header 23 | } 24 | 25 | // Error returns the string representation of the error 26 | func (a APIError) Error() string { 27 | if a.StatusCode < 1 { 28 | a.StatusCode = 500 29 | } 30 | return fmt.Sprintf("%v error: %s", a.StatusCode, a.Body) 31 | } 32 | 33 | // Nil is an empty struct that is designed to represent "nil" 34 | // and is typically used to denote that a request/response 35 | // has no body or parameters depending on context 36 | type Nil struct{} 37 | 38 | // API is a collection of routes and middleware with an associated OpenAPI spec 39 | type API struct { 40 | openAPISpec OpenAPI 41 | router *chi.Mux 42 | routes []*route 43 | middleware []MiddlewareFunc 44 | subAPIs []*API 45 | basePath string 46 | parent *API 47 | staticPaths map[string]string 48 | } 49 | 50 | // OpenAPISpec returns the underlying OpenAPI structure for this API 51 | func (a *API) OpenAPISpec() *OpenAPI { 52 | return &a.openAPISpec 53 | } 54 | 55 | // ServeHTTP serves to implement support for the standard library 56 | func (a *API) ServeHTTP(w http.ResponseWriter, req *http.Request) { 57 | customWriter := httpResponseWriter{ 58 | writer: w, 59 | } 60 | a.router.ServeHTTP(&customWriter, req) 61 | write(&customWriter, w, req) 62 | } 63 | 64 | func writeError(e error, w http.ResponseWriter) { 65 | if err, ok := e.(APIError); ok { 66 | for k, vals := range err.Header { 67 | for _, v := range vals { 68 | w.Header().Set(k, v) 69 | } 70 | } 71 | if err.StatusCode != 0 { 72 | w.WriteHeader(err.StatusCode) 73 | } else { 74 | w.WriteHeader(500) 75 | } 76 | w.Write(err.Body) 77 | } else { 78 | w.WriteHeader(500) 79 | w.Write(default500Error) 80 | } 81 | } 82 | 83 | func write(customWriter *httpResponseWriter, w http.ResponseWriter, req *http.Request) { 84 | if customWriter.respError != nil { 85 | writeError(customWriter.respError, customWriter.writer) 86 | } else { 87 | // TODO: maybe allow global default response codes for methods? 88 | if customWriter.response != nil && !reflect.ValueOf(customWriter.response).IsNil() { 89 | head := ResponseHead{ 90 | StatusCode: customWriter.route.context.responseCode, 91 | Headers: customWriter.Header(), 92 | } 93 | err := customWriter.response.WriteHead(&head) 94 | if err != nil { 95 | customWriter.writer.WriteHeader(500) 96 | customWriter.Write(default500Error) 97 | } else { 98 | customWriter.WriteHeader(head.StatusCode) 99 | customWriter.response.WriteBody(customWriter.Write) 100 | } 101 | } else { 102 | if customWriter.route != nil && customWriter.route.context.responseCode != 0 { 103 | w.WriteHeader(customWriter.route.context.responseCode) 104 | } else { 105 | switch req.Method { 106 | case http.MethodGet: 107 | case http.MethodPut: 108 | case http.MethodPatch: 109 | w.WriteHeader(200) 110 | case http.MethodPost: 111 | w.WriteHeader(201) 112 | case http.MethodOptions: 113 | case http.MethodDelete: 114 | w.WriteHeader(204) 115 | } 116 | } 117 | } 118 | } 119 | } 120 | 121 | // Start uses http.ListenAndServe to start serving requests from addr 122 | func (a *API) Start(addr string) error { 123 | return http.ListenAndServe(addr, a) 124 | } 125 | 126 | // NewAPI returns an initialized API object 127 | func NewAPI() *API { 128 | api := API{ 129 | router: chi.NewRouter(), 130 | openAPISpec: OpenAPI{ 131 | OpenAPI: "3.1.0", 132 | Paths: make(map[string]Path), 133 | Info: Info{ 134 | Version: "v0.0.0", 135 | Title: "API", 136 | }, 137 | Servers: make([]Server, 0), 138 | Components: &Components{ 139 | Schemas: make(map[string]jsonschema.Schema), 140 | }, 141 | }, 142 | } 143 | return &api 144 | } 145 | 146 | // addRoute creates a route based on method, path, handler, etc. 147 | func addRoute[ReqPtr RequestReaderPtr[Req], Req any, RespPtr ResponseWriterPtr[Resp], Resp any](api *API, method, path string, handler HandlerFunc[ReqPtr, Req, RespPtr, Resp]) Route { 148 | if path == "" || path[0] != '/' { 149 | path = "/" + path 150 | } 151 | 152 | reqSchema := ReqPtr(new(Req)).OpenAPIRequestSpec() 153 | operation := Operation{ 154 | RequestSpec: &reqSchema, 155 | Responses: RespPtr(new(Resp)).OpenAPIResponsesSpec(), 156 | } 157 | 158 | if reqSchema.RequestBody != nil { 159 | for k, v := range reqSchema.RequestBody.Content { 160 | if v.Schema != nil { 161 | // TODO: maybe implement a global schema resolver? 162 | // otherwise some classes with the same name may clobber 163 | // eachother in the final spec 164 | standardizedSchemas(v.Schema, api.openAPISpec.Components.Schemas) 165 | } 166 | reqSchema.RequestBody.Content[k] = v 167 | } 168 | } 169 | if operation.Responses != nil { 170 | for c, r := range operation.Responses { 171 | for k, v := range r.Content { 172 | if v.Schema != nil { 173 | standardizedSchemas(v.Schema, api.openAPISpec.Components.Schemas) 174 | } 175 | r.Content[k] = v 176 | } 177 | for k, v := range r.Headers { 178 | if v.Schema != nil { 179 | standardizedSchemas(v.Schema, api.openAPISpec.Components.Schemas) 180 | } 181 | r.Headers[k] = v 182 | } 183 | operation.Responses[c] = r 184 | } 185 | } 186 | if reqSchema.Parameters != nil { 187 | for i, p := range reqSchema.Parameters { 188 | if p.Schema != nil { 189 | standardizedSchemas(p.Schema, api.openAPISpec.Components.Schemas) 190 | } 191 | reqSchema.Parameters[i] = p 192 | } 193 | } 194 | pathSchema := Path{} 195 | if p, ok := api.openAPISpec.Paths[path]; ok { 196 | pathSchema = p 197 | } 198 | defaultCode := "" 199 | responseCode := 0 200 | switch method { 201 | case http.MethodGet: 202 | if r, ok := operation.Responses[""]; ok { 203 | operation.Responses["200"] = r 204 | defaultCode = "200" 205 | responseCode = 200 206 | delete(operation.Responses, "") 207 | } else if len(operation.Responses) == 0 { 208 | defaultCode = "200" 209 | responseCode = 200 210 | } 211 | pathSchema.Get = &operation 212 | case http.MethodPost: 213 | if r, ok := operation.Responses[""]; ok { 214 | operation.Responses["201"] = r 215 | defaultCode = "201" 216 | responseCode = 201 217 | delete(operation.Responses, "") 218 | } else if len(operation.Responses) == 0 { 219 | defaultCode = "201" 220 | responseCode = 201 221 | } 222 | pathSchema.Post = &operation 223 | case http.MethodDelete: 224 | if r, ok := operation.Responses[""]; ok { 225 | operation.Responses["204"] = r 226 | defaultCode = "204" 227 | responseCode = 204 228 | delete(operation.Responses, "") 229 | } else if len(operation.Responses) == 0 { 230 | defaultCode = "204" 231 | responseCode = 204 232 | } 233 | pathSchema.Delete = &operation 234 | case http.MethodOptions: 235 | if r, ok := operation.Responses[""]; ok { 236 | operation.Responses["204"] = r 237 | defaultCode = "204" 238 | responseCode = 204 239 | delete(operation.Responses, "") 240 | } else if len(operation.Responses) == 0 { 241 | defaultCode = "204" 242 | responseCode = 204 243 | } 244 | pathSchema.Options = &operation 245 | case http.MethodPatch: 246 | if r, ok := operation.Responses[""]; ok { 247 | operation.Responses["200"] = r 248 | defaultCode = "200" 249 | responseCode = 200 250 | delete(operation.Responses, "") 251 | } else if len(operation.Responses) == 0 { 252 | defaultCode = "200" 253 | responseCode = 200 254 | } 255 | pathSchema.Patch = &operation 256 | case http.MethodPut: 257 | if r, ok := operation.Responses[""]; ok { 258 | operation.Responses["200"] = r 259 | defaultCode = "200" 260 | responseCode = 200 261 | delete(operation.Responses, "") 262 | } else if len(operation.Responses) == 0 { 263 | defaultCode = "200" 264 | responseCode = 200 265 | } 266 | pathSchema.Put = &operation 267 | } 268 | api.openAPISpec.Paths[api.basePath+path] = pathSchema 269 | 270 | route := route{ 271 | operationSpec: &operation, 272 | defaultCode: defaultCode, 273 | context: &routeContext{ 274 | responseCode: responseCode, 275 | method: method, 276 | path: path, 277 | }, 278 | api: api, 279 | } 280 | chiHandler := (func(w http.ResponseWriter, r *http.Request) { 281 | request := ReqPtr(new(Req)) 282 | customWriter := w.(*httpResponseWriter) 283 | customWriter.route = &route 284 | customWriter.respError = request.ReadRequest(r) 285 | if customWriter.respError != nil { 286 | return 287 | } 288 | customWriter.response, customWriter.respError = handler(request) 289 | }) 290 | route.handler = chiHandler 291 | 292 | api.routes = append(api.routes, &route) 293 | rebuildAPI(api) 294 | return Route{ 295 | route: &route, 296 | } 297 | } 298 | 299 | func rebuildAPI(api *API) { 300 | a := api 301 | for ; a.parent != nil; a = api.parent { 302 | } 303 | a.rebuildRouter() 304 | } 305 | 306 | // Get adds a "GET" route to the API object which will invode the handler function on route match 307 | // it also returns the Route object to allow easy updates of the Operation spec 308 | func Get[ReqPtr RequestReaderPtr[Req], Req any, RespPtr ResponseWriterPtr[Resp], Resp any](api *API, path string, handler HandlerFunc[ReqPtr, Req, RespPtr, Resp]) Route { 309 | return addRoute(api, http.MethodGet, path, handler) 310 | } 311 | 312 | // Post adds a "POST" route to the API object which will invode the handler function on route match 313 | // it also returns the Route object to allow easy updates of the Operation spec 314 | func Post[ReqPtr RequestReaderPtr[Req], Req any, RespPtr ResponseWriterPtr[Resp], Resp any](api *API, path string, handler HandlerFunc[ReqPtr, Req, RespPtr, Resp]) Route { 315 | return addRoute(api, http.MethodPost, path, handler) 316 | } 317 | 318 | // Put adds a "PUT" route to the API object which will invode the handler function on route match 319 | // it also returns the Route object to allow easy updates of the Operation spec 320 | func Put[ReqPtr RequestReaderPtr[Req], Req any, RespPtr ResponseWriterPtr[Resp], Resp any](api *API, path string, handler HandlerFunc[ReqPtr, Req, RespPtr, Resp]) Route { 321 | return addRoute(api, http.MethodPut, path, handler) 322 | } 323 | 324 | // Patch adds a "PATCH" route to the API object which will invode the handler function on route match 325 | // it also returns the Route object to allow easy updates of the Operation spec 326 | func Patch[ReqPtr RequestReaderPtr[Req], Req any, RespPtr ResponseWriterPtr[Resp], Resp any](api *API, path string, handler HandlerFunc[ReqPtr, Req, RespPtr, Resp]) Route { 327 | return addRoute(api, http.MethodPatch, path, handler) 328 | } 329 | 330 | // Delete adds a "DELETE" route to the API object which will invode the handler function on route match 331 | // it also returns the Route object to allow easy updates of the Operation spec 332 | func Delete[ReqPtr RequestReaderPtr[Req], Req any, RespPtr ResponseWriterPtr[Resp], Resp any](api *API, path string, handler HandlerFunc[ReqPtr, Req, RespPtr, Resp]) Route { 333 | return addRoute(api, http.MethodDelete, path, handler) 334 | } 335 | 336 | // Options adds a "OPTIONS" route to the API object which will invode the handler function on route match 337 | // it also returns the Route object to allow easy updates of the Operation spec 338 | func Options[ReqPtr RequestReaderPtr[Req], Req any, RespPtr ResponseWriterPtr[Resp], Resp any](api *API, path string, handler HandlerFunc[ReqPtr, Req, RespPtr, Resp]) Route { 339 | return addRoute(api, http.MethodOptions, path, handler) 340 | } 341 | 342 | // Idk what trace even does, do people actually use this? 343 | // func Trace[ReqPtr RequestReaderPtr[Req], Req any, RespPtr ResponseWriterPtr[Resp], Resp any](api *API, path string, handler func(ReqPtr) (RespPtr, error)) Route { 344 | // return addRoute(api, http.MethodTrace, path, handler) 345 | // } 346 | 347 | // Static adds support for serving static content from a directory, this route is hidden from the OpenAPI spec 348 | func (a *API) Static(apiPath, filesPath string) { 349 | if len(apiPath) == 0 { 350 | apiPath = "/" 351 | } 352 | if apiPath[0] != '/' { 353 | apiPath = "/" + apiPath 354 | } 355 | if apiPath[len(apiPath)-1] != '/' { 356 | apiPath += "/" 357 | } 358 | a.staticPaths[apiPath+"*"] = filesPath 359 | rebuildAPI(a) 360 | } 361 | 362 | // Use adds middleware to the API 363 | func (a *API) Use(middleware ...MiddlewareFunc) { 364 | a.middleware = append(a.middleware, middleware...) 365 | rebuildAPI(a) 366 | } 367 | 368 | // Group creates a sub-API with seperate middleware and routes using a base path. 369 | // The middleware of the parent API is always evaluated first and any route collisions 370 | // are handled by chi directly 371 | func (a *API) Group(basePath string) *API { 372 | for _, sub := range a.subAPIs { 373 | if sub.basePath == a.basePath+basePath { 374 | return a 375 | } 376 | } 377 | newSub := NewAPI() 378 | a.Mount(basePath, newSub) 379 | return newSub 380 | } 381 | 382 | // Mount adds an API as a child based on route. It is like a reverse Group() 383 | func (a *API) Mount(basePath string, subAPI *API) { 384 | subAPI.basePath = basePath 385 | subAPI.parent = a 386 | a.subAPIs = append(a.subAPIs, subAPI) 387 | rebuildAPI(a) 388 | } 389 | 390 | // rebuildRouter rebuilds the entire router. This is not particularly efficient 391 | // but at least this allows us to specify middleware/routes/groups in any order 392 | // while still having a guaranteed final order 393 | func (a *API) rebuildRouter() chi.Router { 394 | var schema []byte 395 | apiSpec := OpenAPI{ 396 | OpenAPI: "3.1.0", 397 | Paths: make(map[string]Path), 398 | Info: Info{ 399 | Version: "v0.0.0", 400 | Title: "API", 401 | }, 402 | Servers: make([]Server, 0), 403 | Components: &Components{ 404 | Schemas: make(map[string]jsonschema.Schema), 405 | }, 406 | } 407 | apiSpec.Merge(a.openAPISpec) 408 | 409 | if a.parent == nil { 410 | a.openAPISpec = apiSpec 411 | } 412 | 413 | router := chi.NewRouter() 414 | if a.parent == nil { 415 | router.MethodFunc(http.MethodGet, "/openapi.json", func(w http.ResponseWriter, r *http.Request) { 416 | if schema == nil { 417 | schema, _ = json.Marshal(apiSpec) 418 | } 419 | w.Write(schema) 420 | }) 421 | router.Handle("/docs*", 422 | v5emb.New( 423 | apiSpec.Info.Title, 424 | "/openapi.json", 425 | "/docs", 426 | ), 427 | ) 428 | } 429 | for _, sub := range a.subAPIs { 430 | if sub.basePath == "" || sub.basePath[0] != '/' { 431 | sub.basePath = "/" + sub.basePath 432 | } 433 | router.Mount(sub.basePath, sub.rebuildRouter()) 434 | apiSpec.Merge(sub.openAPISpec) 435 | } 436 | for apiPath, filesPath := range a.staticPaths { 437 | fileServer := http.FileServer(http.Dir(filesPath)) 438 | router.Get(apiPath+"*", http.StripPrefix(apiPath, fileServer).ServeHTTP) 439 | } 440 | 441 | middlewareChain := make([]MiddlewareFunc, 0) 442 | for api := a; api != nil; api = api.parent { 443 | middlewareChain = append(api.middleware, middlewareChain...) 444 | } 445 | handler := func(w *httpResponseWriter, r *http.Request) (ResponseWriter, error) { 446 | w.route.handler(w, r) 447 | return w.response, w.respError 448 | } 449 | 450 | for i := len(middlewareChain) - 1; i >= 0; i-- { 451 | h := handler 452 | middleware := middlewareChain[i] 453 | // switch middleware := middlewareChain[i].(type) { 454 | // case MiddlewareFunc: 455 | handler = (func(w *httpResponseWriter, r *http.Request) (ResponseWriter, error) { 456 | wrapped := middlewareWrapper{ 457 | writer: w, 458 | handler: h, 459 | } 460 | return middleware(r, w.route.context, wrapped.Next) 461 | }) 462 | // case HttpMiddlewareFunc: 463 | // next := func(w http.ResponseWriter, req *http.Request) { 464 | // writer := w.(*httpResponseWriter) 465 | // writer.response, writer.responseError = handler(w, req) 466 | // } 467 | // handler = (func(w *httpResponseWriter, r *http.Request) (ResponseWriter, error) { 468 | // fake := Response{} 469 | // return middleware(r, w.route.context, wrapped.Next) 470 | // }) 471 | // } 472 | 473 | 474 | // if true { 475 | // middleware := (func(next http.Handler) http.Handler { 476 | // return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 477 | // w.Write(nil) 478 | // // fake w 479 | // next.ServeHTTP(&Response{}, r) 480 | // w.Write(nil) 481 | // }) 482 | // }) 483 | // h := handler 484 | // handler = (func(writer *httpResponseWriter, req *http.Request) (ResponseWriter, error) { 485 | // next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 486 | // wr, ok := w.(*httpResponseWriter) 487 | // if !ok { 488 | // wr = &httpResponseWriter{ 489 | // writer: w, 490 | // route: writer.route, 491 | // } 492 | // wr.response, wr.respError = h(wr, r) 493 | // write(wr, w, r) 494 | // } else { 495 | // wr.response, wr.respError = h(wr, r) 496 | // if wr.dirty { 497 | // write(wr, w, r) 498 | // } 499 | // } 500 | // }) 501 | // middleware(next).ServeHTTP(writer, req) 502 | // if writer.dirty { 503 | // write(writer, writer.writer, req) 504 | // } 505 | // return writer.response, writer.respError 506 | // }) 507 | // } 508 | } 509 | for _, route := range a.routes { 510 | if route.hidden { 511 | toDelete := make([]string, 0) 512 | for path, obj := range apiSpec.Paths { 513 | if obj.Patch == route.operationSpec { 514 | obj.Patch = nil 515 | } 516 | if obj.Get == route.operationSpec { 517 | obj.Get = nil 518 | } 519 | if obj.Put == route.operationSpec { 520 | obj.Put = nil 521 | } 522 | if obj.Post == route.operationSpec { 523 | obj.Post = nil 524 | } 525 | if obj.Delete == route.operationSpec { 526 | obj.Delete = nil 527 | } 528 | if obj.Options == route.operationSpec { 529 | obj.Options = nil 530 | } 531 | if obj.Post == nil && obj.Patch == nil && obj.Put == nil && obj.Get == nil && obj.Delete == nil && obj.Options == nil { 532 | toDelete = append(toDelete, path) 533 | } 534 | } 535 | for _, p := range toDelete { 536 | delete(apiSpec.Paths, p) 537 | } 538 | } 539 | if route.context.path == "" || route.context.path[0] != '/' { 540 | route.context.path = "/" + route.context.path 541 | } 542 | if len(middlewareChain) > 0 { 543 | router.MethodFunc(route.context.method, route.context.path, func(w http.ResponseWriter, r *http.Request) { 544 | writer := w.(*httpResponseWriter) 545 | writer.route = route 546 | writer.response, writer.respError = handler(writer, r) 547 | }) 548 | } else { 549 | router.MethodFunc(route.context.method, route.context.path, route.handler) 550 | } 551 | 552 | } 553 | a.router = router 554 | return router 555 | } 556 | -------------------------------------------------------------------------------- /api_test.go: -------------------------------------------------------------------------------- 1 | package chimera_test 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/matt1484/chimera" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func addRequestTestHandler[ReqPtr chimera.RequestReaderPtr[Req], Req any](t assert.TestingT, api *chimera.API, method, path string, expectedReq ReqPtr) *ReqPtr { 11 | value := new(ReqPtr) 12 | switch method { 13 | case http.MethodGet: 14 | chimera.Get(api, path, func(req ReqPtr) (*chimera.EmptyResponse, error) { 15 | *value = req 16 | return nil, nil 17 | }) 18 | case http.MethodPut: 19 | chimera.Put(api, path, func(req ReqPtr) (*chimera.EmptyResponse, error) { 20 | *value = req 21 | return nil, nil 22 | }) 23 | case http.MethodPost: 24 | chimera.Post(api, path, func(req ReqPtr) (*chimera.EmptyResponse, error) { 25 | *value = req 26 | return nil, nil 27 | }) 28 | case http.MethodDelete: 29 | chimera.Delete(api, path, func(req ReqPtr) (*chimera.EmptyResponse, error) { 30 | *value = req 31 | return nil, nil 32 | }) 33 | case http.MethodPatch: 34 | chimera.Patch(api, path, func(req ReqPtr) (*chimera.EmptyResponse, error) { 35 | *value = req 36 | return nil, nil 37 | }) 38 | } 39 | return value 40 | } 41 | 42 | func addResponseTestHandler[RespPtr chimera.ResponseWriterPtr[Resp], Resp any](t assert.TestingT, api *chimera.API, method, path string, resp RespPtr) { 43 | switch method { 44 | case http.MethodGet: 45 | chimera.Get(api, path, func(*chimera.EmptyRequest) (RespPtr, error) { 46 | return resp, nil 47 | }) 48 | case http.MethodPut: 49 | chimera.Put(api, path, func(*chimera.EmptyRequest) (RespPtr, error) { 50 | return resp, nil 51 | }) 52 | case http.MethodPost: 53 | chimera.Post(api, path, func(*chimera.EmptyRequest) (RespPtr, error) { 54 | return resp, nil 55 | }) 56 | case http.MethodDelete: 57 | chimera.Delete(api, path, func(*chimera.EmptyRequest) (RespPtr, error) { 58 | return resp, nil 59 | }) 60 | case http.MethodPatch: 61 | chimera.Patch(api, path, func(*chimera.EmptyRequest) (RespPtr, error) { 62 | return resp, nil 63 | }) 64 | } 65 | } 66 | 67 | type TestValidCustomParam string 68 | 69 | type TestStructParams struct { 70 | StringProp string `prop:"stringprop"` 71 | IntProp int `prop:"intprop"` 72 | } 73 | -------------------------------------------------------------------------------- /binary.go: -------------------------------------------------------------------------------- 1 | package chimera 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "net/http" 7 | "reflect" 8 | 9 | "github.com/invopop/jsonschema" 10 | ) 11 | 12 | var ( 13 | _ RequestReader = new(BinaryRequest[Nil]) 14 | _ ResponseWriter = new(BinaryResponse[Nil]) 15 | _ RequestReader = new(Binary[Nil]) 16 | _ ResponseWriter = new(Binary[Nil]) 17 | ) 18 | 19 | // BinaryRequest[Params any] is a request type that uses a 20 | // []byte as the Body and Params as an user-provided struct 21 | type BinaryRequest[Params any] struct { 22 | request *http.Request 23 | Body []byte 24 | Params Params 25 | } 26 | 27 | // Context returns the context that was part of the original http.Request 28 | func (r *BinaryRequest[Params]) Context() context.Context { 29 | if r.request != nil { 30 | return r.request.Context() 31 | } 32 | return nil 33 | } 34 | 35 | func readBinaryRequest[Params any](req *http.Request, body *[]byte, params *Params) error { 36 | defer req.Body.Close() 37 | b, err := io.ReadAll(req.Body) 38 | if err != nil { 39 | return err 40 | } 41 | 42 | *body = b 43 | 44 | if _, ok := any(params).(*Nil); !ok { 45 | err = UnmarshalParams(req, params) 46 | if err != nil { 47 | return err 48 | } 49 | } 50 | return nil 51 | } 52 | 53 | // ReadRequest reads the body of an http request and assigns it to the Body field using io.ReadAll. 54 | // This function also reads the parameters using UnmarshalParams and assigns it to the Params field. 55 | // NOTE: the body of the request is closed after this function is run. 56 | func (r *BinaryRequest[Params]) ReadRequest(req *http.Request) error { 57 | r.request = req 58 | return readBinaryRequest(req, &r.Body, &r.Params) 59 | } 60 | 61 | func binaryRequestSpec[Params any](schema *RequestSpec) { 62 | schema.RequestBody = &RequestBody{ 63 | Content: map[string]MediaType{ 64 | "application/octet-stream": { 65 | Schema: &jsonschema.Schema{ 66 | Type: "string", 67 | Format: "binary", 68 | }, 69 | }, 70 | }, 71 | } 72 | 73 | pType := reflect.TypeOf(new(Params)) 74 | for ; pType.Kind() == reflect.Pointer; pType = pType.Elem() { 75 | } 76 | if pType != reflect.TypeOf(Nil{}) { 77 | schema.Parameters = CacheRequestParamsType(pType) 78 | } 79 | } 80 | 81 | // OpenAPIRequestSpec returns the Request definition of a BinaryRequest 82 | func (r *BinaryRequest[Params]) OpenAPIRequestSpec() RequestSpec { 83 | schema := RequestSpec{} 84 | binaryRequestSpec[Params](&schema) 85 | return schema 86 | } 87 | 88 | // BinaryResponse[Params any] is a response type that uses a 89 | // []byte as the Body and Params as an user-provided struct 90 | type BinaryResponse[Params any] struct { 91 | Body []byte 92 | Params Params 93 | } 94 | 95 | // WriteBody writes the response body 96 | func (r *BinaryResponse[Params]) WriteBody(write BodyWriteFunc) error { 97 | _, err := write(r.Body) 98 | return err 99 | } 100 | 101 | func binaryResponsesSpec[Params any](schema Responses) { 102 | response := ResponseSpec{} 103 | response.Content = map[string]MediaType{ 104 | "application/octet-stream": { 105 | Schema: &jsonschema.Schema{ 106 | Type: "string", 107 | Format: "binary", 108 | }, 109 | }, 110 | } 111 | 112 | pType := reflect.TypeOf(*new(Params)) 113 | for ; pType.Kind() == reflect.Pointer; pType = pType.Elem() { 114 | } 115 | if pType != reflect.TypeOf(Nil{}) { 116 | response.Headers = make(map[string]Parameter) 117 | for _, param := range CacheResponseParamsType(pType) { 118 | response.Headers[param.Name] = Parameter{ 119 | Schema: param.Schema, 120 | Description: param.Description, 121 | Deprecated: param.Deprecated, 122 | AllowReserved: param.AllowReserved, 123 | AllowEmptyValue: param.AllowEmptyValue, 124 | Required: param.Required, 125 | Explode: param.Explode, 126 | Example: param.Example, 127 | Examples: param.Examples, 128 | } 129 | } 130 | } 131 | schema[""] = response 132 | } 133 | 134 | // OpenAPIResponsesSpec returns the Responses definition of a BinaryResponse 135 | func (r *BinaryResponse[Params]) OpenAPIResponsesSpec() Responses { 136 | schema := make(Responses) 137 | binaryResponsesSpec[Params](schema) 138 | return schema 139 | } 140 | 141 | // WriteHead writes adds the header for this response object 142 | func (r *BinaryResponse[Params]) WriteHead(head *ResponseHead) error { 143 | head.Headers.Set("Content-Type", "application/octet-stream") 144 | h, err := MarshalParams(&r.Params) 145 | if err != nil { 146 | return err 147 | } 148 | for k, v := range h { 149 | for _, x := range v { 150 | head.Headers.Add(k, x) 151 | } 152 | } 153 | return nil 154 | } 155 | 156 | // NewBinaryResponse creates a BinaryResponse from body and params 157 | func NewBinaryResponse[Params any](body []byte, params Params) *BinaryResponse[Params] { 158 | return &BinaryResponse[Params]{ 159 | Body: body, 160 | Params: params, 161 | } 162 | } 163 | 164 | // Binary[Params] is a helper type that effectively works as both a BinaryRequest[Params] and BinaryResponse[Params] 165 | // This is mostly here for convenience 166 | type Binary[Params any] struct { 167 | request *http.Request 168 | Body []byte 169 | Params Params 170 | } 171 | 172 | // Context returns the context that was part of the original http.Request 173 | // if this was used in a non-request context it will return nil 174 | func (r *Binary[Params]) Context() context.Context { 175 | if r.request != nil { 176 | return r.request.Context() 177 | } 178 | return nil 179 | } 180 | 181 | // ReadRequest reads the body of an http request and assigns it to the Body field using io.ReadAll. 182 | // This function also reads the parameters using UnmarshalParams and assigns it to the Params field. 183 | // NOTE: the body of the request is closed after this function is run. 184 | func (r *Binary[Params]) ReadRequest(req *http.Request) error { 185 | r.request = req 186 | return readBinaryRequest(req, &r.Body, &r.Params) 187 | } 188 | 189 | // OpenAPIRequestSpec returns the Request definition of a BinaryRequest 190 | func (r *Binary[Params]) OpenAPIRequestSpec() RequestSpec { 191 | schema := RequestSpec{} 192 | binaryRequestSpec[Params](&schema) 193 | return schema 194 | } 195 | 196 | // WriteBody writes the response body 197 | func (r *Binary[Params]) WriteBody(write BodyWriteFunc) error { 198 | _, err := write(r.Body) 199 | return err 200 | } 201 | 202 | // OpenAPIResponsesSpec returns the Responses definition of a BinaryResponse 203 | func (r *Binary[Params]) OpenAPIResponsesSpec() Responses { 204 | schema := make(Responses) 205 | binaryResponsesSpec[Params](schema) 206 | return schema 207 | } 208 | 209 | // WriteHead writes the header for this response object 210 | func (r *Binary[Params]) WriteHead(head *ResponseHead) error { 211 | head.Headers.Set("Content-Type", "application/octet-stream") 212 | h, err := MarshalParams(&r.Params) 213 | if err != nil { 214 | return err 215 | } 216 | for k, v := range h { 217 | for _, x := range v { 218 | head.Headers.Add(k, x) 219 | } 220 | } 221 | return nil 222 | } 223 | -------------------------------------------------------------------------------- /cookie.go: -------------------------------------------------------------------------------- 1 | package chimera 2 | 3 | import ( 4 | "net/http" 5 | "reflect" 6 | ) 7 | 8 | var ( 9 | cookieParamUnmarshalerType = reflect.TypeOf((*CookieParamUnmarshaler)(nil)).Elem() 10 | cookieParamMarshalerType = reflect.TypeOf((*CookieParamMarshaler)(nil)).Elem() 11 | ) 12 | 13 | // CookieParamUnmarshaler is an interface that supports converting an http.Cookie to a user-defined type 14 | type CookieParamUnmarshaler interface { 15 | UnmarshalCookieParam(http.Cookie, ParamStructTag) error 16 | } 17 | 18 | // CookieParamMarshaler is an interface that supports converting a user-defined type to an http.Cookie 19 | type CookieParamMarshaler interface { 20 | MarshalCookieParam(ParamStructTag) (http.Cookie, error) 21 | } 22 | 23 | // unmarshalCookieParam converts a cookie to a value using the options in tag 24 | func unmarshalCookieParam(param http.Cookie, tag *ParamStructTag, addr reflect.Value) error { 25 | addr = fixPointer(addr) 26 | if tag.schemaType == interfaceType { 27 | return addr.Interface().(CookieParamUnmarshaler).UnmarshalCookieParam(param, *tag) 28 | } 29 | return unmarshalStringParam(param.Value, tag, addr) 30 | } 31 | 32 | // unmarshalCookieParam converts a value to a http.Cookie using the options in tag 33 | func marshalCookieParam(tag *ParamStructTag, addr reflect.Value) (http.Cookie, error) { 34 | addr = fixPointer(addr) 35 | switch tag.schemaType { 36 | case interfaceType: 37 | return addr.Interface().(CookieParamMarshaler).MarshalCookieParam(*tag) 38 | case primitiveType: 39 | return http.Cookie{ 40 | Name: tag.Name, 41 | Value: marshalPrimitiveToString(addr), 42 | }, nil 43 | case sliceType: 44 | return http.Cookie{ 45 | Name: tag.Name, 46 | Value: marshalSliceToString(addr), 47 | }, nil 48 | case structType: 49 | return http.Cookie{ 50 | Name: tag.Name, 51 | Value: marshalStructToString(addr, tag), 52 | }, nil 53 | } 54 | return http.Cookie{}, nil 55 | } 56 | -------------------------------------------------------------------------------- /cookie_test.go: -------------------------------------------------------------------------------- 1 | package chimera_test 2 | 3 | import "net/http" 4 | 5 | type TestPrimitiveCookieParams struct { 6 | FormStr string `param:"formstr,in=cookie,style=form"` 7 | FormU8 uint8 `param:"formuint,in=cookie,style=form"` 8 | FormU16 uint16 `param:"formuint,in=cookie,style=form"` 9 | FormU32 uint32 `param:"formuint,in=cookie,style=form"` 10 | FormU64 uint64 `param:"formuint,in=cookie,style=form"` 11 | FormUint uint `param:"formuint,in=cookie,style=form"` 12 | FormI8 int8 `param:"formint,in=cookie,style=form"` 13 | FormI16 int16 `param:"formint,in=cookie,style=form"` 14 | FormI32 int32 `param:"formint,in=cookie,style=form"` 15 | FormI64 int64 `param:"formint,in=cookie,style=form"` 16 | FormInt int `param:"formint,in=cookie,style=form"` 17 | FormF32 float32 `param:"formfloat,in=cookie,style=form"` 18 | FormF64 float64 `param:"formfloat,in=cookie,style=form"` 19 | 20 | FormExplodeStr string `param:"formexstr,in=cookie,explode,style=form"` 21 | FormExplodeU8 uint8 `param:"formexuint,in=cookie,explode,style=form"` 22 | FormExplodeU16 uint16 `param:"formexuint,in=cookie,explode,style=form"` 23 | FormExplodeU32 uint32 `param:"formexuint,in=cookie,explode,style=form"` 24 | FormExplodeU64 uint64 `param:"formexuint,in=cookie,explode,style=form"` 25 | FormExplodeUint uint `param:"formexuint,in=cookie,explode,style=form"` 26 | FormExplodeI8 int8 `param:"formexint,in=cookie,explode,style=form"` 27 | FormExplodeI16 int16 `param:"formexint,in=cookie,explode,style=form"` 28 | FormExplodeI32 int32 `param:"formexint,in=cookie,explode,style=form"` 29 | FormExplodeI64 int64 `param:"formexint,in=cookie,explode,style=form"` 30 | FormExplodeInt int `param:"formexint,in=cookie,explode,style=form"` 31 | FormExplodeF32 float32 `param:"formexfloat,in=cookie,explode,style=form"` 32 | FormExplodeF64 float64 `param:"formexfloat,in=cookie,explode,style=form"` 33 | } 34 | 35 | type TestComplexCookieParams struct { 36 | FormStr []string `param:"formstr,in=cookie,style=form"` 37 | FormU8 []uint8 `param:"formuint,in=cookie,style=form"` 38 | FormU16 []uint16 `param:"formuint,in=cookie,style=form"` 39 | FormU32 []uint32 `param:"formuint,in=cookie,style=form"` 40 | FormU64 []uint64 `param:"formuint,in=cookie,style=form"` 41 | FormUint []uint `param:"formuint,in=cookie,style=form"` 42 | FormI8 []int8 `param:"formint,in=cookie,style=form"` 43 | FormI16 []int16 `param:"formint,in=cookie,style=form"` 44 | FormI32 []int32 `param:"formint,in=cookie,style=form"` 45 | FormI64 []int64 `param:"formint,in=cookie,style=form"` 46 | FormInt []int `param:"formint,in=cookie,style=form"` 47 | FormF32 []float32 `param:"formfloat,in=cookie,style=form"` 48 | FormF64 []float64 `param:"formfloat,in=cookie,style=form"` 49 | FormStruct TestStructParams `param:"formstruct,in=cookie,style=form"` 50 | 51 | FormExplodeStr []string `param:"formexstr,in=cookie,explode,style=form"` 52 | FormExplodeU8 []uint8 `param:"formexuint,in=cookie,explode,style=form"` 53 | FormExplodeU16 []uint16 `param:"formexuint,in=cookie,explode,style=form"` 54 | FormExplodeU32 []uint32 `param:"formexuint,in=cookie,explode,style=form"` 55 | FormExplodeU64 []uint64 `param:"formexuint,in=cookie,explode,style=form"` 56 | FormExplodeUint []uint `param:"formexuint,in=cookie,explode,style=form"` 57 | FormExplodeI8 []int8 `param:"formexint,in=cookie,explode,style=form"` 58 | FormExplodeI16 []int16 `param:"formexint,in=cookie,explode,style=form"` 59 | FormExplodeI32 []int32 `param:"formexint,in=cookie,explode,style=form"` 60 | FormExplodeI64 []int64 `param:"formexint,in=cookie,explode,style=form"` 61 | FormExplodeInt []int `param:"formexint,in=cookie,explode,style=form"` 62 | FormExplodeF32 []float32 `param:"formexfloat,in=cookie,explode,style=form"` 63 | FormExplodeF64 []float64 `param:"formexfloat,in=cookie,explode,style=form"` 64 | } 65 | 66 | var ( 67 | testValidFormPrimitiveCookieValues = []http.Cookie{ 68 | {Name: "formstr", Value: "ateststring"}, 69 | {Name: "formuint", Value: "123"}, 70 | {Name: "formint", Value: "-123"}, 71 | {Name: "formfloat", Value: "123.45"}, 72 | {Name: "formexstr", Value: "test..."}, 73 | {Name: "formexuint", Value: "255"}, 74 | {Name: "formexint", Value: "0"}, 75 | {Name: "formexfloat", Value: "-123.45"}, 76 | } 77 | testValidFormComplexCookieValues = []http.Cookie{ 78 | {Name: "formstr", Value: "string1,string2"}, 79 | {Name: "formuint", Value: "0,123"}, 80 | {Name: "formint", Value: "123,-123"}, 81 | {Name: "formfloat", Value: "123.45,0.0"}, 82 | {Name: "formstruct", Value: "stringprop,propstring,intprop,123"}, 83 | {Name: "formexstr", Value: "test..."}, 84 | {Name: "formexuint", Value: "255"}, 85 | {Name: "formexint", Value: "0"}, 86 | {Name: "formexfloat", Value: "-123.45"}, 87 | } 88 | 89 | testPrimitiveCookieParams = TestPrimitiveCookieParams{ 90 | FormStr: "ateststring", 91 | FormU8: 123, 92 | FormU16: 123, 93 | FormU32: 123, 94 | FormU64: 123, 95 | FormUint: 123, 96 | FormI8: -123, 97 | FormI16: -123, 98 | FormI32: -123, 99 | FormI64: -123, 100 | FormInt: -123, 101 | FormF32: 123.45, 102 | FormF64: 123.45, 103 | 104 | FormExplodeStr: "test...", 105 | FormExplodeU8: 255, 106 | FormExplodeU16: 255, 107 | FormExplodeU32: 255, 108 | FormExplodeU64: 255, 109 | FormExplodeUint: 255, 110 | FormExplodeI8: 0, 111 | FormExplodeI16: 0, 112 | FormExplodeI32: 0, 113 | FormExplodeI64: 0, 114 | FormExplodeInt: 0, 115 | FormExplodeF32: -123.45, 116 | FormExplodeF64: -123.45, 117 | } 118 | 119 | testComplexCookieParams = TestComplexCookieParams{ 120 | FormStr: []string{"string1", "string2"}, 121 | FormU8: []uint8{0, 123}, 122 | FormU16: []uint16{0, 123}, 123 | FormU32: []uint32{0, 123}, 124 | FormU64: []uint64{0, 123}, 125 | FormUint: []uint{0, 123}, 126 | FormI8: []int8{123, -123}, 127 | FormI16: []int16{123, -123}, 128 | FormI32: []int32{123, -123}, 129 | FormI64: []int64{123, -123}, 130 | FormInt: []int{123, -123}, 131 | FormF32: []float32{123.45, 0.0}, 132 | FormF64: []float64{123.45, 0.0}, 133 | FormStruct: TestStructParams{ 134 | StringProp: "propstring", 135 | IntProp: 123, 136 | }, 137 | 138 | FormExplodeStr: []string{"test..."}, 139 | FormExplodeU8: []uint8{255}, 140 | FormExplodeU16: []uint16{255}, 141 | FormExplodeU32: []uint32{255}, 142 | FormExplodeU64: []uint64{255}, 143 | FormExplodeUint: []uint{255}, 144 | FormExplodeI8: []int8{0}, 145 | FormExplodeI16: []int16{0}, 146 | FormExplodeI32: []int32{0}, 147 | FormExplodeI64: []int64{0}, 148 | FormExplodeInt: []int{0}, 149 | FormExplodeF32: []float32{-123.45}, 150 | FormExplodeF64: []float64{-123.45}, 151 | } 152 | ) 153 | -------------------------------------------------------------------------------- /docs/Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem "jekyll", "~> 4.3.3" # installed by `gem jekyll` 4 | # gem "webrick" # required when using Ruby >= 3 and Jekyll <= 4.2.2 5 | 6 | gem "just-the-docs", "0.8.0" # pinned to the current release 7 | # gem "just-the-docs" # always download the latest release 8 | -------------------------------------------------------------------------------- /docs/Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | addressable (2.8.6) 5 | public_suffix (>= 2.0.2, < 6.0) 6 | colorator (1.1.0) 7 | concurrent-ruby (1.2.3) 8 | em-websocket (0.5.3) 9 | eventmachine (>= 0.12.9) 10 | http_parser.rb (~> 0) 11 | eventmachine (1.2.7) 12 | ffi (1.16.3) 13 | forwardable-extended (2.6.0) 14 | google-protobuf (3.25.3-arm64-darwin) 15 | google-protobuf (3.25.3-x86_64-linux) 16 | http_parser.rb (0.8.0) 17 | i18n (1.14.4) 18 | concurrent-ruby (~> 1.0) 19 | jekyll (4.3.3) 20 | addressable (~> 2.4) 21 | colorator (~> 1.0) 22 | em-websocket (~> 0.5) 23 | i18n (~> 1.0) 24 | jekyll-sass-converter (>= 2.0, < 4.0) 25 | jekyll-watch (~> 2.0) 26 | kramdown (~> 2.3, >= 2.3.1) 27 | kramdown-parser-gfm (~> 1.0) 28 | liquid (~> 4.0) 29 | mercenary (>= 0.3.6, < 0.5) 30 | pathutil (~> 0.9) 31 | rouge (>= 3.0, < 5.0) 32 | safe_yaml (~> 1.0) 33 | terminal-table (>= 1.8, < 4.0) 34 | webrick (~> 1.7) 35 | jekyll-include-cache (0.2.1) 36 | jekyll (>= 3.7, < 5.0) 37 | jekyll-sass-converter (3.0.0) 38 | sass-embedded (~> 1.54) 39 | jekyll-seo-tag (2.8.0) 40 | jekyll (>= 3.8, < 5.0) 41 | jekyll-watch (2.2.1) 42 | listen (~> 3.0) 43 | just-the-docs (0.8.0) 44 | jekyll (>= 3.8.5) 45 | jekyll-include-cache 46 | jekyll-seo-tag (>= 2.0) 47 | rake (>= 12.3.1) 48 | kramdown (2.4.0) 49 | rexml 50 | kramdown-parser-gfm (1.1.0) 51 | kramdown (~> 2.0) 52 | liquid (4.0.4) 53 | listen (3.9.0) 54 | rb-fsevent (~> 0.10, >= 0.10.3) 55 | rb-inotify (~> 0.9, >= 0.9.10) 56 | mercenary (0.4.0) 57 | pathutil (0.16.2) 58 | forwardable-extended (~> 2.6) 59 | public_suffix (5.0.4) 60 | rake (13.1.0) 61 | rb-fsevent (0.11.2) 62 | rb-inotify (0.10.1) 63 | ffi (~> 1.0) 64 | rexml (3.2.6) 65 | rouge (4.2.0) 66 | safe_yaml (1.0.5) 67 | sass-embedded (1.71.1-arm64-darwin) 68 | google-protobuf (~> 3.25) 69 | sass-embedded (1.71.1-x86_64-linux-gnu) 70 | google-protobuf (~> 3.25) 71 | terminal-table (3.0.2) 72 | unicode-display_width (>= 1.1.1, < 3) 73 | unicode-display_width (2.5.0) 74 | webrick (1.8.1) 75 | 76 | PLATFORMS 77 | arm64-darwin-23 78 | x86_64-linux 79 | 80 | DEPENDENCIES 81 | jekyll (~> 4.3.3) 82 | just-the-docs (= 0.8.0) 83 | 84 | BUNDLED WITH 85 | 2.3.26 86 | -------------------------------------------------------------------------------- /docs/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 just-the-docs 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # just-the-docs-template 2 | 3 | This is a *bare-minimum* template to create a [Jekyll] site that: 4 | 5 | - uses the [Just the Docs] theme; 6 | - can be built and published on [GitHub Pages]; 7 | - can be built and previewed locally, and published on other platforms. 8 | 9 | More specifically, the created site: 10 | 11 | - uses a gem-based approach, i.e. uses a `Gemfile` and loads the `just-the-docs` gem; 12 | - uses the [GitHub Pages / Actions workflow] to build and publish the site on GitHub Pages. 13 | 14 | To get started with creating a site, simply: 15 | 16 | 1. click "[use this template]" to create a GitHub repository 17 | 2. go to Settings > Pages > Build and deployment > Source, and select GitHub Actions 18 | 19 | If you want to maintain your docs in the `docs` directory of an existing project repo, see [Hosting your docs from an existing project repo](#hosting-your-docs-from-an-existing-project-repo). 20 | 21 | After completing the creation of your new site on GitHub, update it as needed: 22 | 23 | ## Replace the content of the template pages 24 | 25 | Update the following files to your own content: 26 | 27 | - `index.md` (your new home page) 28 | - `README.md` (information for those who access your site repo on GitHub) 29 | 30 | ## Changing the version of the theme and/or Jekyll 31 | 32 | Simply edit the relevant line(s) in the `Gemfile`. 33 | 34 | ## Adding a plugin 35 | 36 | The Just the Docs theme automatically includes the [`jekyll-seo-tag`] plugin. 37 | 38 | To add an extra plugin, you need to add it in the `Gemfile` *and* in `_config.yml`. For example, to add [`jekyll-default-layout`]: 39 | 40 | - Add the following to your site's `Gemfile`: 41 | 42 | ```ruby 43 | gem "jekyll-default-layout" 44 | ``` 45 | 46 | - And add the following to your site's `_config.yml`: 47 | 48 | ```yaml 49 | plugins: 50 | - jekyll-default-layout 51 | ``` 52 | 53 | Note: If you are using a Jekyll version less than 3.5.0, use the `gems` key instead of `plugins`. 54 | 55 | ## Publishing your site on GitHub Pages 56 | 57 | 1. If your created site is `YOUR-USERNAME/YOUR-SITE-NAME`, update `_config.yml` to: 58 | 59 | ```yaml 60 | title: YOUR TITLE 61 | description: YOUR DESCRIPTION 62 | theme: just-the-docs 63 | 64 | url: https://YOUR-USERNAME.github.io/YOUR-SITE-NAME 65 | 66 | aux_links: # remove if you don't want this link to appear on your pages 67 | Template Repository: https://github.com/YOUR-USERNAME/YOUR-SITE-NAME 68 | ``` 69 | 70 | 2. Push your updated `_config.yml` to your site on GitHub. 71 | 72 | 3. In your newly created repo on GitHub: 73 | - go to the `Settings` tab -> `Pages` -> `Build and deployment`, then select `Source`: `GitHub Actions`. 74 | - if there were any failed Actions, go to the `Actions` tab and click on `Re-run jobs`. 75 | 76 | ## Building and previewing your site locally 77 | 78 | Assuming [Jekyll] and [Bundler] are installed on your computer: 79 | 80 | 1. Change your working directory to the root directory of your site. 81 | 82 | 2. Run `bundle install`. 83 | 84 | 3. Run `bundle exec jekyll serve` to build your site and preview it at `localhost:4000`. 85 | 86 | The built site is stored in the directory `_site`. 87 | 88 | ## Publishing your built site on a different platform 89 | 90 | Just upload all the files in the directory `_site`. 91 | 92 | ## Customization 93 | 94 | You're free to customize sites that you create with this template, however you like! 95 | 96 | [Browse our documentation][Just the Docs] to learn more about how to use this theme. 97 | 98 | ## Hosting your docs from an existing project repo 99 | 100 | You might want to maintain your docs in an existing project repo. Instead of creating a new repo using the [just-the-docs template](https://github.com/just-the-docs/just-the-docs-template), you can copy the template files into your existing repo and configure the template's Github Actions workflow to build from a `docs` directory. You can clone the template to your local machine or download the `.zip` file to access the files. 101 | 102 | ### Copy the template files 103 | 104 | 1. Create a `.github/workflows` directory at your project root if your repo doesn't already have one. Copy the `pages.yml` file into this directory. GitHub Actions searches this directory for workflow files. 105 | 106 | 2. Create a `docs` directory at your project root and copy all remaining template files into this directory. 107 | 108 | ### Modify the GitHub Actions workflow 109 | 110 | The GitHub Actions workflow that builds and deploys your site to Github Pages is defined by the `pages.yml` file. You'll need to edit this file to that so that your build and deploy steps look to your `docs` directory, rather than the project root. 111 | 112 | 1. Set the default `working-directory` param for the build job. 113 | 114 | ```yaml 115 | build: 116 | runs-on: ubuntu-latest 117 | defaults: 118 | run: 119 | working-directory: docs 120 | ``` 121 | 122 | 2. Set the `working-directory` param for the Setup Ruby step. 123 | 124 | ```yaml 125 | - name: Setup Ruby 126 | uses: ruby/setup-ruby@v1 127 | with: 128 | ruby-version: '3.1' 129 | bundler-cache: true 130 | cache-version: 0 131 | working-directory: '${{ github.workspace }}/docs' 132 | ``` 133 | 134 | 3. Set the path param for the Upload artifact step: 135 | 136 | ```yaml 137 | - name: Upload artifact 138 | uses: actions/upload-pages-artifact@v1 139 | with: 140 | path: "docs/_site/" 141 | ``` 142 | 143 | 4. Modify the trigger so that only changes within the `docs` directory start the workflow. Otherwise, every change to your project (even those that don't affect the docs) would trigger a new site build and deploy. 144 | 145 | ```yaml 146 | on: 147 | push: 148 | branches: 149 | - "main" 150 | paths: 151 | - "docs/**" 152 | ``` 153 | 154 | ## Licensing and Attribution 155 | 156 | This repository is licensed under the [MIT License]. You are generally free to reuse or extend upon this code as you see fit; just include the original copy of the license (which is preserved when you "make a template"). While it's not necessary, we'd love to hear from you if you do use this template, and how we can improve it for future use! 157 | 158 | The deployment GitHub Actions workflow is heavily based on GitHub's mixed-party [starter workflows]. A copy of their MIT License is available in [actions/starter-workflows]. 159 | 160 | ---- 161 | 162 | [^1]: [It can take up to 10 minutes for changes to your site to publish after you push the changes to GitHub](https://docs.github.com/en/pages/setting-up-a-github-pages-site-with-jekyll/creating-a-github-pages-site-with-jekyll#creating-your-site). 163 | 164 | [Jekyll]: https://jekyllrb.com 165 | [Just the Docs]: https://just-the-docs.github.io/just-the-docs/ 166 | [GitHub Pages]: https://docs.github.com/en/pages 167 | [GitHub Pages / Actions workflow]: https://github.blog/changelog/2022-07-27-github-pages-custom-github-actions-workflows-beta/ 168 | [Bundler]: https://bundler.io 169 | [use this template]: https://github.com/just-the-docs/just-the-docs-template/generate 170 | [`jekyll-default-layout`]: https://github.com/benbalter/jekyll-default-layout 171 | [`jekyll-seo-tag`]: https://jekyll.github.io/jekyll-seo-tag 172 | [MIT License]: https://en.wikipedia.org/wiki/MIT_License 173 | [starter workflows]: https://github.com/actions/starter-workflows/blob/main/pages/jekyll.yml 174 | [actions/starter-workflows]: https://github.com/actions/starter-workflows/blob/main/LICENSE 175 | -------------------------------------------------------------------------------- /docs/_config.yml: -------------------------------------------------------------------------------- 1 | title: chimera 2 | description: 3 | theme: just-the-docs 4 | 5 | domain: matt1484.github.io 6 | url: https://matt1484.github.io 7 | baseurl: /chimera/ 8 | color_scheme: dark 9 | 10 | heading_anchors: true -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Overview 3 | layout: default 4 | nav_order: 1 5 | --- 6 | 7 | # chimera 8 | Chi-based Module for Easy REST APIs 9 | 10 | ## Overview 11 | `chimera` is designed for fast/easy API development based on OpenAPI with the following core features: 12 | - Automatic OpenAPI (3.1) docs from structs (no comments or files needed) 13 | - Automatic parsing of JSON/text/binary/form requests 14 | - Automatic serialization of JSON/text/binary responses 15 | - Automatic handling of request/response parameters (cookies/query/headers/path) 16 | - Middleware (with easy error handling) 17 | - Route groups (with isolated middleware) 18 | - Error handling as responses 19 | - Static file serving from directories 20 | 21 | Gettings started is as easy as: 22 | 23 | ```go 24 | package main 25 | 26 | import "github.com/matt1484/chimera" 27 | 28 | type TestBody struct { 29 | Property string `json:"prop"` 30 | } 31 | 32 | type TestParams struct { 33 | Path string `param:"path,in=path"` 34 | Header string `param:"header,in=header"` 35 | } 36 | 37 | func main() { 38 | api := chimera.NewAPI() 39 | api.Use(func(req *http.Request, ctx chimera.RouteContext, next chimera.NextFunc) (chimera.ResponseWriter, error) { 40 | resp, err := next(req) 41 | return resp, err 42 | }) 43 | chimera.Get(api, "/test/{path}", func(req *chimera.JSON[TestBody, TestParams]) (*chimera.JSON[TestBody, chimera.Nil], error) { 44 | return &chimera.JSON[TestBody, TestParams]{ 45 | Body: req.Body, 46 | } 47 | }) 48 | api.Start(":8000") 49 | } 50 | 51 | ``` 52 | -------------------------------------------------------------------------------- /docs/json.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: JSON 3 | layout: default 4 | nav_order: 3 5 | --- 6 | 7 | # JSON 8 | `chimera` supports requests and responses with JSON bodies via these classes: 9 | - `JSONRequest[Body, Params any]`: JSON request with `Body` type being parsed via `encoding/json` and `Params` type being parsed via `chimera.UnmarshalParams` 10 | - `JSONResponse[Body, Params any]`: JSON response with `Body` type being marshaled via `encoding/json` and `Params` type being marshaled via `chimera.MarshalParams` 11 | - `JSON[Body, Params any]`: represents both `JSONRequest[Body, Params any]` and `JSONResponse[Body, Params any]` 12 | 13 | Essentially, you just need to provide any valid JSON type for `Body` and any struct with `param` tags for `Params` and the object is structured like so: 14 | ```golang 15 | type JSON[Body, Params any] struct { 16 | Body Body 17 | Params Params 18 | } 19 | ``` 20 | so the parsed body ends up in `Body` and parsed params end up in `Params`. 21 | 22 | ## Usage 23 | An example of how to use JSON in chimera is: 24 | ```golang 25 | type RequestBody struct { 26 | S string `json:"s" jsonschema:"description='some property'"` 27 | } 28 | 29 | type RequestParams struct { 30 | P string `param:"p,in=path" 31 | } 32 | 33 | type ResponseBody struct { 34 | I int `json:"i"` 35 | } 36 | 37 | type ResponseParams struct { 38 | H string `param:"h,in=header" 39 | } 40 | 41 | chimera.Post(api, "/route/{path}", func(req *chimera.JSON[RequestBody, RequestParams]) (*chimera.JSON[ResponseBody, ResponseParams], error) { 42 | // req contains an already parsed JSON body 43 | // any returned response will be marshaled as JSON before writing the body 44 | return nil, nil 45 | }) 46 | ``` -------------------------------------------------------------------------------- /docs/middleware.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Middleware 3 | layout: default 4 | nav_order: 3 5 | parent: Routing 6 | --- 7 | 8 | # Middleware 9 | `chimera` allows the use of generic middleware of the form: 10 | ```golang 11 | func(req *http.Request, ctx chimera.RouteContext, next func(req *http.Request) (ResponseWriter, error)) (ResponseWriter, error) 12 | ``` 13 | Effectively this allows for: 14 | - requests to be modified before reaching handlers 15 | - responses to be modified before written (which doesn't happen until after all middleware) 16 | - errors in processing to be handled more gracefully 17 | An example of this would be: 18 | ```golang 19 | // Use adds middleware to an api 20 | api.Use(func(req *http.Request, ctx chimera.RouteContext, next chimera.NextFunc) (chimera.ResponseWriter, error) { 21 | // ctx contains basic info about the matched route which cant be modified, but can be read 22 | // middleware can modify the request directly here before calling next 23 | // next invokes the next middleware or handler function 24 | resp, err := next(req) 25 | // resp is an interface technically, so it can't be read directly 26 | // but you could use ctx.GetResponseHead(resp) to get the headers/status to edit and then 27 | // turn into a custom response with the same body like: 28 | // chimera.NewLazyBodyResponse(head, resp) 29 | // or you could use ctx.GetResponse(resp) to get the the whole Response in memory. 30 | // err could also be handled more gracefully here before the actual response gets written 31 | return resp, err 32 | }) 33 | ``` 34 | It's import to note that middleware can't write responses directly, but they can return a `ResponseWriter` (like `Response`) which will write it later. This is different from how a lot of other frameworks based on the standard lib handle it, but in theory makes it easy to construct responses on the fly. This does come with a few caveats: 35 | 1. Middleware can't tell if their response will have an error when writing 36 | 2. Standard library based middleware attempting to intercept responses will end up storing the whole response in memory 37 | 38 | ## Stand lib support 39 | `chimera` has a wrapper function: 40 | ```golang 41 | HTTPMiddleware(middleware func(http.Handler) http.Handler) chimera.MiddlewareFunc 42 | ``` 43 | that attempts to convert standard library based middleware into a lazy-response middleware that is used in `chimera`. In general this will work for most middleware that: 44 | - modify requests 45 | - write small responses 46 | - edit response headers from next 47 | 48 | While still being lazy-write like everything else in `chimera`. Any middleware using this that change the `http.ResponseWriter` before calling next will end up with the entire response written in memory. In general this is only an issue for large files or responses that would have historically been streamed to the writer. 49 | -------------------------------------------------------------------------------- /docs/openapi.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: OpenAPI 3 | layout: default 4 | nav_order: 4 5 | --- 6 | # OpenAPI 7 | `chimera` has built in support for automatically generating OpenAPI 3.1 documentation. (the current latest version) 8 | It does this by providing structs in [`openapi.go`](../openapi.go) that cover almost the entirety of the OpenAPI 3.1 [spec](https://spec.openapis.org/oas/v3.1.0) 9 | 10 | When starting a server, the OpenAPI docs are available at `/openapi.json` with a Swagger UI at `/docs`. 11 | 12 | ## API Routes 13 | The `API` struct contains top-level OpenAPI docs that can be retrieved and edited using `API.OpenAPISpec()`. 14 | Technically sub-apis (a la `API.Group()`) also have an `OpenAPI` object but they get merged in to the main (i.e. top-most parent) `API` object. 15 | Each call to `Get`, `Post`, `Patch`, `Put`, `Delete`, `Options` adds an `Operation` to the `OpenAPI` object by using the provided path, associated HTTP method, and the `OpenAPIRequestSpec()` and `OpenAPIResponsesSpec()` functions on the handler's `RequestReader` and `ResponseWriter` respectively. 16 | The exception is routes created created with `Static()` which are hidden from the spec. Similarly you can run `Internalize()` on any route to hide it from the api spec. 17 | Default status code is determined using the following logic: 18 | - `Get`, `Patch`, `Put` assumes the default response code is `200` (unless this is explicitly changed on the `Route` object) 19 | - `Post` assumes the default response code is `201` (unless this is explicitly changed on the `Route` object) 20 | - `Delete`, `Options` assumes the default response code is `204` (unless this is explicitly changed on the `Route` object) 21 | - The provided response types (i.e. `JSONResponse`) set the response code to "", so custom types that use that in their `Responses` object will have that code overwritten 22 | - If path/method combinations are overwritten, the most recent one will take precendence in the spec 23 | - Routes can have the default status code changed using `WithResponseCode()` 24 | 25 | ## JSONSchema 26 | Since OpenAPI 3.1 supports JSONSchema, `chimera` uses [`invopop/jsonschema`](https://github.com/invopop/jsonschema) to generate schemas from request/response bodies. This relies heavily on the `jsonschema` struct tag and other relevant tags based on type (i.e. `json`, `form`, `param`, `prop`) 27 | 28 | ## Parameters 29 | OpenAPI parameters use the `param` struct tag (`ParamStructTag`) to define how parameters are defined in the spec. The parameters section of the docs covers this further. -------------------------------------------------------------------------------- /docs/params.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Parameters 3 | layout: default 4 | nav_order: 2 5 | --- 6 | # Params 7 | `chimera` attempts to marshal/unmarshal params via the standards set in [OpenAPI](https://spec.openapis.org/oas/v3.1.0#parameter-object). 8 | This is done via 2 methods: 9 | ```golang 10 | func UnmarshalParams(request *http.Request, obj any) error 11 | func MarshalParams(obj any) (http.Header, error) 12 | ``` 13 | that each utilize the `param` struct tag of the format: 14 | ```golang 15 | type ParamStructTag struct { 16 | Name string `structtag:"$name"` 17 | In In `structtag:"in,required"` 18 | Explode bool `structtag:"explode"` 19 | Style Style `structtag:"style"` 20 | Required bool `structtag:"required"` 21 | Description string `structtag:"description"` 22 | Deprecated bool `structtag:"deprecated"` 23 | AllowEmptyValue bool `structtag:"allowEmptyValue"` 24 | AllowReserved bool `structtag:"allowReserved"` 25 | } 26 | ``` 27 | The options closely follow the OpenAPI formats but an overview of the options is as follows: 28 | - `$name`: the first value of the struct (similar to json) 29 | - `in`: one of `cookie`, `path`, `query`, or `header` (same as OpenAPI) 30 | - `explode`: controls how `style` works (same as OpenAPI), defaults to false 31 | - `style`: one of `matrix`, `label`, `form`, `simple`, `spaceDelimited`, `pipeDelimited`, or `deepObject` (same as OpenAPI) 32 | - `required`: marks the param as required (validation will fail if the param is missing) 33 | - `description`: describes the param (same as OpenAPI) 34 | - `deprecated`: marks the param as deprecated (same as OpenAPI) 35 | - `allowEmptyValue`: same as OpenAPI 36 | - `allowReserved`: same as OpenAPI 37 | 38 | A complete example of this is: 39 | ```golang 40 | type Params struct { 41 | SomeProp string `param:"propName,in=query,explode,style=form,required,description='this is a param',allowEmptyValue,allowReserved"` 42 | } 43 | ``` 44 | Each type that supports utilizing param structs would then unmarshal each field using the options provided. 45 | Its important to note that fields that are `struct` types utilize the `prop` struct tag to determine the name of the sub properties of a param but cant provide any additional options for validation. 46 | 47 | ## Customizing 48 | To support customization of param marshaling/unmarshaling the following functions can be implemented: 49 | - `UnmarshalCookieParam(http.Cookie, ParamStructTag) error` 50 | - `MarshalCookieParam(ParamStructTag) (http.Cookie, error)` 51 | - `UnmarshalHeaderParam([]string, ParamStructTag) error` 52 | - `MarshalHeaderParam(ParamStructTag) (http.Header, error)` 53 | - `UnmarshalPathParam(string, ParamStructTag) error` 54 | - `UnmarshalQueryParam(url.Values, ParamStructTag) error` 55 | 56 | ## Usage 57 | The included request and response types utilize auto-handling of params like so: 58 | ```golang 59 | type RequestParams struct { 60 | PathParam string `param:"some_param,in=path,description:'a parameter'"` 61 | } 62 | 63 | type ResponseParams struct { 64 | HeaderParam string `param:"my-header,in=header,description:'a response header'"` 65 | } 66 | 67 | chimera.Post(api, "/route/{some_param}", func(req *chimera.NoBodyRequest[RequestParams]) (*chimera.NoBodyResponse[ResponseParams], error) { 68 | // request contains parsed RequestParams 69 | return &chimera.NoBodyResponse[ResponseParams]{ 70 | Params: ResponseParams{ 71 | HeaderParam: "some header value", // this will get written to the header "my-header" 72 | }, 73 | }, nil 74 | }) 75 | ``` -------------------------------------------------------------------------------- /docs/requests.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Requests 3 | layout: default 4 | nav_order: 1 5 | parent: Routing 6 | --- 7 | 8 | # Requests 9 | All route handlers use a pointer to a `RequestReader` (i.e. `RequestReaderPtr`) as their input which require the following methods to be defined: 10 | ```golang 11 | // responsible for unmarshaling the request object 12 | ReadRequest(*http.Request) error 13 | // describes the request object (body, params, etc.) 14 | OpenAPIRequestSpec() chimera.RequestSpec 15 | ``` 16 | 17 | All of the provided request types in `chimera` include sensible implementations for these methods so in general you only need to concern yourself with this if you are making your own `RequestReader` type. 18 | The internal usage however is defined by: 19 | 1. `OpenAPIRequestSpec()` is run when the route is registered (via `Get`, `Post`, etc.) so any pre-valdation should happen there 20 | 2. `ReadRequest()` is run just before being passed to the handler (i.e. JSON parsing) 21 | 3. If any errors occur in `ReadRequest()` then the handler will NOT be executed 22 | 23 | ## Basic request types 24 | `chimera` provides a few request types that implement `RequestReader` which are: 25 | - `Request` which is just an alias for `http.Request` 26 | - `NoBodyRequest[Params any]` which is a request with customizable params and no body (useful for GET requests) 27 | - `EmptyRequest` which is a request that has no body or params (useful for GET requests) -------------------------------------------------------------------------------- /docs/responses.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Responses 3 | layout: default 4 | nav_order: 2 5 | parent: Routing 6 | --- 7 | 8 | # Responses 9 | All route handlers use a pointer to a `ResponseWriter` (i.e. `ResponseWriterPtr`) as part of their output (plus an error) which require the following methods to be defined: 10 | ```golang 11 | WriteBody(write func(body []byte) (int, error)) error 12 | WriteHead(head *chimera.ResponseHead) error 13 | OpenAPIResponsesSpec() chimera.Responses 14 | ``` 15 | 16 | All of the provided request types in `chimera` include sensible implementations for these methods so in general you only need to concern yourself with this if you are making your own `ResponseWriter` type. 17 | The internal usage however is defined by: 18 | 1. The `OpenAPIRequestSpec()` is run when the route is registered (via `Get`, `Post`, etc.) so any pre-valdation should happen there 19 | 2. When handlers return a `ResponseWriter`, the write does NOT happen immediately and can be intercepted by middleware 20 | 3. After all middleware is done `WriteHead()` and `WriteResponse()` are used to write the response body/head to the underlying `http.ResponseWriter` 21 | 4. `WriteHead()` recieves a `ResponseHead` object with the default status code already set and an empty `http.Header` map 22 | 5. `WriteResponse()` and `WriteHead()` should be kept very simple since errors returned by them can not be easily caught. In general the only issues that should occur here are errors involving serialization (i.e. `json.Marshal`) or writing (i.e. `http.ResponseWriter.Write`) 23 | 6. handlers can return an `error`, which if non-nil will ignore the response value and instead return a generic `500` or a custom response if it is a `chimera.APIError`. 24 | 25 | 26 | ## Simple response types 27 | `chimera` provides a few response types that implement `ResponseWriter` which are: 28 | - `Response` which is just a set of predefined headers, body, response code 29 | - `NoBodyResponse[Params any]` which is a response that has no body but returns headers 30 | - `EmptyResponse` which is a response that has no body or params 31 | - `LazybodyResponse` which is a response with predefined headers/status code and a lazy body (written after middleware) 32 | -------------------------------------------------------------------------------- /docs/routing.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Routing 3 | layout: default 4 | nav_order: 2 5 | has_children: true 6 | --- 7 | 8 | # Routing 9 | Internally `chimera` uses [`go-chi/chi`](https://github.com/go-chi/chi) to setup and handle routing. This means that all pathing rules in `chi` apply here with some slight variations: 10 | - Middleware and routes can be defined in any order with middleware always taking precedence 11 | - Paths must be proper OpenAPI paths (i.e. `/path/{param}`) so regex paths arent fully supported yet 12 | - Group routes can be defined indepently (instead of all in one function) 13 | 14 | ## APIs 15 | `chimera` uses an `API` object to serve, group, and manage routes/middleware. Effectively a user must first create an `API` object like so: 16 | ```golang 17 | api := chimera.NewAPI() 18 | ``` 19 | Afterwards ths `API` object is used to add handlers, middleware or sub-`API`s (via `Group()` and `Mount()`). 20 | Sub-`API`s allow routes to be isolated into groups and maintain separate middleware with the following rules: 21 | - the parent `API` has its middleware evaluated first (including grandparents and so on) 22 | - middleware in the sub-`API` wont be called for routes not directly attached to the sub-`API` object 23 | - calls to `Group()` with the same base path will return the same sub-`API` 24 | - calls to `Mount()` with the same base path will overwrite the existing sub-`API` 25 | - if a base path doesn't match an immediate child `API` base path it will lead to route collision 26 | 27 | An example of how to use sub-`API`s is: 28 | ```golang 29 | api := chimera.NewAPI() 30 | 31 | foo := chimera.NewAPI() 32 | chimera.Get(foo, "/bar", func(req *chimera.Request) (*chimera.Response, error) {}) 33 | api.Mount("/foo", foo) // now /bar is actually /foo/bar 34 | 35 | baz := foo.Group("/baz") // technically /foo/baz 36 | // this route is technically /foo/baz/qux 37 | chimera.Get(test, "/qux", func(req *chimera.Request) (*chimera.Response, error) {}) 38 | ``` 39 | 40 | ## Handlers 41 | `API` handlers are affectively functions of the form 42 | ```golang 43 | func[ReqPtr RequestReaderPtr[Req], Req any, RespPtr ResponseWriterPtr[Resp], Resp any](ReqPtr) (RespPtr, error) 44 | ``` 45 | While this may seem confusing at first glance this is effectively just a generic function that: 46 | - accepts a pointer to a type that implements `RequestReader` 47 | - returns a pointer to a type that implements `ResponseWriter` (and error) 48 | 49 | The simplest example being: 50 | ```golang 51 | func(req *chimera.Request) (*chimera.Response, error) {} 52 | ``` 53 | 54 | ## Routes 55 | Handler functions are then bound to `HTTP` methods and paths using the following route functions: 56 | - `Get(api *API, path string, handler HandlerFunc) Route` 57 | - `Post(api *API, path string, handler HandlerFunc) Route` 58 | - `Put(api *API, path string, handler HandlerFunc) Route` 59 | - `Patch(api *API, path string, handler HandlerFunc) Route` 60 | - `Delete(api *API, path string, handler HandlerFunc) Route` 61 | - `Options(api *API, path string, handler HandlerFunc) Route` 62 | 63 | An example of adding a route is: 64 | ```golang 65 | api := chimera.NewAPI() 66 | chimera.Post(api, "/test/{path}", func(req *chimera.Request) (*chimera.Response, error) { 67 | return &chimera.Response{ 68 | Body: []byte("hello world"), 69 | StatusCode: 200, 70 | }, nil 71 | }) 72 | ``` 73 | 74 | 75 | All route functions in `chimera` return a `Route` object which contains its `OpenAPI` operation spec which is automatically created based on the parameters but can be edited/managed using the following helper methods: 76 | - `OpenAPIOperationSpec() *Operation`: returns the raw `Operation` to be edited directly 77 | - `WithResponseCode(code int) Route`: updates the default response code inline and returns the route 78 | - `WithResponses(resp Responses) Route`: merges the `Responses` object into the existing one and returns the route 79 | - `WithRequest(req RequestSpec) Route`: merges the `Request` object into the existing one and returns the route 80 | - `WithOperation(op Operation) Route`: merges the `Operation` object into the existing one and returns the route 81 | - `UsingResponses(resp Responses) Route`: replaces the existing `Responses` object with this one one and returns the route 82 | - `UsingRequest(req RequestSpec) Route`: replaces the existing `Request` object with this one one and returns the route 83 | - `UsingOperation(op Operation) Route`: replaces the existing `Operation` object with this one one and returns the route 84 | - `Internalize() Route`: marks the `Route` as "internal", meaning that it wont show up in the `OpenAPI` spec 85 | 86 | Most of these functions were designed to be daisy-chained on route creation like so: 87 | ```golang 88 | chimera.Post(api, "/test/{path}", func(req *chimera.Request) (*chimera.Response, error) { 89 | }).WithResponseCode( 90 | 418, // now this route will return a 418 by default. having these defaults is important for enforcing the spec 91 | ).WithOperation(chimera.Operation{ // now the docs will be more verbose 92 | Tags: []string{"tag1"}, 93 | Summary: "this is a route", 94 | }) 95 | ``` 96 | 97 | ## Standard lib support 98 | `chimera` has a function that attempts to wrap/convert a generic standard library handler: 99 | ```golang 100 | func HTTPHandler(handler http.HandlerFunc) chimera.HandlerFunc[*chimera.Request, chimera.Request, *chimera.Response, chimera.Response] 101 | ``` 102 | effectively the output of this function can be passed directly to a routing function but with the following caveats: 103 | 1. The OpenAPI spec for the route is very empty by default 104 | 2. The response body is passed to middleware in-memory so this should not be used for large response bodies 105 | 3. The response is still lazy so there are no errors ever from write which may be misleading -------------------------------------------------------------------------------- /form.go: -------------------------------------------------------------------------------- 1 | package chimera 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "reflect" 7 | "strings" 8 | 9 | "github.com/go-playground/form/v4" 10 | "github.com/invopop/jsonschema" 11 | ) 12 | 13 | var ( 14 | formBodyDecoder = form.NewDecoder() 15 | ) 16 | 17 | // FormRequest[Body, Params any] is a request type that decodes request bodies to a 18 | // user-defined struct for the Body and Params 19 | type FormRequest[Body, Params any] struct { 20 | request *http.Request 21 | Body Body 22 | Params Params 23 | } 24 | 25 | // Context returns the context that was part of the original http.Request 26 | func (r *FormRequest[Body, Params]) Context() context.Context { 27 | if r.request != nil { 28 | return r.request.Context() 29 | } 30 | return nil 31 | } 32 | 33 | // ReadRequest reads the body of an http request and assigns it to the Body field using 34 | // http.Request.ParseForm and the "go-playground/form" package. 35 | // This function also reads the parameters using UnmarshalParams and assigns it to the Params field. 36 | // NOTE: the body of the request is closed after this function is run. 37 | func (r *FormRequest[Body, Params]) ReadRequest(req *http.Request) error { 38 | defer req.Body.Close() 39 | err := req.ParseForm() 40 | if err != nil { 41 | return err 42 | } 43 | 44 | r.Body = *new(Body) 45 | if _, ok := any(r.Body).(Nil); !ok { 46 | err = formBodyDecoder.Decode(&r.Body, req.PostForm) 47 | if err != nil { 48 | return err 49 | } 50 | } 51 | 52 | r.Params = *new(Params) 53 | if _, ok := any(r.Params).(Nil); !ok { 54 | err = UnmarshalParams(req, &r.Params) 55 | if err != nil { 56 | return err 57 | } 58 | } 59 | 60 | r.request = req 61 | return nil 62 | } 63 | 64 | // flattenFormSchemas is kind of a jank way to convert jsonschema.Schema objects to be of a "form" 65 | // style using patternProperties to represent arrays/object paths 66 | func flattenFormSchemas(schema *jsonschema.Schema, properties map[string]*jsonschema.Schema, refs jsonschema.Definitions, prefix string) { 67 | if schema.Ref != "" && len(refs) > 0 { 68 | name := strings.Split(schema.Ref, "/") 69 | schema = refs[name[len(name)-1]] 70 | if schema != nil { 71 | delete(refs, name[len(name)-1]) 72 | } else { 73 | properties["^"+prefix+".*$"] = &jsonschema.Schema{} 74 | return 75 | } 76 | } 77 | 78 | switch schema.Type { 79 | case "object": 80 | if prefix != "" { 81 | prefix += "." 82 | } 83 | for p := schema.Properties.Oldest(); p != nil; p = p.Next() { 84 | flattenFormSchemas(p.Value, properties, refs, prefix+p.Key) 85 | } 86 | case "array": 87 | flattenFormSchemas(schema.Items, properties, refs, prefix+"\\[\\d+\\]") 88 | default: 89 | properties["^"+prefix+"$"] = schema 90 | } 91 | } 92 | 93 | // OpenAPIRequestSpec returns the Request definition of a FormRequest 94 | // It attempts to utilize patternProperties to try to define the body schema 95 | // i.e. objects/arrays use dotted/bracketed paths X.Y.Z[i] 96 | func (r *FormRequest[Body, Params]) OpenAPIRequestSpec() RequestSpec { 97 | bType := reflect.TypeOf(new(Body)) 98 | for ; bType.Kind() == reflect.Pointer; bType = bType.Elem() { 99 | } 100 | 101 | schema := RequestSpec{} 102 | if bType != reflect.TypeOf(Nil{}) { 103 | s := (&jsonschema.Reflector{FieldNameTag: "form"}).Reflect(new(Body)) 104 | // s.ID = jsonschema.ID(bType.PkgPath() + "_" + bType.Name()) 105 | if s.PatternProperties == nil { 106 | s.PatternProperties = make(map[string]*jsonschema.Schema) 107 | } 108 | sType := s.Type 109 | if s.Ref != "" && len(s.Definitions) > 0 { 110 | name := strings.Split(s.Ref, "/") 111 | sType = s.Definitions[name[len(name)-1]].Type 112 | } 113 | flattenFormSchemas(s, s.PatternProperties, s.Definitions, "") 114 | s.Type = sType 115 | s.Ref = "" 116 | 117 | schema.RequestBody = &RequestBody{ 118 | Content: map[string]MediaType{ 119 | "application/x-www-form-urlencoded ": { 120 | Schema: s, 121 | }, 122 | }, 123 | Required: reflect.TypeOf(*new(Body)).Kind() != reflect.Pointer, 124 | } 125 | } 126 | 127 | pType := reflect.TypeOf(new(Params)) 128 | for ; pType.Kind() == reflect.Pointer; pType = pType.Elem() { 129 | } 130 | if pType != reflect.TypeOf(Nil{}) { 131 | schema.Parameters = CacheRequestParamsType(pType) 132 | } 133 | // flatten form schemas 134 | return schema 135 | } 136 | -------------------------------------------------------------------------------- /form_test.go: -------------------------------------------------------------------------------- 1 | package chimera_test 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "net/url" 7 | "strings" 8 | "testing" 9 | 10 | "github.com/matt1484/chimera" 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | func TestAPIFormRequestReading(t *testing.T) { 15 | type SubStruct struct { 16 | Float float64 `form:"float"` 17 | } 18 | type Struct struct { 19 | Str string `form:"str"` 20 | Int int `form:"int"` 21 | Array []string `form:"array"` 22 | Sub SubStruct `form:"sub"` 23 | } 24 | body := Struct{ 25 | Str: "test", 26 | Int: 12345, 27 | Array: []string{"string1", "string2"}, 28 | Sub: SubStruct{ 29 | Float: -1.0, 30 | }, 31 | } 32 | form := url.Values{ 33 | "str": []string{"test"}, 34 | "int": []string{"12345"}, 35 | "array[0]": []string{"string1"}, 36 | "array[1]": []string{"string2"}, 37 | "sub.float": []string{"-1.0"}, 38 | } 39 | 40 | api := chimera.NewAPI() 41 | primPath := addRequestTestHandler(t, api, http.MethodPost, testValidSimplePath+testValidLabelPath+testValidMatrixPath, &chimera.FormRequest[Struct, TestPrimitivePathParams]{Body: body, Params: testPrimitivePathParams}) 42 | server := httptest.NewServer(api) 43 | resp, err := http.PostForm(server.URL+testValidPrimitiveSimplePathValues+testValidPrimitiveLabelPathValues+testValidPrimitiveMatrixPathValues, form) 44 | server.Close() 45 | assert.NoError(t, err) 46 | assert.Equal(t, resp.StatusCode, 201) 47 | assert.Equal(t, (*primPath).Params, testPrimitivePathParams) 48 | assert.Equal(t, (*primPath).Body, body) 49 | 50 | api = chimera.NewAPI() 51 | complexPath := addRequestTestHandler(t, api, http.MethodPost, testValidSimplePath+testValidLabelPath+testValidMatrixPath, &chimera.FormRequest[Struct, TestComplexPathParams]{Body: body, Params: testComplexPathParams}) 52 | server = httptest.NewServer(api) 53 | resp, err = http.PostForm(server.URL+testValidComplexSimplePathValues+testValidComplexLabelPathValues+testValidComplexMatrixPathValues, form) 54 | server.Close() 55 | assert.NoError(t, err) 56 | assert.Equal(t, resp.StatusCode, 201) 57 | assert.Equal(t, (*complexPath).Params, testComplexPathParams) 58 | assert.Equal(t, (*complexPath).Body, body) 59 | 60 | api = chimera.NewAPI() 61 | primHeader := addRequestTestHandler(t, api, http.MethodPost, "/headertest", &chimera.FormRequest[Struct, TestPrimitiveHeaderParams]{Body: body, Params: testPrimitiveHeaderParams}) 62 | server = httptest.NewServer(api) 63 | req, err := http.NewRequest(http.MethodPost, server.URL+"/headertest", strings.NewReader(form.Encode())) 64 | assert.NoError(t, err) 65 | req.Header.Add("content-type", "application/x-www-form-urlencoded") 66 | for k, v := range testValidSimplePrimitiveHeaderValues { 67 | req.Header.Set(k, v[0]) 68 | } 69 | resp, err = http.DefaultClient.Do(req) 70 | server.Close() 71 | assert.NoError(t, err) 72 | assert.Equal(t, resp.StatusCode, 201) 73 | assert.Equal(t, (*primHeader).Params, testPrimitiveHeaderParams) 74 | assert.Equal(t, (*primHeader).Body, body) 75 | 76 | api = chimera.NewAPI() 77 | complexHeader := addRequestTestHandler(t, api, http.MethodPost, "/headertest", &chimera.FormRequest[Struct, TestComplexHeaderParams]{Body: body, Params: testComplexHeaderParams}) 78 | server = httptest.NewServer(api) 79 | req, err = http.NewRequest(http.MethodPost, server.URL+"/headertest", strings.NewReader(form.Encode())) 80 | assert.NoError(t, err) 81 | for k, v := range testValidSimpleComplexHeaderValues { 82 | req.Header.Set(k, v[0]) 83 | } 84 | req.Header.Add("content-type", "application/x-www-form-urlencoded") 85 | resp, err = http.DefaultClient.Do(req) 86 | server.Close() 87 | assert.NoError(t, err) 88 | assert.Equal(t, resp.StatusCode, 201) 89 | assert.Equal(t, (*complexHeader).Params, testComplexHeaderParams) 90 | assert.Equal(t, (*complexHeader).Body, body) 91 | 92 | api = chimera.NewAPI() 93 | primCookie := addRequestTestHandler(t, api, http.MethodPost, "/cookietest", &chimera.FormRequest[Struct, TestPrimitiveCookieParams]{Body: body, Params: testPrimitiveCookieParams}) 94 | server = httptest.NewServer(api) 95 | req, err = http.NewRequest(http.MethodPost, server.URL+"/cookietest", strings.NewReader(form.Encode())) 96 | assert.NoError(t, err) 97 | for _, c := range testValidFormPrimitiveCookieValues { 98 | req.AddCookie(&c) 99 | } 100 | req.Header.Add("content-type", "application/x-www-form-urlencoded") 101 | resp, err = http.DefaultClient.Do(req) 102 | server.Close() 103 | assert.NoError(t, err) 104 | assert.Equal(t, resp.StatusCode, 201) 105 | assert.Equal(t, (*primCookie).Params, testPrimitiveCookieParams) 106 | assert.Equal(t, (*primCookie).Body, body) 107 | 108 | api = chimera.NewAPI() 109 | complexCookie := addRequestTestHandler(t, api, http.MethodPost, "/cookietest", &chimera.FormRequest[Struct, TestComplexCookieParams]{Body: body, Params: testComplexCookieParams}) 110 | server = httptest.NewServer(api) 111 | req, err = http.NewRequest(http.MethodPost, server.URL+"/cookietest", strings.NewReader(form.Encode())) 112 | assert.NoError(t, err) 113 | for _, c := range testValidFormComplexCookieValues { 114 | req.AddCookie(&c) 115 | } 116 | req.Header.Add("content-type", "application/x-www-form-urlencoded") 117 | resp, err = http.DefaultClient.Do(req) 118 | server.Close() 119 | assert.NoError(t, err) 120 | assert.Equal(t, resp.StatusCode, 201) 121 | assert.Equal(t, (*complexCookie).Params, testComplexCookieParams) 122 | assert.Equal(t, (*complexCookie).Body, body) 123 | 124 | api = chimera.NewAPI() 125 | primQuery := addRequestTestHandler(t, api, http.MethodPost, "/querytest", &chimera.FormRequest[Struct, TestPrimitiveQueryParams]{Body: body, Params: testPrimitiveQueryParams}) 126 | server = httptest.NewServer(api) 127 | req, err = http.NewRequest(http.MethodPost, server.URL+"/querytest?"+testValidFormPrimitiveQueryValues.Encode(), strings.NewReader(form.Encode())) 128 | assert.NoError(t, err) 129 | req.Header.Add("content-type", "application/x-www-form-urlencoded") 130 | resp, err = http.DefaultClient.Do(req) 131 | server.Close() 132 | assert.NoError(t, err) 133 | assert.Equal(t, resp.StatusCode, 201) 134 | assert.Equal(t, (*primQuery).Params, testPrimitiveQueryParams) 135 | assert.Equal(t, (*primQuery).Body, body) 136 | 137 | api = chimera.NewAPI() 138 | complexQuery := addRequestTestHandler(t, api, http.MethodPost, "/querytest", &chimera.FormRequest[Struct, TestComplexQueryParams]{Body: body, Params: testComplexQueryParams}) 139 | server = httptest.NewServer(api) 140 | req, err = http.NewRequest(http.MethodPost, server.URL+"/querytest?"+testValidFormComplexQueryValues.Encode(), strings.NewReader(form.Encode())) 141 | assert.NoError(t, err) 142 | req.Header.Add("content-type", "application/x-www-form-urlencoded") 143 | resp, err = http.DefaultClient.Do(req) 144 | server.Close() 145 | assert.NoError(t, err) 146 | assert.Equal(t, resp.StatusCode, 201) 147 | assert.Equal(t, (*complexQuery).Params, testComplexQueryParams) 148 | assert.Equal(t, (*complexQuery).Body, body) 149 | } 150 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/matt1484/chimera 2 | 3 | go 1.19 4 | 5 | retract ( 6 | [v0.0.0, v0.0.5] // pre-release versions 7 | v0.1.0 // middleware bug 8 | ) 9 | 10 | require ( 11 | github.com/go-chi/chi/v5 v5.0.8 12 | github.com/go-playground/form/v4 v4.2.1 13 | github.com/invopop/jsonschema v0.12.0 14 | github.com/matt1484/spectagular v1.0.4 15 | github.com/stretchr/testify v1.8.1 16 | ) 17 | 18 | require ( 19 | github.com/bahlo/generic-list-go v0.2.0 // indirect 20 | github.com/buger/jsonparser v1.1.1 // indirect 21 | github.com/davecgh/go-spew v1.1.1 // indirect 22 | github.com/kr/text v0.2.0 // indirect 23 | github.com/mailru/easyjson v0.7.7 // indirect 24 | github.com/pmezard/go-difflib v1.0.0 // indirect 25 | github.com/swaggest/swgui v1.8.0 // indirect 26 | github.com/vearutop/statigz v1.4.0 // indirect 27 | github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect 28 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect 29 | gopkg.in/yaml.v3 v3.0.1 // indirect 30 | ) 31 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= 2 | github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= 3 | github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= 4 | github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= 5 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 6 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 7 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 8 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 9 | github.com/go-chi/chi/v5 v5.0.8 h1:lD+NLqFcAi1ovnVZpsnObHGW4xb4J8lNmoYVfECH1Y0= 10 | github.com/go-chi/chi/v5 v5.0.8/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= 11 | github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A= 12 | github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= 13 | github.com/go-playground/form/v4 v4.2.1 h1:HjdRDKO0fftVMU5epjPW2SOREcZ6/wLUzEobqUGJuPw= 14 | github.com/go-playground/form/v4 v4.2.1/go.mod h1:q1a2BY+AQUUzhl6xA/6hBetay6dEIhMHjgvJiGo6K7U= 15 | github.com/invopop/jsonschema v0.12.0 h1:6ovsNSuvn9wEQVOyc72aycBMVQFKz7cPdMJn10CvzRI= 16 | github.com/invopop/jsonschema v0.12.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= 17 | github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= 18 | github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= 19 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 20 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 21 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 22 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 23 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 24 | github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= 25 | github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= 26 | github.com/matt1484/spectagular v1.0.4 h1:Ax6Zrike187Y4peJpZgwdmKEaX18eTAThkAQzyXEis8= 27 | github.com/matt1484/spectagular v1.0.4/go.mod h1:iIFQ90CIEsWcKFBjQ1d4wLNY94lAhk3A4czeH/BXVmU= 28 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 29 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 30 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 31 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 32 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 33 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 34 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 35 | github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= 36 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 37 | github.com/swaggest/swgui v1.8.0 h1:dPu8TsYIOraaObAkyNdoiLI8mu7nOqQ6SU7HOv254rM= 38 | github.com/swaggest/swgui v1.8.0/go.mod h1:YBaAVAwS3ndfvdtW8A4yWDJpge+W57y+8kW+f/DqZtU= 39 | github.com/vearutop/statigz v1.4.0 h1:RQL0KG3j/uyA/PFpHeZ/L6l2ta920/MxlOAIGEOuwmU= 40 | github.com/vearutop/statigz v1.4.0/go.mod h1:LYTolBLiz9oJISwiVKnOQoIwhO1LWX1A7OECawGS8XE= 41 | github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= 42 | github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= 43 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 44 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 45 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 46 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 47 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 48 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 49 | -------------------------------------------------------------------------------- /header.go: -------------------------------------------------------------------------------- 1 | package chimera 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "reflect" 7 | "strings" 8 | ) 9 | 10 | var ( 11 | headerParamUnmarshalerType = reflect.TypeOf((*HeaderParamUnmarshaler)(nil)).Elem() 12 | headerParamMarshalerType = reflect.TypeOf((*HeaderParamMarshaler)(nil)).Elem() 13 | ) 14 | 15 | // HeaderParamUnmarshaler is an interface that supports converting a header value ([]string) to a user-defined type 16 | type HeaderParamUnmarshaler interface { 17 | UnmarshalHeaderParam(value []string, info ParamStructTag) error 18 | } 19 | 20 | // HeaderParamMarshaler is an interface that supports converting a user-defined type to a header value ([]string) 21 | type HeaderParamMarshaler interface { 22 | MarshalHeaderParam(ParamStructTag) (http.Header, error) 23 | } 24 | 25 | // unmarshalHeaderParam converts a []string to a value using the options in tag 26 | func unmarshalHeaderParam(param []string, tag *ParamStructTag, addr reflect.Value) error { 27 | addr = fixPointer(addr) 28 | if tag.schemaType == interfaceType { 29 | return addr.Interface().(HeaderParamUnmarshaler).UnmarshalHeaderParam(param, *tag) 30 | } 31 | if tag.Required && len(param) == 0 { 32 | return NewRequiredParamError("header", tag.Name) 33 | } 34 | if len(param) == 0 { 35 | return nil 36 | } 37 | return unmarshalStringParam(param[0], tag, addr) 38 | } 39 | 40 | // marshalHeaderParam converts a value to a http.Header using the options in tag 41 | func marshalHeaderParam(tag *ParamStructTag, addr reflect.Value) (http.Header, error) { 42 | addr = fixPointer(addr) 43 | switch tag.schemaType { 44 | case interfaceType: 45 | return addr.Interface().(HeaderParamMarshaler).MarshalHeaderParam(*tag) 46 | case primitiveType: 47 | return http.Header{ 48 | tag.Name: []string{marshalPrimitiveToString(addr)}, 49 | }, nil 50 | case sliceType: 51 | return http.Header{ 52 | tag.Name: []string{marshalSliceToString(addr)}, 53 | }, nil 54 | case structType: 55 | return http.Header{ 56 | tag.Name: []string{marshalStructToString(addr, tag)}, 57 | }, nil 58 | } 59 | return nil, nil 60 | } 61 | 62 | // marshalPrimitiveToString converts a value to a string 63 | func marshalPrimitiveToString(addr reflect.Value) string { 64 | return fmt.Sprint(addr.Elem().Interface()) 65 | } 66 | 67 | // marshalSliceToString converts a slice/array value to a string 68 | func marshalSliceToString(addr reflect.Value) string { 69 | value := "" 70 | addr = addr.Elem() 71 | for i := 0; i < addr.Len(); i++ { 72 | if i != 0 { 73 | value += "," 74 | } 75 | value += marshalPrimitiveToString(addr.Index(i).Addr()) 76 | } 77 | return value 78 | } 79 | 80 | // marshalStructToString converts a struct value to a string 81 | func marshalStructToString(addr reflect.Value, tag *ParamStructTag) string { 82 | values := make([]string, 0) 83 | for name, prop := range tag.propMap { 84 | f := addr.Elem().Field(prop.fieldIndex) 85 | if f.Type().Kind() == reflect.Pointer { 86 | f = fixPointer(f) 87 | } 88 | v := name + tag.valueDelim 89 | v += marshalPrimitiveToString(f.Addr()) 90 | values = append(values, v) 91 | } 92 | return strings.Join(values, tag.delim) 93 | } 94 | -------------------------------------------------------------------------------- /header_test.go: -------------------------------------------------------------------------------- 1 | package chimera_test 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/matt1484/chimera" 7 | ) 8 | 9 | type TestPrimitiveHeaderParams struct { 10 | SimpleStr string `param:"simpstr,in=header,style=simple"` 11 | SimpleU8 uint8 `param:"simpuint,in=header,style=simple"` 12 | SimpleU16 uint16 `param:"simpuint,in=header,style=simple"` 13 | SimpleU32 uint32 `param:"simpuint,in=header,style=simple"` 14 | SimpleU64 uint64 `param:"simpuint,in=header,style=simple"` 15 | SimpleUint uint `param:"simpuint,in=header,style=simple"` 16 | SimpleI8 int8 `param:"simpint,in=header,style=simple"` 17 | SimpleI16 int16 `param:"simpint,in=header,style=simple"` 18 | SimpleI32 int32 `param:"simpint,in=header,style=simple"` 19 | SimpleI64 int64 `param:"simpint,in=header,style=simple"` 20 | SimpleInt int `param:"simpint,in=header,style=simple"` 21 | SimpleF32 float32 `param:"simpfloat,in=header,style=simple"` 22 | SimpleF64 float64 `param:"simpfloat,in=header,style=simple"` 23 | 24 | SimpleExplodeStr string `param:"simpexstr,in=header,explode,style=simple"` 25 | SimpleExplodeU8 uint8 `param:"simpexuint,in=header,explode,style=simple"` 26 | SimpleExplodeU16 uint16 `param:"simpexuint,in=header,explode,style=simple"` 27 | SimpleExplodeU32 uint32 `param:"simpexuint,in=header,explode,style=simple"` 28 | SimpleExplodeU64 uint64 `param:"simpexuint,in=header,explode,style=simple"` 29 | SimpleExplodeUint uint `param:"simpexuint,in=header,explode,style=simple"` 30 | SimpleExplodeI8 int8 `param:"simpexint,in=header,explode,style=simple"` 31 | SimpleExplodeI16 int16 `param:"simpexint,in=header,explode,style=simple"` 32 | SimpleExplodeI32 int32 `param:"simpexint,in=header,explode,style=simple"` 33 | SimpleExplodeI64 int64 `param:"simpexint,in=header,explode,style=simple"` 34 | SimpleExplodeInt int `param:"simpexint,in=header,explode,style=simple"` 35 | SimpleExplodeF32 float32 `param:"simpexfloat,in=header,explode,style=simple"` 36 | SimpleExplodeF64 float64 `param:"simpexfloat,in=header,explode,style=simple"` 37 | } 38 | 39 | type TestComplexHeaderParams struct { 40 | SimpleStr []string `param:"simpstr,in=header,style=simple"` 41 | SimpleU8 []uint8 `param:"simpuint,in=header,style=simple"` 42 | SimpleU16 []uint16 `param:"simpuint,in=header,style=simple"` 43 | SimpleU32 []uint32 `param:"simpuint,in=header,style=simple"` 44 | SimpleU64 []uint64 `param:"simpuint,in=header,style=simple"` 45 | SimpleUint []uint `param:"simpuint,in=header,style=simple"` 46 | SimpleI8 []int8 `param:"simpint,in=header,style=simple"` 47 | SimpleI16 []int16 `param:"simpint,in=header,style=simple"` 48 | SimpleI32 []int32 `param:"simpint,in=header,style=simple"` 49 | SimpleI64 []int64 `param:"simpint,in=header,style=simple"` 50 | SimpleInt []int `param:"simpint,in=header,style=simple"` 51 | SimpleF32 []float32 `param:"simpfloat,in=header,style=simple"` 52 | SimpleF64 []float64 `param:"simpfloat,in=header,style=simple"` 53 | SimpleStruct TestStructParams `param:"simpstruct,in=header,style=simple"` 54 | 55 | SimpleExplodeStr []string `param:"simpexstr,in=header,explode,style=simple"` 56 | SimpleExplodeU8 []uint8 `param:"simpexuint,in=header,explode,style=simple"` 57 | SimpleExplodeU16 []uint16 `param:"simpexuint,in=header,explode,style=simple"` 58 | SimpleExplodeU32 []uint32 `param:"simpexuint,in=header,explode,style=simple"` 59 | SimpleExplodeU64 []uint64 `param:"simpexuint,in=header,explode,style=simple"` 60 | SimpleExplodeUint []uint `param:"simpexuint,in=header,explode,style=simple"` 61 | SimpleExplodeI8 []int8 `param:"simpexint,in=header,explode,style=simple"` 62 | SimpleExplodeI16 []int16 `param:"simpexint,in=header,explode,style=simple"` 63 | SimpleExplodeI32 []int32 `param:"simpexint,in=header,explode,style=simple"` 64 | SimpleExplodeI64 []int64 `param:"simpexint,in=header,explode,style=simple"` 65 | SimpleExplodeInt []int `param:"simpexint,in=header,explode,style=simple"` 66 | SimpleExplodeF32 []float32 `param:"simpexfloat,in=header,explode,style=simple"` 67 | SimpleExplodeF64 []float64 `param:"simpexfloat,in=header,explode,style=simple"` 68 | SimpleExplodeStruct TestStructParams `param:"simpexstruct,in=header,explode,style=simple"` 69 | } 70 | 71 | func (c *TestValidCustomParam) UnmarshalHeaderParam(val string, t chimera.ParamStructTag) error { 72 | *c = "test" 73 | return nil 74 | } 75 | 76 | var ( 77 | testValidSimplePrimitiveHeaderValues = http.Header{ 78 | "simpstr": []string{"ateststring"}, 79 | "simpuint": []string{"123"}, 80 | "simpint": []string{"-123"}, 81 | "simpfloat": []string{"123.45"}, 82 | "simpexstr": []string{"test..."}, 83 | "simpexuint": []string{"255"}, 84 | "simpexint": []string{"0"}, 85 | "simpexfloat": []string{"-123.45"}, 86 | } 87 | testValidSimpleComplexHeaderValues = http.Header{ 88 | "simpstr": []string{"string1,string2"}, 89 | "simpuint": []string{"0,123"}, 90 | "simpint": []string{"123,-123"}, 91 | "simpfloat": []string{"123.45,0.0"}, 92 | "simpstruct": []string{"stringprop,propstring,intprop,123"}, 93 | "simpexstr": []string{"test..."}, 94 | "simpexuint": []string{"255"}, 95 | "simpexint": []string{"0"}, 96 | "simpexfloat": []string{"-123.45"}, 97 | "simpexstruct": []string{"stringprop=propstring,intprop=123"}, 98 | } 99 | ) 100 | 101 | var ( 102 | testPrimitiveHeaderParams = TestPrimitiveHeaderParams{ 103 | SimpleStr: "ateststring", 104 | SimpleU8: 123, 105 | SimpleU16: 123, 106 | SimpleU32: 123, 107 | SimpleU64: 123, 108 | SimpleUint: 123, 109 | SimpleI8: -123, 110 | SimpleI16: -123, 111 | SimpleI32: -123, 112 | SimpleI64: -123, 113 | SimpleInt: -123, 114 | SimpleF32: 123.45, 115 | SimpleF64: 123.45, 116 | 117 | SimpleExplodeStr: "test...", 118 | SimpleExplodeU8: 255, 119 | SimpleExplodeU16: 255, 120 | SimpleExplodeU32: 255, 121 | SimpleExplodeU64: 255, 122 | SimpleExplodeUint: 255, 123 | SimpleExplodeI8: 0, 124 | SimpleExplodeI16: 0, 125 | SimpleExplodeI32: 0, 126 | SimpleExplodeI64: 0, 127 | SimpleExplodeInt: 0, 128 | SimpleExplodeF32: -123.45, 129 | SimpleExplodeF64: -123.45, 130 | } 131 | 132 | testComplexHeaderParams = TestComplexHeaderParams{ 133 | SimpleStr: []string{"string1", "string2"}, 134 | SimpleU8: []uint8{0, 123}, 135 | SimpleU16: []uint16{0, 123}, 136 | SimpleU32: []uint32{0, 123}, 137 | SimpleU64: []uint64{0, 123}, 138 | SimpleUint: []uint{0, 123}, 139 | SimpleI8: []int8{123, -123}, 140 | SimpleI16: []int16{123, -123}, 141 | SimpleI32: []int32{123, -123}, 142 | SimpleI64: []int64{123, -123}, 143 | SimpleInt: []int{123, -123}, 144 | SimpleF32: []float32{123.45, 0.0}, 145 | SimpleF64: []float64{123.45, 0.0}, 146 | SimpleStruct: TestStructParams{ 147 | StringProp: "propstring", 148 | IntProp: 123, 149 | }, 150 | 151 | SimpleExplodeStr: []string{"test..."}, 152 | SimpleExplodeU8: []uint8{255}, 153 | SimpleExplodeU16: []uint16{255}, 154 | SimpleExplodeU32: []uint32{255}, 155 | SimpleExplodeU64: []uint64{255}, 156 | SimpleExplodeUint: []uint{255}, 157 | SimpleExplodeI8: []int8{0}, 158 | SimpleExplodeI16: []int16{0}, 159 | SimpleExplodeI32: []int32{0}, 160 | SimpleExplodeI64: []int64{0}, 161 | SimpleExplodeInt: []int{0}, 162 | SimpleExplodeF32: []float32{-123.45}, 163 | SimpleExplodeF64: []float64{-123.45}, 164 | SimpleExplodeStruct: TestStructParams{ 165 | StringProp: "propstring", 166 | IntProp: 123, 167 | }, 168 | } 169 | ) 170 | -------------------------------------------------------------------------------- /json.go: -------------------------------------------------------------------------------- 1 | package chimera 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "io" 7 | "net/http" 8 | "reflect" 9 | "strings" 10 | 11 | "github.com/invopop/jsonschema" 12 | ) 13 | 14 | var ( 15 | _ RequestReader = new(JSONRequest[Nil, Nil]) 16 | _ ResponseWriter = new(JSONResponse[Nil, Nil]) 17 | _ RequestReader = new(JSON[Nil, Nil]) 18 | _ ResponseWriter = new(JSON[Nil, Nil]) 19 | ) 20 | 21 | // JSONRequest[Body, Params any] is a request type that decodes json request bodies to a 22 | // user-defined struct for the Body and Params 23 | type JSONRequest[Body, Params any] struct { 24 | request *http.Request 25 | Body Body 26 | Params Params 27 | } 28 | 29 | // Context returns the context that was part of the original http.Request 30 | func (r *JSONRequest[Body, Params]) Context() context.Context { 31 | if r.request != nil { 32 | return r.request.Context() 33 | } 34 | return nil 35 | } 36 | 37 | func readJSONRequest[Body, Params any](req *http.Request, body *Body, params *Params) error { 38 | defer req.Body.Close() 39 | b, err := io.ReadAll(req.Body) 40 | if err != nil { 41 | return err 42 | } 43 | 44 | if _, ok := any(body).(*Nil); !ok { 45 | err = json.Unmarshal(b, body) 46 | if err != nil { 47 | return err 48 | } 49 | } 50 | 51 | if _, ok := any(params).(*Nil); !ok { 52 | err = UnmarshalParams(req, params) 53 | if err != nil { 54 | return err 55 | } 56 | } 57 | return nil 58 | } 59 | 60 | // ReadRequest reads the body of an http request and assigns it to the Body field using json.Unmarshal 61 | // This function also reads the parameters using UnmarshalParams and assigns it to the Params field. 62 | // NOTE: the body of the request is closed after this function is run. 63 | func (r *JSONRequest[Body, Params]) ReadRequest(req *http.Request) error { 64 | r.request = req 65 | return readJSONRequest(req, &r.Body, &r.Params) 66 | } 67 | 68 | // standardizedSchemas basically tries to convert all jsonschema.Schema objects to be mapped 69 | // in defs and then replace them with $refs 70 | func standardizedSchemas(schema *jsonschema.Schema, defs map[string]jsonschema.Schema) { 71 | if schema == nil { 72 | return 73 | } 74 | schema.ID = "" 75 | schema.Version = "" 76 | standardizedSchemas(schema.AdditionalProperties, defs) 77 | standardizedSchemas(schema.Contains, defs) 78 | standardizedSchemas(schema.ContentSchema, defs) 79 | standardizedSchemas(schema.Else, defs) 80 | standardizedSchemas(schema.If, defs) 81 | standardizedSchemas(schema.Items, defs) 82 | standardizedSchemas(schema.Not, defs) 83 | standardizedSchemas(schema.PropertyNames, defs) 84 | standardizedSchemas(schema.Then, defs) 85 | for _, s := range schema.AllOf { 86 | standardizedSchemas(s, defs) 87 | } 88 | for _, s := range schema.AnyOf { 89 | standardizedSchemas(s, defs) 90 | } 91 | for _, s := range schema.OneOf { 92 | standardizedSchemas(s, defs) 93 | } 94 | for _, s := range schema.DependentSchemas { 95 | standardizedSchemas(s, defs) 96 | } 97 | for _, s := range schema.PatternProperties { 98 | standardizedSchemas(s, defs) 99 | } 100 | for k, s := range schema.Definitions { 101 | standardizedSchemas(s, defs) 102 | if _, ok := defs[k]; ok { 103 | split := strings.Split(s.ID.String(), "/") 104 | for i, p := range split { 105 | if i != len(split)-1 { 106 | k = p + "_" + k 107 | } 108 | } 109 | } 110 | defs[k] = *s 111 | } 112 | schema.Definitions = nil 113 | for p := schema.Properties.Oldest(); p != nil; p = p.Next() { 114 | standardizedSchemas(p.Value, defs) 115 | } 116 | if strings.HasPrefix(schema.Ref, "#/$defs/") { 117 | name, _ := strings.CutPrefix(schema.Ref, "#/$defs/") 118 | schema.Ref = "#/components/schemas/" + name 119 | } 120 | schema.ID = "" 121 | } 122 | 123 | func jsonRequestSpec[Body, Params any](schema *RequestSpec) { 124 | bType := reflect.TypeOf(new(Body)) 125 | for ; bType.Kind() == reflect.Pointer; bType = bType.Elem() { 126 | } 127 | 128 | if bType != reflect.TypeOf(Nil{}) { 129 | s := (&jsonschema.Reflector{ 130 | // ExpandedStruct: bType.Kind() == reflect.Struct, 131 | }).Reflect(new(Body)) 132 | schema.RequestBody = &RequestBody{ 133 | Content: map[string]MediaType{ 134 | "application/json": { 135 | Schema: s, 136 | }, 137 | }, 138 | Required: reflect.TypeOf(*new(Body)).Kind() != reflect.Pointer, 139 | } 140 | } 141 | 142 | pType := reflect.TypeOf(new(Params)) 143 | for ; pType.Kind() == reflect.Pointer; pType = pType.Elem() { 144 | } 145 | if pType != reflect.TypeOf(Nil{}) { 146 | schema.Parameters = CacheRequestParamsType(pType) 147 | } 148 | } 149 | 150 | func jsonResponsesSpec[Body, Params any](schema Responses) { 151 | bType := reflect.TypeOf(new(Body)) 152 | for ; bType.Kind() == reflect.Pointer; bType = bType.Elem() { 153 | } 154 | 155 | response := ResponseSpec{} 156 | if bType != reflect.TypeOf(Nil{}) { 157 | response.Content = map[string]MediaType{ 158 | "application/json": { 159 | Schema: (&jsonschema.Reflector{ 160 | // ExpandedStruct: bType.Kind() == reflect.Struct, 161 | // DoNotReference: true, 162 | }).Reflect(new(Body)), 163 | }, 164 | } 165 | } 166 | 167 | pType := reflect.TypeOf(*new(Params)) 168 | for ; pType.Kind() == reflect.Pointer; pType = pType.Elem() { 169 | } 170 | if pType != reflect.TypeOf(Nil{}) { 171 | response.Headers = make(map[string]Parameter) 172 | for _, param := range CacheResponseParamsType(pType) { 173 | response.Headers[param.Name] = Parameter{ 174 | Schema: param.Schema, 175 | Description: param.Description, 176 | Deprecated: param.Deprecated, 177 | AllowReserved: param.AllowReserved, 178 | AllowEmptyValue: param.AllowEmptyValue, 179 | Required: param.Required, 180 | Explode: param.Explode, 181 | Example: param.Example, 182 | Examples: param.Examples, 183 | } 184 | } 185 | } 186 | schema[""] = response 187 | } 188 | 189 | // OpenAPIRequestSpec returns the Request definition of a JSONRequest using "invopop/jsonschema" 190 | func (r *JSONRequest[Body, Params]) OpenAPIRequestSpec() RequestSpec { 191 | schema := RequestSpec{} 192 | jsonRequestSpec[Body, Params](&schema) 193 | return schema 194 | } 195 | 196 | // JSONResponse[Body, Params any] is a response type that converts 197 | // user-provided types to json and marshals params to headers 198 | type JSONResponse[Body, Params any] struct { 199 | Body Body 200 | Params Params 201 | } 202 | 203 | // WriteBody writes the response body using json.Marshal 204 | func (r *JSONResponse[Body, Params]) WriteBody(write BodyWriteFunc) error { 205 | b, err := json.Marshal(r.Body) 206 | if err != nil { 207 | return err 208 | } 209 | _, err = write(b) 210 | return err 211 | } 212 | 213 | // OpenAPIResponsesSpec returns the Responses definition of a JSONResponse using "invopop/jsonschema" 214 | func (r *JSONResponse[Body, Params]) OpenAPIResponsesSpec() Responses { 215 | schema := make(Responses) 216 | jsonResponsesSpec[Body, Params](schema) 217 | return schema 218 | } 219 | 220 | // WriteHead writes header for this response object 221 | func (r *JSONResponse[Body, Params]) WriteHead(head *ResponseHead) error { 222 | head.Headers.Set("Content-Type", "application/json") 223 | h, err := MarshalParams(&r.Params) 224 | if err != nil { 225 | return err 226 | } 227 | for k, v := range h { 228 | for _, x := range v { 229 | head.Headers.Add(k, x) 230 | } 231 | } 232 | return nil 233 | } 234 | 235 | // NewJSONResponse creates a JSONResponse from body and params 236 | func NewJSONResponse[Body, Params any](body Body, params Params) *JSONResponse[Body, Params] { 237 | return &JSONResponse[Body, Params]{ 238 | Body: body, 239 | Params: params, 240 | } 241 | } 242 | 243 | // JSON[Body, Params] is a helper type that effectively works as both a JSONRequest[Body, Params] and JSONResponse[Body, Params] 244 | // This is mostly here for convenience 245 | type JSON[Body, Params any] struct { 246 | request *http.Request 247 | Body Body 248 | Params Params 249 | } 250 | 251 | // Context returns the context for this request 252 | // NOTE: this type can also be used for responses in which case Context() would be nil 253 | func (r *JSON[Body, Params]) Context() context.Context { 254 | if r.request != nil { 255 | return r.request.Context() 256 | } 257 | return nil 258 | } 259 | 260 | // ReadRequest reads the body of an http request and assigns it to the Body field using json.Unmarshal 261 | // This function also reads the parameters using UnmarshalParams and assigns it to the Params field. 262 | // NOTE: the body of the request is closed after this function is run. 263 | func (r *JSON[Body, Params]) ReadRequest(req *http.Request) error { 264 | r.request = req 265 | return readJSONRequest(req, &r.Body, &r.Params) 266 | } 267 | 268 | // OpenAPIRequestSpec returns the Request definition of a JSON request using "invopop/jsonschema" 269 | func (r *JSON[Body, Params]) OpenAPIRequestSpec() RequestSpec { 270 | schema := RequestSpec{} 271 | jsonRequestSpec[Body, Params](&schema) 272 | return schema 273 | } 274 | 275 | // WriteBody writes the response body 276 | func (r *JSON[Body, Params]) WriteBody(write BodyWriteFunc) error { 277 | b, err := json.Marshal(r.Body) 278 | if err != nil { 279 | return err 280 | } 281 | _, err = write(b) 282 | return err 283 | } 284 | 285 | // OpenAPIResponsesSpec returns the Responses definition of a JSON response using "invopop/jsonschema" 286 | func (r *JSON[Body, Params]) OpenAPIResponsesSpec() Responses { 287 | schema := make(Responses) 288 | jsonResponsesSpec[Body, Params](schema) 289 | return schema 290 | } 291 | 292 | // WriteHead writes header for this response object 293 | func (r *JSON[Body, Params]) WriteHead(head *ResponseHead) error { 294 | head.Headers.Set("Content-Type", "application/json") 295 | h, err := MarshalParams(&r.Params) 296 | if err != nil { 297 | return err 298 | } 299 | for k, v := range h { 300 | for _, x := range v { 301 | head.Headers.Add(k, x) 302 | } 303 | } 304 | return nil 305 | } 306 | -------------------------------------------------------------------------------- /json_test.go: -------------------------------------------------------------------------------- 1 | package chimera_test 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "io" 7 | "net/http" 8 | "net/http/httptest" 9 | "testing" 10 | 11 | "github.com/matt1484/chimera" 12 | "github.com/stretchr/testify/assert" 13 | ) 14 | 15 | func TestJSONRequestValid(t *testing.T) { 16 | type Struct struct { 17 | Str string `json:"str"` 18 | Int int `json:"int"` 19 | } 20 | body := Struct{ 21 | Str: "a test", 22 | Int: 12345, 23 | } 24 | b, err := json.Marshal(body) 25 | assert.NoError(t, err) 26 | 27 | api := chimera.NewAPI() 28 | primPath := addRequestTestHandler(t, api, http.MethodPost, testValidSimplePath+testValidLabelPath+testValidMatrixPath, &chimera.JSON[Struct, TestPrimitivePathParams]{Body: body, Params: testPrimitivePathParams}) 29 | server := httptest.NewServer(api) 30 | resp, err := http.Post(server.URL+testValidPrimitiveSimplePathValues+testValidPrimitiveLabelPathValues+testValidPrimitiveMatrixPathValues, "application/json", bytes.NewBuffer(b)) 31 | server.Close() 32 | assert.NoError(t, err) 33 | assert.Equal(t, resp.StatusCode, 201) 34 | assert.Equal(t, (*primPath).Params, testPrimitivePathParams) 35 | assert.Equal(t, (*primPath).Body, body) 36 | 37 | api = chimera.NewAPI() 38 | complexPath := addRequestTestHandler(t, api, http.MethodPost, testValidSimplePath+testValidLabelPath+testValidMatrixPath, &chimera.JSONRequest[Struct, TestComplexPathParams]{Body: body, Params: testComplexPathParams}) 39 | server = httptest.NewServer(api) 40 | resp, err = http.Post(server.URL+testValidComplexSimplePathValues+testValidComplexLabelPathValues+testValidComplexMatrixPathValues, "application/json", bytes.NewBuffer(b)) 41 | server.Close() 42 | assert.NoError(t, err) 43 | assert.Equal(t, resp.StatusCode, 201) 44 | assert.Equal(t, (*complexPath).Params, testComplexPathParams) 45 | assert.Equal(t, (*complexPath).Body, body) 46 | 47 | api = chimera.NewAPI() 48 | primHeader := addRequestTestHandler(t, api, http.MethodPost, "/headertest", &chimera.JSONRequest[Struct, TestPrimitiveHeaderParams]{Body: body, Params: testPrimitiveHeaderParams}) 49 | server = httptest.NewServer(api) 50 | req, err := http.NewRequest(http.MethodPost, server.URL+"/headertest", bytes.NewBuffer(b)) 51 | assert.NoError(t, err) 52 | for k, v := range testValidSimplePrimitiveHeaderValues { 53 | req.Header.Set(k, v[0]) 54 | } 55 | resp, err = http.DefaultClient.Do(req) 56 | server.Close() 57 | assert.NoError(t, err) 58 | assert.Equal(t, resp.StatusCode, 201) 59 | assert.Equal(t, (*primHeader).Params, testPrimitiveHeaderParams) 60 | assert.Equal(t, (*primHeader).Body, body) 61 | 62 | api = chimera.NewAPI() 63 | complexHeader := addRequestTestHandler(t, api, http.MethodPost, "/headertest", &chimera.JSONRequest[Struct, TestComplexHeaderParams]{Body: body, Params: testComplexHeaderParams}) 64 | server = httptest.NewServer(api) 65 | req, err = http.NewRequest(http.MethodPost, server.URL+"/headertest", bytes.NewBuffer(b)) 66 | assert.NoError(t, err) 67 | for k, v := range testValidSimpleComplexHeaderValues { 68 | req.Header.Set(k, v[0]) 69 | } 70 | resp, err = http.DefaultClient.Do(req) 71 | server.Close() 72 | assert.NoError(t, err) 73 | assert.Equal(t, resp.StatusCode, 201) 74 | assert.Equal(t, (*complexHeader).Params, testComplexHeaderParams) 75 | assert.Equal(t, (*complexHeader).Body, body) 76 | 77 | api = chimera.NewAPI() 78 | primCookie := addRequestTestHandler(t, api, http.MethodPost, "/cookietest", &chimera.JSONRequest[Struct, TestPrimitiveCookieParams]{Body: body, Params: testPrimitiveCookieParams}) 79 | server = httptest.NewServer(api) 80 | req, err = http.NewRequest(http.MethodPost, server.URL+"/cookietest", bytes.NewBuffer(b)) 81 | assert.NoError(t, err) 82 | for _, c := range testValidFormPrimitiveCookieValues { 83 | req.AddCookie(&c) 84 | } 85 | resp, err = http.DefaultClient.Do(req) 86 | server.Close() 87 | assert.NoError(t, err) 88 | assert.Equal(t, resp.StatusCode, 201) 89 | assert.Equal(t, (*primCookie).Params, testPrimitiveCookieParams) 90 | assert.Equal(t, (*primCookie).Body, body) 91 | 92 | api = chimera.NewAPI() 93 | complexCookie := addRequestTestHandler(t, api, http.MethodPost, "/cookietest", &chimera.JSONRequest[Struct, TestComplexCookieParams]{Body: body, Params: testComplexCookieParams}) 94 | server = httptest.NewServer(api) 95 | req, err = http.NewRequest(http.MethodPost, server.URL+"/cookietest", bytes.NewBuffer(b)) 96 | assert.NoError(t, err) 97 | for _, c := range testValidFormComplexCookieValues { 98 | req.AddCookie(&c) 99 | } 100 | resp, err = http.DefaultClient.Do(req) 101 | server.Close() 102 | assert.NoError(t, err) 103 | assert.Equal(t, resp.StatusCode, 201) 104 | assert.Equal(t, (*complexCookie).Params, testComplexCookieParams) 105 | assert.Equal(t, (*complexCookie).Body, body) 106 | 107 | api = chimera.NewAPI() 108 | primQuery := addRequestTestHandler(t, api, http.MethodPost, "/querytest", &chimera.JSONRequest[Struct, TestPrimitiveQueryParams]{Body: body, Params: testPrimitiveQueryParams}) 109 | server = httptest.NewServer(api) 110 | req, err = http.NewRequest(http.MethodPost, server.URL+"/querytest?"+testValidFormPrimitiveQueryValues.Encode(), bytes.NewBuffer(b)) 111 | assert.NoError(t, err) 112 | resp, err = http.DefaultClient.Do(req) 113 | server.Close() 114 | assert.NoError(t, err) 115 | assert.Equal(t, resp.StatusCode, 201) 116 | assert.Equal(t, (*primQuery).Params, testPrimitiveQueryParams) 117 | assert.Equal(t, (*primQuery).Body, body) 118 | 119 | api = chimera.NewAPI() 120 | complexQuery := addRequestTestHandler(t, api, http.MethodPost, "/querytest", &chimera.JSONRequest[Struct, TestComplexQueryParams]{Body: body, Params: testComplexQueryParams}) 121 | server = httptest.NewServer(api) 122 | req, err = http.NewRequest(http.MethodPost, server.URL+"/querytest?"+testValidFormComplexQueryValues.Encode(), bytes.NewBuffer(b)) 123 | assert.NoError(t, err) 124 | resp, err = http.DefaultClient.Do(req) 125 | server.Close() 126 | assert.NoError(t, err) 127 | assert.Equal(t, resp.StatusCode, 201) 128 | assert.Equal(t, (*complexQuery).Params, testComplexQueryParams) 129 | assert.Equal(t, (*complexQuery).Body, body) 130 | } 131 | 132 | func TestJSONResponseValid(t *testing.T) { 133 | type Struct struct { 134 | Str string `json:"str"` 135 | Int int `json:"int"` 136 | } 137 | body := Struct{ 138 | Str: "a test", 139 | Int: 12345, 140 | } 141 | api := chimera.NewAPI() 142 | addResponseTestHandler(t, api, http.MethodGet, "/headertest", &chimera.JSON[Struct, TestPrimitiveHeaderParams]{Body: body, Params: testPrimitiveHeaderParams}) 143 | server := httptest.NewServer(api) 144 | resp, err := http.Get(server.URL + "/headertest") 145 | server.Close() 146 | assert.NoError(t, err) 147 | assert.Equal(t, resp.StatusCode, 200) 148 | for k, v := range testValidSimplePrimitiveHeaderValues { 149 | assert.Equal(t, resp.Header.Values(k)[0], v[0]) 150 | } 151 | server.Close() 152 | b, err := io.ReadAll(resp.Body) 153 | assert.NoError(t, err) 154 | assert.Equal(t, `{"str":"a test","int":12345}`, string(b)) 155 | 156 | api = chimera.NewAPI() 157 | addResponseTestHandler(t, api, http.MethodGet, "/cookietest", &chimera.JSONResponse[Struct, TestPrimitiveCookieParams]{Body: body, Params: testPrimitiveCookieParams}) 158 | server = httptest.NewServer(api) 159 | resp, err = http.Get(server.URL + "/cookietest") 160 | server.Close() 161 | assert.NoError(t, err) 162 | assert.Equal(t, resp.StatusCode, 200) 163 | cookie := make(map[string]string) 164 | for _, c := range testValidFormPrimitiveCookieValues { 165 | cookie[c.Name] = c.Value 166 | } 167 | found := make(map[string]struct{}) 168 | for _, c := range resp.Cookies() { 169 | assert.Equal(t, cookie[c.Name], c.Value) 170 | found[c.Name] = struct{}{} 171 | } 172 | assert.Equal(t, len(cookie), len(found)) 173 | assert.Equal(t, `{"str":"a test","int":12345}`, string(b)) 174 | } 175 | -------------------------------------------------------------------------------- /middleware.go: -------------------------------------------------------------------------------- 1 | package chimera 2 | 3 | import ( 4 | "net/http" 5 | ) 6 | 7 | // NextFunc is the format for allowing middleware to continue to child middleware 8 | type NextFunc func(req *http.Request) (ResponseWriter, error) 9 | 10 | // MiddlewareFunc is a function that can be used as middleware 11 | type MiddlewareFunc func(req *http.Request, ctx RouteContext, next NextFunc) (ResponseWriter, error) 12 | 13 | type middlewareWrapper struct { 14 | writer *httpResponseWriter 15 | handler func(w *httpResponseWriter, r *http.Request) (ResponseWriter, error) 16 | } 17 | 18 | func (w *middlewareWrapper) Next(r *http.Request) (ResponseWriter, error) { 19 | return w.handler(w.writer, r) 20 | } 21 | 22 | // TODO: add func to wrap and convert a http.Handler to a MiddlewareFunc 23 | type httpMiddlewareWriter struct { 24 | parts []ResponseBodyWriter 25 | statusCode int 26 | header http.Header 27 | savedHeader http.Header 28 | } 29 | 30 | type respBody []byte 31 | 32 | func (r respBody) WriteBody(write BodyWriteFunc) error { 33 | _, err := write(r) 34 | return err 35 | } 36 | 37 | // Header returns the response headers 38 | func (w *httpMiddlewareWriter) Header() http.Header { 39 | if w.statusCode > 0 { 40 | return w.savedHeader 41 | } 42 | return w.header 43 | } 44 | 45 | // Write writes to the response body 46 | func (w *httpMiddlewareWriter) Write(b []byte) (int, error) { 47 | if w.statusCode < 1 { 48 | w.WriteHeader(200) 49 | } 50 | w.parts = append(w.parts, respBody(b)) 51 | return len(b), nil 52 | } 53 | 54 | // WriteHeader sets the status code 55 | func (w *httpMiddlewareWriter) WriteHeader(s int) { 56 | if w.statusCode > 0 { 57 | return 58 | } 59 | w.statusCode = s 60 | w.savedHeader = make(http.Header) 61 | for k, v := range w.header { 62 | w.savedHeader[k] = v 63 | } 64 | } 65 | 66 | // WriteHead returns the status code and header for this response object 67 | func (w *httpMiddlewareWriter) WriteHead(head *ResponseHead) error { 68 | if w.statusCode > 0 { 69 | head.StatusCode = w.statusCode 70 | } 71 | for k, v := range w.Header() { 72 | head.Headers[k] = v 73 | } 74 | return nil 75 | } 76 | 77 | func (w *httpMiddlewareWriter) WriteBody(write BodyWriteFunc) error { 78 | var err error 79 | for _, p := range w.parts { 80 | err = p.WriteBody(write) 81 | if err != nil { 82 | return err 83 | } 84 | } 85 | return nil 86 | } 87 | 88 | func (w *httpMiddlewareWriter) OpenAPIResponsesSpec() Responses { 89 | return Responses{} 90 | } 91 | 92 | 93 | func HTTPMiddleware(middleware func(http.Handler) http.Handler) MiddlewareFunc { 94 | return func(req *http.Request, ctx RouteContext, next NextFunc) (ResponseWriter, error) { 95 | writer := httpMiddlewareWriter{ 96 | header: make(http.Header), 97 | } 98 | nextWrapper := http.HandlerFunc(func (w http.ResponseWriter, req *http.Request) { 99 | unchanged := w.(*httpMiddlewareWriter) == &writer 100 | resp, err := next(req) 101 | if err != nil { 102 | if unchanged && writer.statusCode < 1 { 103 | writeError(err, w) 104 | } 105 | return 106 | } 107 | if resp == nil { 108 | return 109 | } 110 | head := ResponseHead{ 111 | Headers: w.Header(), 112 | StatusCode: ctx.DefaultResponseCode(), 113 | } 114 | err = resp.WriteHead(&head) 115 | if err != nil { 116 | if unchanged && writer.statusCode < 1 { 117 | writeError(err, w) 118 | } 119 | return 120 | } 121 | if head.StatusCode > 0 { 122 | w.WriteHeader(head.StatusCode) 123 | } else { 124 | w.WriteHeader(ctx.DefaultResponseCode()) 125 | } 126 | if resp == nil { 127 | return 128 | } 129 | if unchanged { 130 | writer.parts = append(writer.parts, resp) 131 | } else { 132 | resp.WriteBody(w.Write) 133 | } 134 | }) 135 | middleware(nextWrapper).ServeHTTP(&writer, req) 136 | return &writer, nil 137 | } 138 | } -------------------------------------------------------------------------------- /middleware_test.go: -------------------------------------------------------------------------------- 1 | package chimera_test 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | 8 | "github.com/matt1484/chimera" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestMiddleware(t *testing.T) { 13 | api := chimera.NewAPI() 14 | called := make([]int, 0) 15 | api.Use(func(req *http.Request, ctx chimera.RouteContext, next chimera.NextFunc) (chimera.ResponseWriter, error) { 16 | called = append(called, 1) 17 | return next(req) 18 | }) 19 | api.Use(func(req *http.Request, ctx chimera.RouteContext, next chimera.NextFunc) (chimera.ResponseWriter, error) { 20 | called = append(called, 2) 21 | return next(req) 22 | }) 23 | group := api.Group("/base") 24 | group.Use(func(req *http.Request, ctx chimera.RouteContext, next chimera.NextFunc) (chimera.ResponseWriter, error) { 25 | called = append(called, 3) 26 | return next(req) 27 | }) 28 | 29 | sub := chimera.NewAPI() 30 | sub.Use(func(req *http.Request, ctx chimera.RouteContext, next chimera.NextFunc) (chimera.ResponseWriter, error) { 31 | called = append(called, 4) 32 | return next(req) 33 | }) 34 | chimera.Get(sub, "/route", func(*chimera.EmptyRequest) (*chimera.EmptyResponse, error) { 35 | return nil, nil 36 | }) 37 | 38 | group.Mount("/sub", sub) 39 | server := httptest.NewServer(api) 40 | resp, _ := http.Get(server.URL + "/base/sub/route") 41 | server.Close() 42 | assert.Equal(t, resp.StatusCode, 200) 43 | assert.Equal(t, called, []int{1, 2, 3, 4}) 44 | } 45 | 46 | 47 | func TestHTTPMiddleware(t *testing.T) { 48 | api := chimera.NewAPI() 49 | api.Use(chimera.HTTPMiddleware(func(next http.Handler) http.Handler { 50 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 51 | w.Header().Add("test", "test") 52 | w.WriteHeader(418) 53 | next.ServeHTTP(w, r) 54 | }) 55 | })) 56 | chimera.Get(api, "/route", func(*chimera.EmptyRequest) (*chimera.Response, error) { 57 | return &chimera.Response{ 58 | Body: []byte("test"), 59 | }, nil 60 | }) 61 | 62 | server := httptest.NewServer(api) 63 | resp, _ := http.Get(server.URL + "/route") 64 | server.Close() 65 | assert.Equal(t, resp.StatusCode, 418) 66 | } 67 | -------------------------------------------------------------------------------- /oneof.go: -------------------------------------------------------------------------------- 1 | package chimera 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | 7 | "github.com/matt1484/spectagular" 8 | ) 9 | 10 | // ResponseStructTag represents the "response" struct tag used by OneOfResponse 11 | // it has a statusCode and a description 12 | type ResponseStructTag struct { 13 | StatusCode int `structtag:"statusCode"` 14 | Description string `structtag:"description"` 15 | } 16 | 17 | var ( 18 | _ ResponseWriter = new(OneOfResponse[Nil]) 19 | responseTagCache, _ = spectagular.NewFieldTagCache[ResponseStructTag]("response") 20 | responseWriterType = reflect.TypeOf((*ResponseWriter)(nil)).Elem() 21 | ) 22 | 23 | // OneOfResponse[ResponseType any] is a response that uses the fields of 24 | // ResponseType to determine which response to use as well as ResponseStructTag 25 | // to control the status code, description of the different responses 26 | // All fields must implement ResponseWriter to allow this to work properly 27 | type OneOfResponse[ResponseType any] struct { 28 | Response ResponseType 29 | } 30 | 31 | // WriteBody writes the response body using the first non-nil field 32 | func (r *OneOfResponse[ResponseType]) WriteBody(write BodyWriteFunc) error { 33 | body := reflect.ValueOf(r.Response) 34 | tags, _ := responseTagCache.Get(body.Type()) 35 | for _, tag := range tags { 36 | field := body.Field(tag.FieldIndex) 37 | if !field.IsNil() { 38 | field = fixPointer(field) 39 | return field.Interface().(ResponseWriter).WriteBody(write) 40 | } 41 | } 42 | return nil 43 | } 44 | 45 | // OpenAPIResponsesSpec returns the Responses definition of a OneOfResponse using all the OpenAPIResponsesSpec() functions 46 | // of the fields in ResponseType 47 | func (r *OneOfResponse[ResponseType]) OpenAPIResponsesSpec() Responses { 48 | schema := make(Responses) 49 | body := reflect.ValueOf(*new(ResponseType)) 50 | tags, err := responseTagCache.GetOrAdd(body.Type()) 51 | if err != nil { 52 | panic("chimera.OneOfResponse[Body]: Had invalid Body type: " + body.Type().Name()) 53 | } 54 | for _, tag := range tags { 55 | val := body.Field(tag.FieldIndex) 56 | if val.Kind() == reflect.Pointer { 57 | val = reflect.New(val.Type().Elem()) 58 | } 59 | val = fixPointer(val) 60 | if !val.Type().Implements(responseWriterType) { 61 | panic("chimera.OneOfResponse[Body]: Body fields MUST implement chimera.ResponseWriter") 62 | } 63 | resp := val.Interface().(ResponseWriter).OpenAPIResponsesSpec() 64 | if v, ok := resp[""]; ok { 65 | v.Description = tag.Value.Description 66 | resp[fmt.Sprint(tag.Value.StatusCode)] = v 67 | delete(resp, "") 68 | } 69 | schema.Merge(resp) 70 | } 71 | return schema 72 | } 73 | 74 | // WriteHead writes the status code and header using the first non-nil field 75 | func (r *OneOfResponse[ResponseType]) WriteHead(head *ResponseHead) error { 76 | body := reflect.ValueOf(r.Response) 77 | tags, _ := responseTagCache.Get(body.Type()) 78 | for _, tag := range tags { 79 | field := body.Field(tag.FieldIndex) 80 | if !field.IsNil() { 81 | field = fixPointer(field) 82 | if tag.Value.StatusCode > 0 { 83 | head.StatusCode = tag.Value.StatusCode 84 | } 85 | return field.Interface().(ResponseWriter).WriteHead(head) 86 | } 87 | } 88 | return nil 89 | } 90 | 91 | // NewOneOfResponse creates a OneOfResponse from a response 92 | func NewOneOfResponse[ResponseType any](response ResponseType) *OneOfResponse[ResponseType] { 93 | return &OneOfResponse[ResponseType]{ 94 | Response: response, 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /oneof_test.go: -------------------------------------------------------------------------------- 1 | package chimera_test 2 | 3 | import ( 4 | "io" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | 9 | "github.com/matt1484/chimera" 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | type TestOneOfBodyX struct { 14 | S string `json:"s"` 15 | } 16 | 17 | type TestOneOfBodyY struct { 18 | B bool `json:"b"` 19 | } 20 | 21 | type TestOneOfValidStruct struct { 22 | X *chimera.JSONResponse[TestOneOfBodyX, chimera.Nil] `response:"statusCode=206"` 23 | Y *chimera.JSONResponse[TestOneOfBodyY, chimera.Nil] `response:"statusCode=207"` 24 | } 25 | 26 | func TestValidOneOfRequest(t *testing.T) { 27 | api := chimera.NewAPI() 28 | addResponseTestHandler(t, api, http.MethodGet, "/testoneof", &chimera.OneOfResponse[TestOneOfValidStruct]{ 29 | Response: TestOneOfValidStruct{ 30 | X: &chimera.JSONResponse[TestOneOfBodyX, chimera.Nil]{ 31 | Body: TestOneOfBodyX{ 32 | S: "test x", 33 | }, 34 | }, 35 | }, 36 | }) 37 | server := httptest.NewServer(api) 38 | resp, err := http.Get(server.URL + "/testoneof") 39 | assert.NoError(t, err) 40 | assert.Equal(t, resp.StatusCode, 206) 41 | b, err := io.ReadAll(resp.Body) 42 | assert.NoError(t, err) 43 | assert.Equal(t, b, []byte(`{"s":"test x"}`)) 44 | 45 | api = chimera.NewAPI() 46 | addResponseTestHandler(t, api, http.MethodGet, "/testoneof", &chimera.OneOfResponse[TestOneOfValidStruct]{ 47 | Response: TestOneOfValidStruct{ 48 | Y: &chimera.JSONResponse[TestOneOfBodyY, chimera.Nil]{ 49 | Body: TestOneOfBodyY{ 50 | B: true, 51 | }, 52 | }, 53 | }, 54 | }) 55 | server = httptest.NewServer(api) 56 | resp, err = http.Get(server.URL + "/testoneof") 57 | assert.NoError(t, err) 58 | assert.Equal(t, resp.StatusCode, 207) 59 | b, err = io.ReadAll(resp.Body) 60 | assert.NoError(t, err) 61 | assert.Equal(t, b, []byte(`{"b":true}`)) 62 | } 63 | -------------------------------------------------------------------------------- /openapi.go: -------------------------------------------------------------------------------- 1 | package chimera 2 | 3 | import ( 4 | "github.com/invopop/jsonschema" 5 | ) 6 | 7 | // OpenAPI is used to store an entire openapi spec 8 | type OpenAPI struct { 9 | OpenAPI string `json:"openapi,omitempty"` 10 | Info Info `json:"info,omitempty"` 11 | JSONSchemaDialect string `json:"jsonSchemaDialect,omitempty"` 12 | Servers []Server `json:"servers,omitempty"` 13 | Paths map[string]Path `json:"paths,omitempty"` 14 | Webhooks map[string]Path `json:"webhooks,omitempty"` 15 | Components *Components `json:"components,omitempty"` 16 | Security []map[string][]string `json:"security,omitempty"` 17 | Tags []Tag `json:"tags,omitempty"` 18 | ExternalDocs *ExternalDocs `json:"externalDocs,omitempty"` 19 | } 20 | 21 | // Merge attempts to fold a spec into the current spec 22 | // In general, the provided spec takes precedence over the current one 23 | func (o *OpenAPI) Merge(spec OpenAPI) { 24 | o.Servers = append(spec.Servers, o.Servers...) 25 | for path, obj := range spec.Paths { 26 | pathObj, ok := spec.Paths[path] 27 | if !ok { 28 | pathObj = obj 29 | } else { 30 | // only replace if not found seems appropriate 31 | // but maybe later add a merge function? 32 | if pathObj.Delete == nil { 33 | pathObj.Delete = obj.Delete 34 | } 35 | if pathObj.Get == nil { 36 | pathObj.Get = obj.Get 37 | } 38 | if pathObj.Patch == nil { 39 | pathObj.Patch = obj.Patch 40 | } 41 | if pathObj.Post == nil { 42 | pathObj.Post = obj.Post 43 | } 44 | if pathObj.Put == nil { 45 | pathObj.Put = obj.Put 46 | } 47 | if pathObj.Options == nil { 48 | pathObj.Options = obj.Options 49 | } 50 | if pathObj.Trace == nil { 51 | pathObj.Trace = obj.Trace 52 | } 53 | // TODO: handle dupes? 54 | pathObj.Parameters = append(pathObj.Parameters, obj.Parameters...) 55 | if pathObj.Description == "" { 56 | pathObj.Description = obj.Description 57 | } else { 58 | pathObj.Description += "\n" + obj.Description 59 | } 60 | pathObj.Servers = append(spec.Servers, o.Servers...) 61 | if pathObj.Summary == "" { 62 | pathObj.Summary = obj.Summary 63 | } else { 64 | pathObj.Summary += "\n" + obj.Summary 65 | } 66 | } 67 | o.Paths[path] = pathObj 68 | } 69 | if o.Components == nil { 70 | o.Components = spec.Components 71 | } else { 72 | for k, v := range spec.Components.Parameters { 73 | if _, ok := o.Components.Parameters[k]; !ok { 74 | o.Components.Parameters[k] = v 75 | } 76 | } 77 | for k, v := range spec.Components.Callbacks { 78 | if _, ok := o.Components.Callbacks[k]; !ok { 79 | o.Components.Callbacks[k] = v 80 | } 81 | } 82 | for k, v := range spec.Components.Headers { 83 | if _, ok := o.Components.Headers[k]; !ok { 84 | o.Components.Headers[k] = v 85 | } 86 | } 87 | for k, v := range spec.Components.Links { 88 | if _, ok := o.Components.Links[k]; !ok { 89 | o.Components.Links[k] = v 90 | } 91 | } 92 | for k, v := range spec.Components.PathItems { 93 | if _, ok := o.Components.PathItems[k]; !ok { 94 | o.Components.PathItems[k] = v 95 | } 96 | } 97 | for k, v := range spec.Components.RequestBodies { 98 | if _, ok := o.Components.RequestBodies[k]; !ok { 99 | o.Components.RequestBodies[k] = v 100 | } 101 | } 102 | for k, v := range spec.Components.Responses { 103 | if _, ok := o.Components.Responses[k]; !ok { 104 | o.Components.Responses[k] = v 105 | } 106 | } 107 | for k, v := range spec.Components.Schemas { 108 | if _, ok := o.Components.Schemas[k]; !ok { 109 | o.Components.Schemas[k] = v 110 | } 111 | } 112 | for k, v := range spec.Components.SecuritySchemes { 113 | if _, ok := o.Components.SecuritySchemes[k]; !ok { 114 | o.Components.SecuritySchemes[k] = v 115 | } 116 | } 117 | } 118 | if o.ExternalDocs == nil { 119 | o.ExternalDocs = spec.ExternalDocs 120 | } 121 | // TODO: maybe actually merge the sub dicts? 122 | o.Security = append(spec.Security, o.Security...) 123 | // TODO: dedupe 124 | o.Tags = append(spec.Tags, o.Tags...) 125 | if len(o.Webhooks) == 0 { 126 | o.Webhooks = spec.Webhooks 127 | } else { 128 | for k, v := range spec.Webhooks { 129 | if _, ok := o.Webhooks[k]; !ok { 130 | o.Webhooks[k] = v 131 | } 132 | } 133 | } 134 | } 135 | 136 | // Info holds info about an API 137 | type Info struct { 138 | Title string `json:"title"` 139 | Summary string `json:"summary,omitempty"` 140 | Description string `json:"description,omitempty"` 141 | TermsOfService string `json:"termsOfService,omitempty"` 142 | Contact *Contact `json:"contact,omitempty"` 143 | License *License `json:"license,omitempty"` 144 | Version string `json:"version"` 145 | } 146 | 147 | // License describes the license of an API 148 | type License struct { 149 | Name string `json:"name,omitempty"` 150 | URL string `json:"url,omitempty"` 151 | Identifier string `json:"identifier,omitempty"` 152 | } 153 | 154 | // Contact stores basic contanct info 155 | type Contact struct { 156 | Name string `json:"name,omitempty"` 157 | URL string `json:"url,omitempty"` 158 | Email string `json:"email,omitempty"` 159 | } 160 | 161 | // Server describes a server 162 | type Server struct { 163 | URL string `json:"url,omitempty"` 164 | Description string `json:"description,omitempty"` 165 | Variables map[string]ServerVariable `json:"variables,omitempty"` 166 | } 167 | 168 | // ServerVariable is a variable used in servers 169 | type ServerVariable struct { 170 | Enum []string `json:"enum,omitempty"` 171 | Default string `json:"default,omitempty"` 172 | Description string `json:"description,omitempty"` 173 | } 174 | 175 | // Path stores all operations allowed on a particular path 176 | type Path struct { 177 | // Ref string `json:"$ref,omitempty"` 178 | Summary string `json:"summary,omitempty"` 179 | Description string `json:"description,omitempty"` 180 | Get *Operation `json:"get,omitempty"` 181 | Put *Operation `json:"put,omitempty"` 182 | Post *Operation `json:"post,omitempty"` 183 | Delete *Operation `json:"delete,omitempty"` 184 | Options *Operation `json:"options,omitempty"` 185 | Head *Operation `json:"head,omitempty"` 186 | Patch *Operation `json:"patch,omitempty"` 187 | Trace *Operation `json:"trace,omitempty"` 188 | Servers []Server `json:"servers,omitempty"` 189 | Parameters []Parameter `json:"parameters,omitempty"` 190 | } 191 | 192 | // RequestSpec is the description of an openapi request used in an Operation 193 | type RequestSpec struct { 194 | Parameters []Parameter `json:"parameters,omitempty"` 195 | RequestBody *RequestBody `json:"requestBody,omitempty"` 196 | } 197 | 198 | // Merge folds a Request object into the current Request object 199 | // In general, the provided request takes precedence over the current one 200 | func (r *RequestSpec) Merge(other RequestSpec) { 201 | // TODO ensure the parameters can be otherwritten? 202 | r.Parameters = append(r.Parameters, other.Parameters...) 203 | if r.RequestBody == nil { 204 | r.RequestBody = other.RequestBody 205 | } else if other.RequestBody != nil { 206 | if len(other.RequestBody.Description) != 0 { 207 | r.RequestBody.Description = other.RequestBody.Description 208 | } 209 | r.RequestBody.Required = other.RequestBody.Required || r.RequestBody.Required 210 | if r.RequestBody.Content == nil { 211 | r.RequestBody.Content = other.RequestBody.Content 212 | } else { 213 | for k, v := range other.RequestBody.Content { 214 | r.RequestBody.Content[k] = v 215 | } 216 | } 217 | } 218 | } 219 | 220 | // Operation describes an openapi Operation 221 | type Operation struct { 222 | *RequestSpec 223 | Tags []string `json:"tags,omitempty"` 224 | Summary string `json:"summary,omitempty"` 225 | Description string `json:"description,omitempty"` 226 | ExternalDocs *ExternalDocs `json:"externalDocs,omitempty"` 227 | OperationID string `json:"operationId,omitempty"` 228 | Callbacks map[string]map[string]Path `json:"callbacks,omitempty"` 229 | Deprecated bool `json:"deprecated,omitempty"` 230 | Security []map[string][]string `json:"security,omitempty"` 231 | Servers []Server `json:"servers,omitempty"` 232 | Responses Responses `json:"responses,omitempty"` 233 | } 234 | 235 | // Merge folds an Operation object into the current Operation object 236 | // In general, the provided operation takes precedence over the current one 237 | func (o *Operation) Merge(other Operation) { 238 | if other.RequestSpec != nil { 239 | if o.RequestSpec != nil { 240 | o.RequestSpec.Merge(*other.RequestSpec) 241 | } else { 242 | o.RequestSpec = other.RequestSpec 243 | } 244 | } 245 | if o.Tags == nil || len(o.Tags) == 0 { 246 | o.Tags = other.Tags 247 | } else if len(other.Tags) != 0 { 248 | tagMap := make(map[string]struct{}) 249 | for _, tag := range append(o.Tags, other.Tags...) { 250 | tagMap[tag] = struct{}{} 251 | } 252 | o.Tags = make([]string, len(tagMap)) 253 | i := 0 254 | for t := range tagMap { 255 | o.Tags[i] = t 256 | i++ 257 | } 258 | } 259 | if len(other.Summary) != 0 { 260 | o.Summary = other.Summary 261 | } 262 | if len(other.Description) != 0 { 263 | o.Description = other.Description 264 | } 265 | if len(other.OperationID) != 0 { 266 | o.OperationID = other.OperationID 267 | } 268 | o.Deprecated = o.Deprecated || other.Deprecated 269 | if other.ExternalDocs != nil { 270 | o.ExternalDocs = other.ExternalDocs 271 | } 272 | o.Servers = append(o.Servers, other.Servers...) 273 | o.Responses.Merge(other.Responses) 274 | for k, cb := range other.Callbacks { 275 | if v, ok := o.Callbacks[k]; ok { 276 | for p, path := range cb { 277 | v[p] = path 278 | } 279 | } else { 280 | o.Callbacks[k] = cb 281 | } 282 | } 283 | o.Security = append(o.Security, other.Security...) 284 | } 285 | 286 | // ResponseSpec is an openapi Response description 287 | type ResponseSpec struct { 288 | Description string `json:"description"` 289 | Headers map[string]Parameter `json:"headers,omitempty"` 290 | Content map[string]MediaType `json:"content,omitempty"` 291 | Links map[string]Link `json:"links,omitempty"` 292 | } 293 | 294 | // Link descript a link to parts of a spec 295 | type Link struct { 296 | OperationRef string `json:"operationRef,omitempty"` 297 | OperationId string `json:"operationId,omitempty"` 298 | Parameters map[string]any `json:"parameters,omitempty"` 299 | RequestBody any `json:"requestBody,omitempty"` 300 | Description string `json:"description,omitempty"` 301 | Server *Server `json:"server,omitempty"` 302 | } 303 | 304 | // RequestBody is the spec of a request body 305 | type RequestBody struct { 306 | Description string `json:"description"` 307 | Required bool `json:"required,omitempty"` 308 | Content map[string]MediaType `json:"content,omitempty"` 309 | } 310 | 311 | // MediaType describes a media type in openapi 312 | type MediaType struct { 313 | Schema *jsonschema.Schema `json:"schema,omitempty"` 314 | Encoding map[string]Encoding `json:"encoding,omitempty"` 315 | Example any `json:"example,omitempty"` 316 | Examples *map[string]Example `json:"examples,omitempty"` 317 | } 318 | 319 | // Encoding is used to describe a content encoding in an API 320 | type Encoding struct { 321 | ContentType string `json:"contentType,omitempty"` 322 | Headers map[string]Parameter `json:"headers,omitempty"` 323 | Style string `json:"style,omitempty"` 324 | Explode bool `json:"explode,omitempty"` 325 | AllowReserved bool `json:"allowReserved,omitempty"` 326 | } 327 | 328 | // Parameter describes a paramater used in requests/responses 329 | type Parameter struct { 330 | Name string `json:"name,omitempty"` 331 | In string `json:"in,omitempty"` 332 | Description string `json:"description,omitempty"` 333 | Required bool `json:"required,omitempty"` 334 | Deprecated bool `json:"deprecated,omitempty"` 335 | AllowEmptyValue bool `json:"allowEmptyValue,omitempty"` 336 | Style string `json:"style,omitempty"` 337 | Explode bool `json:"explode,omitempty"` 338 | AllowReserved bool `json:"allowReserved,omitempty"` 339 | Schema *jsonschema.Schema `json:"schema,omitempty"` 340 | Example any `json:"example,omitempty"` 341 | Examples *map[string]Example `json:"examples,omitempty"` 342 | } 343 | 344 | // Example is an example of any type 345 | type Example struct { 346 | Summary string `json:"summary,omitempty"` 347 | Description string `json:"description,omitempty"` 348 | Value any `json:"value,omitempty"` 349 | ExternalValue string `json:"externalValue,omitempty"` 350 | Example any `json:"example,omitempty"` 351 | } 352 | 353 | // Responses is a map of status code string to ResponseSpec 354 | type Responses map[string]ResponseSpec 355 | 356 | // Merge combines 2 responses objects into a single map 357 | func (r *Responses) Merge(other Responses) { 358 | for k, v := range other { 359 | (*r)[k] = v 360 | } 361 | } 362 | 363 | // Components describes all openapi components 364 | type Components struct { 365 | Schemas map[string]jsonschema.Schema `json:"schemas,omitempty"` 366 | Responses Responses `json:"responses,omitempty"` 367 | Parameters map[string]Parameter `json:"parameters,omitempty"` 368 | Examples map[string]Example `json:"examples,omitempty"` 369 | RequestBodies map[string]RequestBody `json:"requestBodies,omitempty"` 370 | Headers map[string]map[string]Parameter `json:"headers,omitempty"` 371 | SecuritySchemes map[string]map[string][]string `json:"securitySchemes,omitempty"` 372 | Links map[string]Link `json:"links,omitempty"` 373 | Callbacks map[string]map[string]Path `json:"callbacks,omitempty"` 374 | PathItems map[string]Path `json:"pathItems,omitempty"` 375 | } 376 | 377 | // Tag is used to tag parts of an API 378 | type Tag struct { 379 | Name string `json:"name"` 380 | Description string `json:"description"` 381 | } 382 | 383 | // ExternalDocs is a link to external API documentation 384 | type ExternalDocs struct { 385 | Description string `json:"description"` 386 | URL string `json:"url"` 387 | } 388 | 389 | // type Reference struct { 390 | // Ref string `json:"$ref"` 391 | // Summary string `json:"summary,omitempty"` 392 | // Description string `json:"description,omitempty"` 393 | // } 394 | -------------------------------------------------------------------------------- /path.go: -------------------------------------------------------------------------------- 1 | package chimera 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "reflect" 7 | "strconv" 8 | "strings" 9 | ) 10 | 11 | var ( 12 | pathParamUnmarshalerType = reflect.TypeOf((*PathParamUnmarshaler)(nil)).Elem() 13 | ) 14 | 15 | // normalizePathStyle forces a style to match one of the allowed openapi types 16 | func normalizePathStyle(style Style) Style { 17 | if style == SimpleStyle || style == LabelStyle || style == MatrixStyle { 18 | return style 19 | } 20 | return SimpleStyle 21 | } 22 | 23 | // decodePrimitiveString turns a string into a primitive reflect value 24 | func decodePrimitiveString(value string, kind reflect.Kind) (reflect.Value, error) { 25 | // TODO: support time, ip, email, url, etc? 26 | switch kind { 27 | case reflect.Bool: 28 | v, err := strconv.ParseBool(value) 29 | return reflect.ValueOf(v), err 30 | case reflect.String: 31 | return reflect.ValueOf(value), nil 32 | case reflect.Int8: 33 | v, err := strconv.ParseInt(value, 10, 8) 34 | return reflect.ValueOf(v).Convert(reflect.TypeOf(*new(int8))), err 35 | case reflect.Int16: 36 | v, err := strconv.ParseInt(value, 10, 16) 37 | return reflect.ValueOf(v).Convert(reflect.TypeOf(*new(int16))), err 38 | case reflect.Int32: 39 | v, err := strconv.ParseInt(value, 10, 32) 40 | return reflect.ValueOf(v).Convert(reflect.TypeOf(*new(int32))), err 41 | case reflect.Int, reflect.Int64: 42 | v, err := strconv.ParseInt(value, 10, 64) 43 | if kind == reflect.Int64 { 44 | return reflect.ValueOf(v).Convert(reflect.TypeOf(*new(int64))), err 45 | } 46 | return reflect.ValueOf(v).Convert(reflect.TypeOf(*new(int))), err 47 | case reflect.Uint8: 48 | v, err := strconv.ParseUint(value, 10, 8) 49 | return reflect.ValueOf(v).Convert(reflect.TypeOf(*new(uint8))), err 50 | case reflect.Uint16: 51 | v, err := strconv.ParseUint(value, 10, 16) 52 | return reflect.ValueOf(v).Convert(reflect.TypeOf(*new(uint16))), err 53 | case reflect.Uint32: 54 | v, err := strconv.ParseUint(value, 10, 32) 55 | return reflect.ValueOf(v).Convert(reflect.TypeOf(*new(uint32))), err 56 | case reflect.Uint, reflect.Uint64: 57 | v, err := strconv.ParseUint(value, 10, 64) 58 | if kind == reflect.Uint64 { 59 | return reflect.ValueOf(v).Convert(reflect.TypeOf(*new(uint64))), err 60 | } 61 | return reflect.ValueOf(v).Convert(reflect.TypeOf(*new(uint))), err 62 | case reflect.Float32, reflect.Float64: 63 | var v float64 64 | var err error 65 | if kind == reflect.Float32 { 66 | v, err = strconv.ParseFloat(value, 32) 67 | return reflect.ValueOf(v).Convert(reflect.TypeOf(*new(float32))), err 68 | } 69 | v, err = strconv.ParseFloat(value, 64) 70 | return reflect.ValueOf(v), err 71 | case reflect.Complex64, reflect.Complex128: 72 | var v complex128 73 | var err error 74 | if kind == reflect.Complex64 { 75 | v, err = strconv.ParseComplex(value, 64) 76 | return reflect.ValueOf(v).Convert(reflect.TypeOf(*new(complex64))), err 77 | } 78 | v, err = strconv.ParseComplex(value, 128) 79 | return reflect.ValueOf(v), err 80 | } 81 | return reflect.ValueOf(nil), errors.New("unable to convert string to kind: " + kind.String()) 82 | } 83 | 84 | // cutPrefix trims the prefix from a string 85 | func cutPrefix(raw, prefix string) (string, error) { 86 | if prefix == "" { 87 | return raw, nil 88 | } 89 | if len(raw) < len(prefix) || raw[:len(prefix)] != prefix { 90 | return raw, nil 91 | } 92 | return raw[len(prefix):], nil 93 | } 94 | 95 | // unmarshalPathParam converts a path param (string) into a usable value 96 | func unmarshalPathParam(param string, tag *ParamStructTag, addr reflect.Value) error { 97 | addr = fixPointer(addr) 98 | if tag.schemaType == interfaceType { 99 | return addr.Interface().(PathParamUnmarshaler).UnmarshalPathParam(param, *tag) 100 | } 101 | return unmarshalStringParam(param, tag, addr) 102 | } 103 | 104 | // unmarshalStringParam parses string params 105 | func unmarshalStringParam(param string, tag *ParamStructTag, addr reflect.Value) error { 106 | switch tag.schemaType { 107 | case sliceType: 108 | return unmarshalSliceFromString(param, tag, addr) 109 | case primitiveType: 110 | return unmarshalPrimitiveFromString(param, tag, addr) 111 | case structType: 112 | return unmarshalStructFromString(param, tag, addr) 113 | } 114 | return nil 115 | } 116 | 117 | // unmarshalPrimitiveFromString unmarshals a primitive value from a string value 118 | func unmarshalPrimitiveFromString(param string, tag *ParamStructTag, addr reflect.Value) error { 119 | param, err := cutPrefix(param, tag.prefix) 120 | if err != nil { 121 | return err 122 | } 123 | val, err := decodePrimitiveString(param, addr.Elem().Kind()) 124 | addr.Elem().Set(val) 125 | if err != nil { 126 | err = NewInvalidParamError(marshalIn(tag.In), tag.Name, param) 127 | } 128 | return err 129 | } 130 | 131 | // unmarshalSliceFromString unmarshals a slice value from a string value 132 | func unmarshalSliceFromString(param string, tag *ParamStructTag, addr reflect.Value) error { 133 | value := addr.Elem() 134 | eType := value.Type().Elem() 135 | value.Set(reflect.MakeSlice(reflect.SliceOf(eType), 0, 0)) 136 | param, err := cutPrefix(param, tag.prefix) 137 | if err != nil { 138 | return err 139 | } 140 | for _, v := range strings.Split(param, tag.delim) { 141 | val, err := decodePrimitiveString(v, eType.Kind()) 142 | if err != nil { 143 | return NewInvalidParamError(marshalIn(tag.In), tag.Name, param) 144 | } 145 | value = reflect.Append(value, val) 146 | } 147 | addr.Elem().Set(value) 148 | return nil 149 | } 150 | 151 | // PathParamUnmarshaler is used to allow types to implement their own logic for parsing path parameters 152 | type PathParamUnmarshaler interface { 153 | UnmarshalPathParam(param string, info ParamStructTag) error 154 | } 155 | 156 | // type ParamPropUnmarshaler interface { 157 | // UnmarshalParamProp(prop string) error 158 | // } 159 | 160 | // var ( 161 | // paramPropUnmarshalerType = reflect.TypeOf((*ParamPropUnmarshaler)(nil)).Elem() 162 | // ) 163 | 164 | // propsFromString gets the struct properties from a string 165 | // NOTE: this is largely based on of kin-openapi, but may need to change later 166 | func propsFromString(src, propDelim, valueDelim string) (map[string]string, error) { 167 | props := make(map[string]string) 168 | pairs := strings.Split(src, propDelim) 169 | 170 | // When propDelim and valueDelim is equal the source string follow the next rule: 171 | // every even item of pairs is a properties's name, and the subsequent odd item is a property's value. 172 | if propDelim == valueDelim { 173 | // Taking into account the rule above, a valid source string must be splitted by propDelim 174 | // to an array with an even number of items. 175 | if len(pairs)%2 != 0 { 176 | return nil, fmt.Errorf("a value must be a list of object's properties in format \"name%svalue\" separated by %s", valueDelim, propDelim) 177 | } 178 | for i := 0; i < len(pairs)/2; i++ { 179 | props[pairs[i*2]] = pairs[i*2+1] 180 | } 181 | return props, nil 182 | } 183 | 184 | // When propDelim and valueDelim is not equal the source string follow the next rule: 185 | // every item of pairs is a string that follows format . 186 | for _, pair := range pairs { 187 | prop := strings.Split(pair, valueDelim) 188 | if len(prop) != 2 { 189 | return nil, fmt.Errorf("a value must be a list of object's properties in format \"name%svalue\" separated by %s", valueDelim, propDelim) 190 | } 191 | props[prop[0]] = prop[1] 192 | } 193 | return props, nil 194 | } 195 | 196 | // unmarshalStructFromString unmarshals a struct value from a string value 197 | func unmarshalStructFromString(param string, tag *ParamStructTag, addr reflect.Value) error { 198 | par, err := cutPrefix(param, tag.prefix) 199 | if err != nil { 200 | return err 201 | } 202 | props, err := propsFromString(par, tag.delim, tag.valueDelim) 203 | if err != nil { 204 | return NewInvalidParamError(marshalIn(tag.In), tag.Name, param) 205 | } 206 | for name, prop := range tag.propMap { 207 | if valStr, ok := props[name]; ok { 208 | f := addr.Elem().Field(prop.fieldIndex) 209 | switch prop.schemaType { 210 | case primitiveType: 211 | v, err := decodePrimitiveString(valStr, f.Kind()) 212 | if err != nil { 213 | return NewInvalidParamError(marshalIn(tag.In), tag.Name, param) 214 | } 215 | f.Set(v) 216 | // case Interface: 217 | // err = f.Interface().(ParamPropUnmarshaler).UnmarshalParamProp(valStr) 218 | // if err != nil { 219 | // return err 220 | // } 221 | } 222 | } 223 | } 224 | return nil 225 | } 226 | -------------------------------------------------------------------------------- /query.go: -------------------------------------------------------------------------------- 1 | package chimera 2 | 3 | import ( 4 | "net/url" 5 | "reflect" 6 | "strings" 7 | ) 8 | 9 | var ( 10 | queryParamUnmarshalerType = reflect.TypeOf((*QueryParamUnmarshaler)(nil)).Elem() 11 | ) 12 | 13 | // QueryParamUnmarshaler allows a type to add custom validation or parsing logic based on query parameters 14 | type QueryParamUnmarshaler interface { 15 | UnmarshalQueryParam(value url.Values, info ParamStructTag) error 16 | } 17 | 18 | // unmarshalQueryParam attempts to turn query values into a value 19 | func unmarshalQueryParam(param url.Values, tag *ParamStructTag, addr reflect.Value) error { 20 | // TODO: we need to find a way to handle multiple nested query params 21 | // its hard because form encoded bodies cant repeat keys but query strings 22 | // are the wild west. at this point, I am just going to follow the OpenAPI 23 | // standard but it may be preferable later to support multiple options like 24 | // X.Y.Z.0 or X[Y][Z][0] or X.Y.Z[0] or just always treat repeats as arrays 25 | // may also want to disable the use of explode for form styles 26 | addr = fixPointer(addr) 27 | if tag.schemaType == interfaceType { 28 | return addr.Interface().(QueryParamUnmarshaler).UnmarshalQueryParam(param, *tag) 29 | } 30 | switch tag.schemaType { 31 | case sliceType: 32 | return unmarshalSliceFromQuery(param, tag, addr) 33 | case primitiveType: 34 | if val, ok := param[tag.Name]; ok { 35 | if len(val) > 0 { 36 | return unmarshalPrimitiveFromString(val[0], tag, addr) 37 | } 38 | } else if tag.Required { 39 | return NewRequiredParamError("query", tag.Name) 40 | } 41 | case structType: 42 | return unmarshalStructFromQuery(param, tag, addr) 43 | } 44 | return nil 45 | } 46 | 47 | // unmarshalSliceFromQuery unmarshals slice types from query values 48 | func unmarshalSliceFromQuery(param url.Values, tag *ParamStructTag, addr reflect.Value) error { 49 | value := addr.Elem() 50 | eType := value.Type().Elem() 51 | value.Set(reflect.MakeSlice(reflect.SliceOf(eType), 0, 0)) 52 | vals, ok := param[tag.Name] 53 | if tag.Required && (!ok || len(vals) == 0) { 54 | return NewRequiredParamError("query", tag.Name) 55 | } 56 | if len(vals) == 0 { 57 | return nil 58 | } 59 | if !tag.Explode { 60 | vals = strings.Split(vals[0], tag.delim) 61 | } 62 | 63 | for _, v := range vals { 64 | val, err := decodePrimitiveString(v, eType.Kind()) 65 | if err != nil { 66 | return NewInvalidParamError(marshalIn(tag.In), tag.Name, v) 67 | } 68 | value = reflect.Append(value, val) 69 | } 70 | addr.Elem().Set(value) 71 | return nil 72 | } 73 | 74 | // unmarshalStructFromQuery unmarshals struct types from query values 75 | func unmarshalStructFromQuery(param url.Values, tag *ParamStructTag, addr reflect.Value) error { 76 | switch tag.Style { 77 | case FormStyle: 78 | if tag.Explode { 79 | requiredCheck := !tag.Required 80 | for name, prop := range tag.propMap { 81 | switch prop.schemaType { 82 | case primitiveType: 83 | if val, ok := param[name]; ok && len(val) > 0 { 84 | f := addr.Elem().Field(prop.fieldIndex) 85 | v, err := decodePrimitiveString(val[0], f.Kind()) 86 | if err != nil { 87 | return NewInvalidParamError(marshalIn(tag.In), tag.Name, val[0]) 88 | } 89 | f.Set(v) 90 | requiredCheck = true 91 | } 92 | // case Interface: 93 | // if val, ok := param[name]; ok && len(val) > 0 { 94 | // f := addr.Elem().Field(prop.fieldIndex) 95 | // err := f.Interface().(ParamPropUnmarshaler).UnmarshalParamProp(val[0]) 96 | // if err != nil { 97 | // return nil 98 | // } 99 | // requiredCheck = true 100 | // } 101 | } 102 | } 103 | if !requiredCheck { 104 | return NewRequiredParamError("query", tag.Name) 105 | } 106 | } else { 107 | if val, ok := param[tag.Name]; ok { 108 | if len(val) > 0 { 109 | return unmarshalStructFromString(param.Get(tag.Name), tag, addr) 110 | } 111 | } else if tag.Required { 112 | return NewRequiredParamError("query", tag.Name) 113 | } 114 | } 115 | case DeepObjectStyle: 116 | requiredCheck := !tag.Required 117 | for name, prop := range tag.propMap { 118 | f := addr.Elem().Field(prop.fieldIndex) 119 | if val, ok := param[name]; ok && len(val) > 0 { 120 | v, err := decodePrimitiveString(val[0], f.Kind()) 121 | if err != nil { 122 | return NewInvalidParamError(marshalIn(tag.In), tag.Name, val[0]) 123 | } 124 | f.Set(v) 125 | requiredCheck = true 126 | } 127 | } 128 | if !requiredCheck { 129 | return NewRequiredParamError("query", tag.Name) 130 | } 131 | } 132 | return nil 133 | } 134 | 135 | // normalizeQueryStyle ensures the style is appropriate for a query param 136 | func normalizeQueryStyle(style Style) Style { 137 | if style == PipeDelimitedStyle || style == FormStyle || style == SpaceDelimitedStyle || style == DeepObjectStyle { 138 | return style 139 | } 140 | return FormStyle 141 | } 142 | -------------------------------------------------------------------------------- /query_test.go: -------------------------------------------------------------------------------- 1 | package chimera_test 2 | 3 | import ( 4 | "net/url" 5 | ) 6 | 7 | type TestPrimitiveQueryParams struct { 8 | FormStr string `param:"formstr,in=query,style=form"` 9 | FormU8 uint8 `param:"formuint,in=query,style=form"` 10 | FormU16 uint16 `param:"formuint,in=query,style=form"` 11 | FormU32 uint32 `param:"formuint,in=query,style=form"` 12 | FormU64 uint64 `param:"formuint,in=query,style=form"` 13 | FormUint uint `param:"formuint,in=query,style=form"` 14 | FormI8 int8 `param:"formint,in=query,style=form"` 15 | FormI16 int16 `param:"formint,in=query,style=form"` 16 | FormI32 int32 `param:"formint,in=query,style=form"` 17 | FormI64 int64 `param:"formint,in=query,style=form"` 18 | FormInt int `param:"formint,in=query,style=form"` 19 | FormF32 float32 `param:"formfloat,in=query,style=form"` 20 | FormF64 float64 `param:"formfloat,in=query,style=form"` 21 | 22 | FormExplodeStr string `param:"formexstr,in=query,explode,style=form"` 23 | FormExplodeU8 uint8 `param:"formexuint,in=query,explode,style=form"` 24 | FormExplodeU16 uint16 `param:"formexuint,in=query,explode,style=form"` 25 | FormExplodeU32 uint32 `param:"formexuint,in=query,explode,style=form"` 26 | FormExplodeU64 uint64 `param:"formexuint,in=query,explode,style=form"` 27 | FormExplodeUint uint `param:"formexuint,in=query,explode,style=form"` 28 | FormExplodeI8 int8 `param:"formexint,in=query,explode,style=form"` 29 | FormExplodeI16 int16 `param:"formexint,in=query,explode,style=form"` 30 | FormExplodeI32 int32 `param:"formexint,in=query,explode,style=form"` 31 | FormExplodeI64 int64 `param:"formexint,in=query,explode,style=form"` 32 | FormExplodeInt int `param:"formexint,in=query,explode,style=form"` 33 | FormExplodeF32 float32 `param:"formexfloat,in=query,explode,style=form"` 34 | FormExplodeF64 float64 `param:"formexfloat,in=query,explode,style=form"` 35 | } 36 | 37 | type TestComplexQueryParams struct { 38 | FormStr []string `param:"formstr,in=query,style=form"` 39 | FormU8 []uint8 `param:"formuint,in=query,style=form"` 40 | FormU16 []uint16 `param:"formuint,in=query,style=form"` 41 | FormU32 []uint32 `param:"formuint,in=query,style=form"` 42 | FormU64 []uint64 `param:"formuint,in=query,style=form"` 43 | FormUint []uint `param:"formuint,in=query,style=form"` 44 | FormI8 []int8 `param:"formint,in=query,style=form"` 45 | FormI16 []int16 `param:"formint,in=query,style=form"` 46 | FormI32 []int32 `param:"formint,in=query,style=form"` 47 | FormI64 []int64 `param:"formint,in=query,style=form"` 48 | FormInt []int `param:"formint,in=query,style=form"` 49 | FormF32 []float32 `param:"formfloat,in=query,style=form"` 50 | FormF64 []float64 `param:"formfloat,in=query,style=form"` 51 | FormStruct TestStructParams `param:"formstruct,in=query,style=form"` 52 | 53 | FormExplodeStr []string `param:"formexstr,in=query,explode,style=form"` 54 | FormExplodeU8 []uint8 `param:"formexuint,in=query,explode,style=form"` 55 | FormExplodeU16 []uint16 `param:"formexuint,in=query,explode,style=form"` 56 | FormExplodeU32 []uint32 `param:"formexuint,in=query,explode,style=form"` 57 | FormExplodeU64 []uint64 `param:"formexuint,in=query,explode,style=form"` 58 | FormExplodeUint []uint `param:"formexuint,in=query,explode,style=form"` 59 | FormExplodeI8 []int8 `param:"formexint,in=query,explode,style=form"` 60 | FormExplodeI16 []int16 `param:"formexint,in=query,explode,style=form"` 61 | FormExplodeI32 []int32 `param:"formexint,in=query,explode,style=form"` 62 | FormExplodeI64 []int64 `param:"formexint,in=query,explode,style=form"` 63 | FormExplodeInt []int `param:"formexint,in=query,explode,style=form"` 64 | FormExplodeF32 []float32 `param:"formexfloat,in=query,explode,style=form"` 65 | FormExplodeF64 []float64 `param:"formexfloat,in=query,explode,style=form"` 66 | FormExplodeStruct TestStructParams `param:"formexstruct,in=query,explode,style=form"` 67 | 68 | SpaceStr []string `param:"spacestr,in=query,style=spaceDelimited"` 69 | SpaceU8 []uint8 `param:"spaceuint,in=query,style=spaceDelimited"` 70 | SpaceU16 []uint16 `param:"spaceuint,in=query,style=spaceDelimited"` 71 | SpaceU32 []uint32 `param:"spaceuint,in=query,style=spaceDelimited"` 72 | SpaceU64 []uint64 `param:"spaceuint,in=query,style=spaceDelimited"` 73 | SpaceUint []uint `param:"spaceuint,in=query,style=spaceDelimited"` 74 | SpaceI8 []int8 `param:"spaceint,in=query,style=spaceDelimited"` 75 | SpaceI16 []int16 `param:"spaceint,in=query,style=spaceDelimited"` 76 | SpaceI32 []int32 `param:"spaceint,in=query,style=spaceDelimited"` 77 | SpaceI64 []int64 `param:"spaceint,in=query,style=spaceDelimited"` 78 | SpaceInt []int `param:"spaceint,in=query,style=spaceDelimited"` 79 | SpaceF32 []float32 `param:"spacefloat,in=query,style=spaceDelimited"` 80 | SpaceF64 []float64 `param:"spacefloat,in=query,style=spaceDelimited"` 81 | 82 | SpaceExplodeStr []string `param:"spaceexstr,in=query,explode,style=spaceDelimited"` 83 | SpaceExplodeU8 []uint8 `param:"spaceexuint,in=query,explode,style=spaceDelimited"` 84 | SpaceExplodeU16 []uint16 `param:"spaceexuint,in=query,explode,style=spaceDelimited"` 85 | SpaceExplodeU32 []uint32 `param:"spaceexuint,in=query,explode,style=spaceDelimited"` 86 | SpaceExplodeU64 []uint64 `param:"spaceexuint,in=query,explode,style=spaceDelimited"` 87 | SpaceExplodeUint []uint `param:"spaceexuint,in=query,explode,style=spaceDelimited"` 88 | SpaceExplodeI8 []int8 `param:"spaceexint,in=query,explode,style=spaceDelimited"` 89 | SpaceExplodeI16 []int16 `param:"spaceexint,in=query,explode,style=spaceDelimited"` 90 | SpaceExplodeI32 []int32 `param:"spaceexint,in=query,explode,style=spaceDelimited"` 91 | SpaceExplodeI64 []int64 `param:"spaceexint,in=query,explode,style=spaceDelimited"` 92 | SpaceExplodeInt []int `param:"spaceexint,in=query,explode,style=spaceDelimited"` 93 | SpaceExplodeF32 []float32 `param:"spaceexfloat,in=query,explode,style=spaceDelimited"` 94 | SpaceExplodeF64 []float64 `param:"spaceexfloat,in=query,explode,style=spaceDelimited"` 95 | 96 | PipeStr []string `param:"pipestr,in=query,style=pipeDelimited"` 97 | PipeU8 []uint8 `param:"pipeuint,in=query,style=pipeDelimited"` 98 | PipeU16 []uint16 `param:"pipeuint,in=query,style=pipeDelimited"` 99 | PipeU32 []uint32 `param:"pipeuint,in=query,style=pipeDelimited"` 100 | PipeU64 []uint64 `param:"pipeuint,in=query,style=pipeDelimited"` 101 | PipeUint []uint `param:"pipeuint,in=query,style=pipeDelimited"` 102 | PipeI8 []int8 `param:"pipeint,in=query,style=pipeDelimited"` 103 | PipeI16 []int16 `param:"pipeint,in=query,style=pipeDelimited"` 104 | PipeI32 []int32 `param:"pipeint,in=query,style=pipeDelimited"` 105 | PipeI64 []int64 `param:"pipeint,in=query,style=pipeDelimited"` 106 | PipeInt []int `param:"pipeint,in=query,style=pipeDelimited"` 107 | PipeF32 []float32 `param:"pipefloat,in=query,style=pipeDelimited"` 108 | PipeF64 []float64 `param:"pipefloat,in=query,style=pipeDelimited"` 109 | 110 | PipeExplodeStr []string `param:"pipeexstr,in=query,explode,style=pipeDelimited"` 111 | PipeExplodeU8 []uint8 `param:"pipeexuint,in=query,explode,style=pipeDelimited"` 112 | PipeExplodeU16 []uint16 `param:"pipeexuint,in=query,explode,style=pipeDelimited"` 113 | PipeExplodeU32 []uint32 `param:"pipeexuint,in=query,explode,style=pipeDelimited"` 114 | PipeExplodeU64 []uint64 `param:"pipeexuint,in=query,explode,style=pipeDelimited"` 115 | PipeExplodeUint []uint `param:"pipeexuint,in=query,explode,style=pipeDelimited"` 116 | PipeExplodeI8 []int8 `param:"pipeexint,in=query,explode,style=pipeDelimited"` 117 | PipeExplodeI16 []int16 `param:"pipeexint,in=query,explode,style=pipeDelimited"` 118 | PipeExplodeI32 []int32 `param:"pipeexint,in=query,explode,style=pipeDelimited"` 119 | PipeExplodeI64 []int64 `param:"pipeexint,in=query,explode,style=pipeDelimited"` 120 | PipeExplodeInt []int `param:"pipeexint,in=query,explode,style=pipeDelimited"` 121 | PipeExplodeF32 []float32 `param:"pipeexfloat,in=query,explode,style=pipeDelimited"` 122 | PipeExplodeF64 []float64 `param:"pipeexfloat,in=query,explode,style=pipeDelimited"` 123 | 124 | DeepStruct TestStructParams `param:"deepstruct,in=query,style=deepObject"` 125 | } 126 | 127 | var ( 128 | testValidFormPrimitiveQueryValues = url.Values{ 129 | "formstr": []string{"ateststring"}, 130 | "formuint": []string{"123"}, 131 | "formint": []string{"-123"}, 132 | "formfloat": []string{"123.45"}, 133 | "formexstr": []string{"test..."}, 134 | "formexuint": []string{"255"}, 135 | "formexint": []string{"0"}, 136 | "formexfloat": []string{"-123.45"}, 137 | } 138 | testValidFormComplexQueryValues = url.Values{ 139 | "formstr": []string{"string1,string2"}, 140 | "formuint": []string{"0,123"}, 141 | "formint": []string{"123,-123"}, 142 | "formfloat": []string{"123.45,0.0"}, 143 | "formstruct": []string{"stringprop,propstring,intprop,123"}, 144 | "formexstr": []string{"string3", "string4"}, 145 | "formexuint": []string{"255", "1"}, 146 | "formexint": []string{"0", "-1"}, 147 | "formexfloat": []string{"-123.45", "1"}, 148 | "stringprop": []string{"propstring"}, 149 | "intprop": []string{"123"}, 150 | 151 | "spacestr": []string{"string1 string2"}, 152 | "spaceuint": []string{"0 123"}, 153 | "spaceint": []string{"123 -123"}, 154 | "spacefloat": []string{"123.45 0.0"}, 155 | "spaceexstr": []string{"string3", "string4"}, 156 | "spaceexuint": []string{"255", "1"}, 157 | "spaceexint": []string{"0", "-1"}, 158 | "spaceexfloat": []string{"-123.45", "1"}, 159 | 160 | "pipestr": []string{"string1|string2"}, 161 | "pipeuint": []string{"0|123"}, 162 | "pipeint": []string{"123|-123"}, 163 | "pipefloat": []string{"123.45|0.0"}, 164 | "pipeexstr": []string{"string3", "string4"}, 165 | "pipeexuint": []string{"255", "1"}, 166 | "pipeexint": []string{"0", "-1"}, 167 | "pipeexfloat": []string{"-123.45", "1"}, 168 | 169 | "deepstruct[stringprop]": []string{"propstring"}, 170 | "deepstruct[intprop]": []string{"123"}, 171 | } 172 | 173 | testPrimitiveQueryParams = TestPrimitiveQueryParams{ 174 | FormStr: "ateststring", 175 | FormU8: 123, 176 | FormU16: 123, 177 | FormU32: 123, 178 | FormU64: 123, 179 | FormUint: 123, 180 | FormI8: -123, 181 | FormI16: -123, 182 | FormI32: -123, 183 | FormI64: -123, 184 | FormInt: -123, 185 | FormF32: 123.45, 186 | FormF64: 123.45, 187 | 188 | FormExplodeStr: "test...", 189 | FormExplodeU8: 255, 190 | FormExplodeU16: 255, 191 | FormExplodeU32: 255, 192 | FormExplodeU64: 255, 193 | FormExplodeUint: 255, 194 | FormExplodeI8: 0, 195 | FormExplodeI16: 0, 196 | FormExplodeI32: 0, 197 | FormExplodeI64: 0, 198 | FormExplodeInt: 0, 199 | FormExplodeF32: -123.45, 200 | FormExplodeF64: -123.45, 201 | } 202 | 203 | testComplexQueryParams = TestComplexQueryParams{ 204 | FormStr: []string{"string1", "string2"}, 205 | FormU8: []uint8{0, 123}, 206 | FormU16: []uint16{0, 123}, 207 | FormU32: []uint32{0, 123}, 208 | FormU64: []uint64{0, 123}, 209 | FormUint: []uint{0, 123}, 210 | FormI8: []int8{123, -123}, 211 | FormI16: []int16{123, -123}, 212 | FormI32: []int32{123, -123}, 213 | FormI64: []int64{123, -123}, 214 | FormInt: []int{123, -123}, 215 | FormF32: []float32{123.45, 0.0}, 216 | FormF64: []float64{123.45, 0.0}, 217 | FormStruct: TestStructParams{ 218 | StringProp: "propstring", 219 | IntProp: 123, 220 | }, 221 | 222 | FormExplodeStr: []string{"string3", "string4"}, 223 | FormExplodeU8: []uint8{255, 1}, 224 | FormExplodeU16: []uint16{255, 1}, 225 | FormExplodeU32: []uint32{255, 1}, 226 | FormExplodeU64: []uint64{255, 1}, 227 | FormExplodeUint: []uint{255, 1}, 228 | FormExplodeI8: []int8{0, -1}, 229 | FormExplodeI16: []int16{0, -1}, 230 | FormExplodeI32: []int32{0, -1}, 231 | FormExplodeI64: []int64{0, -1}, 232 | FormExplodeInt: []int{0, -1}, 233 | FormExplodeF32: []float32{-123.45, 1}, 234 | FormExplodeF64: []float64{-123.45, 1}, 235 | FormExplodeStruct: TestStructParams{ 236 | StringProp: "propstring", 237 | IntProp: 123, 238 | }, 239 | 240 | SpaceStr: []string{"string1", "string2"}, 241 | SpaceU8: []uint8{0, 123}, 242 | SpaceU16: []uint16{0, 123}, 243 | SpaceU32: []uint32{0, 123}, 244 | SpaceU64: []uint64{0, 123}, 245 | SpaceUint: []uint{0, 123}, 246 | SpaceI8: []int8{123, -123}, 247 | SpaceI16: []int16{123, -123}, 248 | SpaceI32: []int32{123, -123}, 249 | SpaceI64: []int64{123, -123}, 250 | SpaceInt: []int{123, -123}, 251 | SpaceF32: []float32{123.45, 0.0}, 252 | SpaceF64: []float64{123.45, 0.0}, 253 | 254 | SpaceExplodeStr: []string{"string3", "string4"}, 255 | SpaceExplodeU8: []uint8{255, 1}, 256 | SpaceExplodeU16: []uint16{255, 1}, 257 | SpaceExplodeU32: []uint32{255, 1}, 258 | SpaceExplodeU64: []uint64{255, 1}, 259 | SpaceExplodeUint: []uint{255, 1}, 260 | SpaceExplodeI8: []int8{0, -1}, 261 | SpaceExplodeI16: []int16{0, -1}, 262 | SpaceExplodeI32: []int32{0, -1}, 263 | SpaceExplodeI64: []int64{0, -1}, 264 | SpaceExplodeInt: []int{0, -1}, 265 | SpaceExplodeF32: []float32{-123.45, 1}, 266 | SpaceExplodeF64: []float64{-123.45, 1}, 267 | 268 | PipeStr: []string{"string1", "string2"}, 269 | PipeU8: []uint8{0, 123}, 270 | PipeU16: []uint16{0, 123}, 271 | PipeU32: []uint32{0, 123}, 272 | PipeU64: []uint64{0, 123}, 273 | PipeUint: []uint{0, 123}, 274 | PipeI8: []int8{123, -123}, 275 | PipeI16: []int16{123, -123}, 276 | PipeI32: []int32{123, -123}, 277 | PipeI64: []int64{123, -123}, 278 | PipeInt: []int{123, -123}, 279 | PipeF32: []float32{123.45, 0.0}, 280 | PipeF64: []float64{123.45, 0.0}, 281 | 282 | PipeExplodeStr: []string{"string3", "string4"}, 283 | PipeExplodeU8: []uint8{255, 1}, 284 | PipeExplodeU16: []uint16{255, 1}, 285 | PipeExplodeU32: []uint32{255, 1}, 286 | PipeExplodeU64: []uint64{255, 1}, 287 | PipeExplodeUint: []uint{255, 1}, 288 | PipeExplodeI8: []int8{0, -1}, 289 | PipeExplodeI16: []int16{0, -1}, 290 | PipeExplodeI32: []int32{0, -1}, 291 | PipeExplodeI64: []int64{0, -1}, 292 | PipeExplodeInt: []int{0, -1}, 293 | PipeExplodeF32: []float32{-123.45, 1}, 294 | PipeExplodeF64: []float64{-123.45, 1}, 295 | 296 | DeepStruct: TestStructParams{ 297 | StringProp: "propstring", 298 | IntProp: 123, 299 | }, 300 | } 301 | ) 302 | -------------------------------------------------------------------------------- /requests.go: -------------------------------------------------------------------------------- 1 | package chimera 2 | 3 | import ( 4 | "net/http" 5 | "reflect" 6 | ) 7 | 8 | var ( 9 | _ RequestReader = new(EmptyRequest) 10 | _ RequestReader = new(NoBodyRequest[Nil]) 11 | _ RequestReader = new(Request) 12 | ) 13 | 14 | // RequestReader is used to allow chimera to automatically read/parse requests 15 | // as well as describe the parts of a request via openapi 16 | type RequestReader interface { 17 | ReadRequest(*http.Request) error 18 | OpenAPIRequestSpec() RequestSpec 19 | } 20 | 21 | // RequestReaderPtr is just a workaround to allow chimera to accept a pointer 22 | // to a RequestReader and convert to the underlying type 23 | type RequestReaderPtr[T any] interface { 24 | RequestReader 25 | *T 26 | } 27 | 28 | // EmptyRequest is an empty request, effectively a no-op 29 | // (mostly used for GET requests) 30 | type EmptyRequest struct{} 31 | 32 | // ReadRequest does nothing 33 | func (*EmptyRequest) ReadRequest(*http.Request) error { 34 | return nil 35 | } 36 | 37 | // OpenAPIRequestSpec returns an empty RequestSpec 38 | func (*EmptyRequest) OpenAPIRequestSpec() RequestSpec { 39 | return RequestSpec{} 40 | } 41 | 42 | // NoBodyRequest is a request with only parameters and an empty body 43 | // (mostly used for GET requests) 44 | type NoBodyRequest[Params any] struct { 45 | Params Params 46 | } 47 | 48 | // ReadRequest parses the params of the request 49 | func (r *NoBodyRequest[Params]) ReadRequest(req *http.Request) error { 50 | r.Params = *new(Params) 51 | if _, ok := any(r.Params).(Nil); !ok { 52 | err := UnmarshalParams(req, &r.Params) 53 | if err != nil { 54 | return err 55 | } 56 | } 57 | return nil 58 | } 59 | 60 | // OpenAPIRequestSpec returns the parameter definitions of this object 61 | func (r *NoBodyRequest[Params]) OpenAPIRequestSpec() RequestSpec { 62 | schema := RequestSpec{} 63 | pType := reflect.TypeOf(new(Params)) 64 | for ; pType.Kind() == reflect.Pointer; pType = pType.Elem() { 65 | } 66 | if pType != reflect.TypeOf(Nil{}) { 67 | schema.Parameters = CacheRequestParamsType(pType) 68 | } 69 | return schema 70 | } 71 | 72 | // Request is just an http.Request that matches the expected interfaces 73 | type Request http.Request 74 | 75 | // ReadRequest assigns the request to the Request struct 76 | func (r *Request) ReadRequest(req *http.Request) error { 77 | *r = Request(*req) 78 | return nil 79 | } 80 | 81 | // OpenAPIRequestSpec returns an empty RequestSpec 82 | func (r *Request) OpenAPIRequestSpec() RequestSpec { 83 | return RequestSpec{} 84 | } 85 | -------------------------------------------------------------------------------- /requests_test.go: -------------------------------------------------------------------------------- 1 | package chimera_test 2 | 3 | import ( 4 | "bytes" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | 9 | "github.com/matt1484/chimera" 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestNoBodyRequestValid(t *testing.T) { 14 | api := chimera.NewAPI() 15 | primPath := addRequestTestHandler(t, api, http.MethodGet, testValidSimplePath+testValidLabelPath+testValidMatrixPath, &chimera.NoBodyRequest[TestPrimitivePathParams]{Params: testPrimitivePathParams}) 16 | server := httptest.NewServer(api) 17 | resp, err := http.Get(server.URL + testValidPrimitiveSimplePathValues + testValidPrimitiveLabelPathValues + testValidPrimitiveMatrixPathValues) 18 | server.Close() 19 | assert.NoError(t, err) 20 | assert.Equal(t, resp.StatusCode, 200) 21 | assert.Equal(t, (*primPath).Params, testPrimitivePathParams) 22 | 23 | api = chimera.NewAPI() 24 | complexPath := addRequestTestHandler(t, api, http.MethodGet, testValidSimplePath+testValidLabelPath+testValidMatrixPath, &chimera.NoBodyRequest[TestComplexPathParams]{testComplexPathParams}) 25 | server = httptest.NewServer(api) 26 | resp, err = http.Get(server.URL + testValidComplexSimplePathValues + testValidComplexLabelPathValues + testValidComplexMatrixPathValues) 27 | server.Close() 28 | assert.NoError(t, err) 29 | assert.Equal(t, resp.StatusCode, 200) 30 | assert.Equal(t, (*complexPath).Params, testComplexPathParams) 31 | 32 | api = chimera.NewAPI() 33 | primHeader := addRequestTestHandler(t, api, http.MethodGet, "/headertest", &chimera.NoBodyRequest[TestPrimitiveHeaderParams]{Params: testPrimitiveHeaderParams}) 34 | server = httptest.NewServer(api) 35 | req, err := http.NewRequest(http.MethodGet, server.URL+"/headertest", bytes.NewBuffer([]byte{})) 36 | assert.NoError(t, err) 37 | for k, v := range testValidSimplePrimitiveHeaderValues { 38 | req.Header.Set(k, v[0]) 39 | } 40 | resp, err = http.DefaultClient.Do(req) 41 | server.Close() 42 | assert.NoError(t, err) 43 | assert.Equal(t, resp.StatusCode, 200) 44 | assert.Equal(t, (*primHeader).Params, testPrimitiveHeaderParams) 45 | 46 | api = chimera.NewAPI() 47 | complexHeader := addRequestTestHandler(t, api, http.MethodGet, "/headertest", &chimera.NoBodyRequest[TestComplexHeaderParams]{Params: testComplexHeaderParams}) 48 | server = httptest.NewServer(api) 49 | req, err = http.NewRequest(http.MethodGet, server.URL+"/headertest", bytes.NewBuffer([]byte{})) 50 | assert.NoError(t, err) 51 | for k, v := range testValidSimpleComplexHeaderValues { 52 | req.Header.Set(k, v[0]) 53 | } 54 | resp, err = http.DefaultClient.Do(req) 55 | server.Close() 56 | assert.NoError(t, err) 57 | assert.Equal(t, resp.StatusCode, 200) 58 | assert.Equal(t, (*complexHeader).Params, testComplexHeaderParams) 59 | 60 | api = chimera.NewAPI() 61 | primCookie := addRequestTestHandler(t, api, http.MethodGet, "/cookietest", &chimera.NoBodyRequest[TestPrimitiveCookieParams]{Params: testPrimitiveCookieParams}) 62 | server = httptest.NewServer(api) 63 | req, err = http.NewRequest(http.MethodGet, server.URL+"/cookietest", bytes.NewBuffer([]byte{})) 64 | assert.NoError(t, err) 65 | for _, c := range testValidFormPrimitiveCookieValues { 66 | req.AddCookie(&c) 67 | } 68 | resp, err = http.DefaultClient.Do(req) 69 | server.Close() 70 | assert.NoError(t, err) 71 | assert.Equal(t, resp.StatusCode, 200) 72 | assert.Equal(t, (*primCookie).Params, testPrimitiveCookieParams) 73 | 74 | api = chimera.NewAPI() 75 | complexCookie := addRequestTestHandler(t, api, http.MethodGet, "/cookietest", &chimera.NoBodyRequest[TestComplexCookieParams]{Params: testComplexCookieParams}) 76 | server = httptest.NewServer(api) 77 | req, err = http.NewRequest(http.MethodGet, server.URL+"/cookietest", bytes.NewBuffer([]byte{})) 78 | assert.NoError(t, err) 79 | for _, c := range testValidFormComplexCookieValues { 80 | req.AddCookie(&c) 81 | } 82 | resp, err = http.DefaultClient.Do(req) 83 | server.Close() 84 | assert.NoError(t, err) 85 | assert.Equal(t, resp.StatusCode, 200) 86 | assert.Equal(t, (*complexCookie).Params, testComplexCookieParams) 87 | 88 | api = chimera.NewAPI() 89 | primQuery := addRequestTestHandler(t, api, http.MethodGet, "/querytest", &chimera.NoBodyRequest[TestPrimitiveQueryParams]{Params: testPrimitiveQueryParams}) 90 | server = httptest.NewServer(api) 91 | req, err = http.NewRequest(http.MethodGet, server.URL+"/querytest?"+testValidFormPrimitiveQueryValues.Encode(), bytes.NewBuffer([]byte{})) 92 | assert.NoError(t, err) 93 | resp, err = http.DefaultClient.Do(req) 94 | server.Close() 95 | assert.NoError(t, err) 96 | assert.Equal(t, resp.StatusCode, 200) 97 | assert.Equal(t, (*primQuery).Params, testPrimitiveQueryParams) 98 | 99 | api = chimera.NewAPI() 100 | complexQuery := addRequestTestHandler(t, api, http.MethodGet, "/querytest", &chimera.NoBodyRequest[TestComplexQueryParams]{Params: testComplexQueryParams}) 101 | server = httptest.NewServer(api) 102 | req, err = http.NewRequest(http.MethodGet, server.URL+"/querytest?"+testValidFormComplexQueryValues.Encode(), bytes.NewBuffer([]byte{})) 103 | assert.NoError(t, err) 104 | resp, err = http.DefaultClient.Do(req) 105 | server.Close() 106 | assert.NoError(t, err) 107 | assert.Equal(t, resp.StatusCode, 200) 108 | assert.Equal(t, (*complexQuery).Params, testComplexQueryParams) 109 | } 110 | 111 | func TestInvalidParams(t *testing.T) { 112 | type PrimitiveInvalidPath struct { 113 | Param bool `param:"req,in=path"` 114 | } 115 | 116 | api := chimera.NewAPI() 117 | primPath := addRequestTestHandler(t, api, http.MethodGet, "/{req}", &chimera.NoBodyRequest[PrimitiveInvalidPath]{}) 118 | server := httptest.NewServer(api) 119 | resp, err := http.Get(server.URL + "/test") 120 | server.Close() 121 | assert.NoError(t, err) 122 | assert.Equal(t, resp.StatusCode, 422) 123 | assert.Nil(t, *primPath) 124 | 125 | type SliceInvalidPath struct { 126 | Param []bool `param:"req,in=path"` 127 | } 128 | 129 | api = chimera.NewAPI() 130 | slicePath := addRequestTestHandler(t, api, http.MethodGet, "/{req}", &chimera.NoBodyRequest[SliceInvalidPath]{}) 131 | server = httptest.NewServer(api) 132 | resp, err = http.Get(server.URL + "/test") 133 | server.Close() 134 | assert.NoError(t, err) 135 | assert.Equal(t, resp.StatusCode, 422) 136 | assert.Nil(t, *slicePath) 137 | 138 | type Struct struct { 139 | Param bool `prop:"x"` 140 | } 141 | 142 | type StructInvalidPath struct { 143 | Param Struct `param:"req,in=path"` 144 | } 145 | 146 | api = chimera.NewAPI() 147 | structPath := addRequestTestHandler(t, api, http.MethodGet, "/{req}", &chimera.NoBodyRequest[StructInvalidPath]{}) 148 | server = httptest.NewServer(api) 149 | resp, err = http.Get(server.URL + "/x,test") 150 | server.Close() 151 | assert.NoError(t, err) 152 | assert.Equal(t, resp.StatusCode, 422) 153 | assert.Nil(t, *structPath) 154 | 155 | type PrimitiveInvalidHeader struct { 156 | Param bool `param:"req,in=header,required"` 157 | } 158 | 159 | api = chimera.NewAPI() 160 | primHeader := addRequestTestHandler(t, api, http.MethodGet, "/test", &chimera.NoBodyRequest[PrimitiveInvalidHeader]{}) 161 | server = httptest.NewServer(api) 162 | req, err := http.NewRequest(http.MethodGet, server.URL+"/test", bytes.NewBufferString("")) 163 | assert.NoError(t, err) 164 | req.Header.Add("req", "test") 165 | resp, err = http.DefaultClient.Do(req) 166 | assert.NoError(t, err) 167 | server.Close() 168 | assert.Equal(t, resp.StatusCode, 422) 169 | assert.Nil(t, *primHeader) 170 | 171 | api = chimera.NewAPI() 172 | primHeader = addRequestTestHandler(t, api, http.MethodGet, "/test", &chimera.NoBodyRequest[PrimitiveInvalidHeader]{}) 173 | server = httptest.NewServer(api) 174 | req, err = http.NewRequest(http.MethodGet, server.URL+"/test", bytes.NewBufferString("")) 175 | assert.NoError(t, err) 176 | resp, err = http.DefaultClient.Do(req) 177 | assert.NoError(t, err) 178 | server.Close() 179 | assert.Equal(t, resp.StatusCode, 422) 180 | assert.Nil(t, *primHeader) 181 | 182 | type SliceInvalidHeader struct { 183 | Param []bool `param:"req,in=header,required"` 184 | } 185 | 186 | api = chimera.NewAPI() 187 | sliceHeader := addRequestTestHandler(t, api, http.MethodGet, "/test", &chimera.NoBodyRequest[SliceInvalidHeader]{}) 188 | server = httptest.NewServer(api) 189 | req, err = http.NewRequest(http.MethodGet, server.URL+"/test", bytes.NewBufferString("")) 190 | req.Header.Add("req", "test") 191 | assert.NoError(t, err) 192 | resp, err = http.DefaultClient.Do(req) 193 | assert.NoError(t, err) 194 | server.Close() 195 | assert.Equal(t, resp.StatusCode, 422) 196 | assert.Nil(t, *sliceHeader) 197 | 198 | type StructInvalidHeader struct { 199 | Param Struct `param:"req,in=header"` 200 | } 201 | 202 | api = chimera.NewAPI() 203 | structHeader := addRequestTestHandler(t, api, http.MethodGet, "/test", &chimera.NoBodyRequest[StructInvalidHeader]{}) 204 | server = httptest.NewServer(api) 205 | req, err = http.NewRequest(http.MethodGet, server.URL+"/test", bytes.NewBufferString("")) 206 | req.Header.Add("req", "x,test") 207 | assert.NoError(t, err) 208 | resp, err = http.DefaultClient.Do(req) 209 | assert.NoError(t, err) 210 | server.Close() 211 | assert.Equal(t, resp.StatusCode, 422) 212 | assert.Nil(t, *structHeader) 213 | 214 | type PrimitiveInvalidCookie struct { 215 | Param bool `param:"req,in=cookie,required"` 216 | } 217 | 218 | api = chimera.NewAPI() 219 | primCookie := addRequestTestHandler(t, api, http.MethodGet, "/test", &chimera.NoBodyRequest[PrimitiveInvalidCookie]{}) 220 | server = httptest.NewServer(api) 221 | req, err = http.NewRequest(http.MethodGet, server.URL+"/test", bytes.NewBufferString("")) 222 | assert.NoError(t, err) 223 | req.AddCookie(&http.Cookie{ 224 | Name: "req", 225 | Value: "test", 226 | }) 227 | resp, err = http.DefaultClient.Do(req) 228 | assert.NoError(t, err) 229 | server.Close() 230 | assert.Equal(t, resp.StatusCode, 422) 231 | assert.Nil(t, *primCookie) 232 | 233 | api = chimera.NewAPI() 234 | primCookie = addRequestTestHandler(t, api, http.MethodGet, "/test", &chimera.NoBodyRequest[PrimitiveInvalidCookie]{}) 235 | server = httptest.NewServer(api) 236 | req, err = http.NewRequest(http.MethodGet, server.URL+"/test", bytes.NewBufferString("")) 237 | assert.NoError(t, err) 238 | resp, err = http.DefaultClient.Do(req) 239 | assert.NoError(t, err) 240 | server.Close() 241 | assert.Equal(t, resp.StatusCode, 422) 242 | assert.Nil(t, *primCookie) 243 | 244 | type SliceInvalidCookie struct { 245 | Param []bool `param:"req,in=cookie,required"` 246 | } 247 | 248 | api = chimera.NewAPI() 249 | sliceCookie := addRequestTestHandler(t, api, http.MethodGet, "/test", &chimera.NoBodyRequest[SliceInvalidCookie]{}) 250 | server = httptest.NewServer(api) 251 | req, err = http.NewRequest(http.MethodGet, server.URL+"/test", bytes.NewBufferString("")) 252 | req.AddCookie(&http.Cookie{ 253 | Name: "req", 254 | Value: "test", 255 | }) 256 | assert.NoError(t, err) 257 | resp, err = http.DefaultClient.Do(req) 258 | assert.NoError(t, err) 259 | server.Close() 260 | assert.Equal(t, resp.StatusCode, 422) 261 | assert.Nil(t, *sliceCookie) 262 | 263 | type StructInvalidCookie struct { 264 | Param Struct `param:"req,in=cookie"` 265 | } 266 | 267 | api = chimera.NewAPI() 268 | structCookie := addRequestTestHandler(t, api, http.MethodGet, "/test", &chimera.NoBodyRequest[StructInvalidCookie]{}) 269 | server = httptest.NewServer(api) 270 | req, err = http.NewRequest(http.MethodGet, server.URL+"/test", bytes.NewBufferString("")) 271 | req.AddCookie(&http.Cookie{ 272 | Name: "req", 273 | Value: "x,test", 274 | }) 275 | assert.NoError(t, err) 276 | resp, err = http.DefaultClient.Do(req) 277 | assert.NoError(t, err) 278 | server.Close() 279 | assert.Equal(t, resp.StatusCode, 422) 280 | assert.Nil(t, *structCookie) 281 | 282 | type PrimitiveInvalidQuery struct { 283 | Param bool `param:"req,in=query,required"` 284 | } 285 | 286 | api = chimera.NewAPI() 287 | primQuery := addRequestTestHandler(t, api, http.MethodGet, "/test", &chimera.NoBodyRequest[PrimitiveInvalidQuery]{}) 288 | server = httptest.NewServer(api) 289 | resp, err = http.Get(server.URL + "/test?req=test") 290 | assert.NoError(t, err) 291 | server.Close() 292 | assert.Equal(t, resp.StatusCode, 422) 293 | assert.Nil(t, *primQuery) 294 | 295 | api = chimera.NewAPI() 296 | primQuery = addRequestTestHandler(t, api, http.MethodGet, "/test", &chimera.NoBodyRequest[PrimitiveInvalidQuery]{}) 297 | server = httptest.NewServer(api) 298 | resp, err = http.Get(server.URL + "/test") 299 | assert.NoError(t, err) 300 | server.Close() 301 | assert.Equal(t, resp.StatusCode, 422) 302 | assert.Nil(t, *primQuery) 303 | 304 | type SliceInvalidQuery struct { 305 | Param []bool `param:"req,in=query,required"` 306 | } 307 | 308 | api = chimera.NewAPI() 309 | sliceQuery := addRequestTestHandler(t, api, http.MethodGet, "/test", &chimera.NoBodyRequest[SliceInvalidQuery]{}) 310 | server = httptest.NewServer(api) 311 | resp, err = http.Get(server.URL + "/test?req=test") 312 | assert.NoError(t, err) 313 | server.Close() 314 | assert.Equal(t, resp.StatusCode, 422) 315 | assert.Nil(t, *sliceQuery) 316 | 317 | type StructInvalidQuery struct { 318 | Param Struct `param:"req,in=query"` 319 | } 320 | 321 | api = chimera.NewAPI() 322 | structQuery := addRequestTestHandler(t, api, http.MethodGet, "/test", &chimera.NoBodyRequest[StructInvalidQuery]{}) 323 | server = httptest.NewServer(api) 324 | resp, err = http.Get(server.URL + "/test?req=x,test") 325 | assert.NoError(t, err) 326 | server.Close() 327 | assert.Equal(t, resp.StatusCode, 422) 328 | assert.Nil(t, *structQuery) 329 | } 330 | 331 | func TestOpenAPI(t *testing.T) { 332 | api := chimera.NewAPI() 333 | route := chimera.Get(api, "/testopenapi", func(*chimera.EmptyRequest) (*chimera.EmptyResponse, error) { 334 | return nil, nil 335 | }) 336 | assert.Contains(t, api.OpenAPISpec().Paths, "/testopenapi") 337 | route.Internalize() 338 | assert.NotContains(t, api.OpenAPISpec().Paths, "/testopenapi") 339 | } 340 | -------------------------------------------------------------------------------- /responses.go: -------------------------------------------------------------------------------- 1 | package chimera 2 | 3 | import ( 4 | "net/http" 5 | "reflect" 6 | ) 7 | 8 | var ( 9 | _ ResponseWriter = new(EmptyResponse) 10 | _ ResponseWriter = new(NoBodyResponse[Nil]) 11 | _ ResponseWriter = new(Response) 12 | _ http.ResponseWriter = new(Response) 13 | _ http.ResponseWriter = new(httpResponseWriter) 14 | _ ResponseWriter = new(LazyBodyResponse) 15 | ) 16 | 17 | // ResponseHead contains the head of an HTTP Response 18 | type ResponseHead struct { 19 | StatusCode int 20 | Headers http.Header 21 | } 22 | 23 | type BodyWriteFunc func(body []byte) (int, error) 24 | 25 | type ResponseBodyWriter interface { 26 | WriteBody(write BodyWriteFunc) error 27 | } 28 | 29 | type ResponseHeadWriter interface { 30 | WriteHead(*ResponseHead) error 31 | } 32 | 33 | // ResponseWriter allows chimera to automatically write responses 34 | type ResponseWriter interface { 35 | ResponseBodyWriter 36 | ResponseHeadWriter 37 | OpenAPIResponsesSpec() Responses 38 | } 39 | 40 | // ResponseWriterPtr is just a workaround to allow chimera to accept a pointer 41 | // to a ResponseWriter and convert to the underlying type 42 | type ResponseWriterPtr[T any] interface { 43 | ResponseWriter 44 | *T 45 | } 46 | 47 | // EmptyResponse is an empty response, effectively a no-op 48 | // (mostly used for DELETE requests) 49 | type EmptyResponse struct{} 50 | 51 | // WriteHead does nothing 52 | func (*EmptyResponse) WriteHead(*ResponseHead) error { 53 | return nil 54 | } 55 | 56 | // WriteBody does nothing 57 | func (*EmptyResponse) WriteBody(BodyWriteFunc) error { 58 | return nil 59 | } 60 | 61 | // OpenAPIResponsesSpec returns an empty Responses definition 62 | func (*EmptyResponse) OpenAPIResponsesSpec() Responses { 63 | return Responses{} 64 | } 65 | 66 | // NoBodyResponse is a response with no body, but has parameters 67 | // (mostly used for DELETE requests) 68 | type NoBodyResponse[Params any] struct { 69 | Params Params 70 | } 71 | 72 | // WriteBody does nothing 73 | func (r *NoBodyResponse[Params]) WriteBody(BodyWriteFunc) error { 74 | return nil 75 | } 76 | 77 | // OpenAPIResponsesSpec returns the parameter definitions of this object 78 | func (r *NoBodyResponse[Params]) OpenAPIResponsesSpec() Responses { 79 | schema := make(Responses) 80 | pType := reflect.TypeOf(*new(Params)) 81 | for ; pType.Kind() == reflect.Pointer; pType = pType.Elem() { 82 | } 83 | response := ResponseSpec{} 84 | if pType != reflect.TypeOf(Nil{}) { 85 | response.Headers = make(map[string]Parameter) 86 | for _, param := range CacheResponseParamsType(pType) { 87 | response.Headers[param.Name] = Parameter{ 88 | Schema: param.Schema, 89 | Description: param.Description, 90 | Deprecated: param.Deprecated, 91 | AllowReserved: param.AllowReserved, 92 | AllowEmptyValue: param.AllowEmptyValue, 93 | Required: param.Required, 94 | Explode: param.Explode, 95 | Example: param.Example, 96 | Examples: param.Examples, 97 | } 98 | } 99 | } 100 | schema[""] = response 101 | return schema 102 | } 103 | 104 | // WriteHead writes the headers for this response 105 | func (r *NoBodyResponse[Params]) WriteHead(head *ResponseHead) error { 106 | h, err := MarshalParams(&r.Params) 107 | if err != nil { 108 | return err 109 | } 110 | for k, v := range h { 111 | for _, x := range v { 112 | head.Headers.Add(k, x) 113 | } 114 | } 115 | return nil 116 | } 117 | 118 | // NewBinaryResponse creates a NoBodyResponse from params 119 | func NewNoBodyResponse[Params any](params Params) *NoBodyResponse[Params] { 120 | return &NoBodyResponse[Params]{ 121 | Params: params, 122 | } 123 | } 124 | 125 | // httpResponseWriter is the interal struct that overrides the default http.ResponseWriter 126 | type httpResponseWriter struct { 127 | writer http.ResponseWriter 128 | respError error 129 | response ResponseWriter 130 | route *route 131 | dirty bool 132 | } 133 | 134 | // Header returns the response headers 135 | func (w *httpResponseWriter) Header() http.Header { 136 | return w.writer.Header() 137 | } 138 | 139 | // Write writes to the response body 140 | func (w *httpResponseWriter) Write(b []byte) (int, error) { 141 | w.dirty = true 142 | return w.writer.Write(b) 143 | } 144 | 145 | // WriteHeader sets the status code 146 | func (w *httpResponseWriter) WriteHeader(s int) { 147 | w.dirty = true 148 | w.writer.WriteHeader(s) 149 | } 150 | 151 | // Response is a simple response type to support creating responses on the fly 152 | // it is mostly useful for middleware where execution needs to halt and an 153 | // undefined response needs to be returned 154 | type Response struct { 155 | StatusCode int 156 | Headers http.Header 157 | Body []byte 158 | } 159 | 160 | // WriteBody writes the exact body from the struct 161 | func (r *Response) WriteBody(write BodyWriteFunc) error { 162 | _, err := write(r.Body) 163 | return err 164 | } 165 | 166 | // OpenAPIResponsesSpec returns an empty Responses object 167 | func (r *Response) OpenAPIResponsesSpec() Responses { 168 | return Responses{} 169 | } 170 | 171 | // WriteHead returns the status code and header for this response object 172 | func (r *Response) WriteHead(head *ResponseHead) error { 173 | if r.StatusCode > 0 { 174 | head.StatusCode = r.StatusCode 175 | } 176 | for k, v := range r.Headers { 177 | head.Headers[k] = v 178 | } 179 | return nil 180 | } 181 | 182 | // Write stores the body in the Reponse object for use later 183 | func (r *Response) Write(body []byte) (int, error) { 184 | if r.Body == nil { 185 | r.Body = body 186 | } else { 187 | r.Body = append(r.Body, body...) 188 | } 189 | return len(body), nil 190 | } 191 | 192 | // WriteHeader stores the status code in the Reponse object for use later 193 | func (r *Response) WriteHeader(status int) { 194 | r.StatusCode = status 195 | } 196 | 197 | // Header returns the current header for http.ResponseWriter compatibility 198 | func (r *Response) Header() http.Header { 199 | return r.Headers 200 | } 201 | 202 | // NewResponse creates a response with the body, status, and header 203 | func NewResponse(body []byte, statusCode int, header http.Header) *Response { 204 | return &Response{ 205 | Body: body, 206 | StatusCode: statusCode, 207 | Headers: header, 208 | } 209 | } 210 | 211 | // LazyBodyResponse is a response that effectively wraps another ReponseWriter with predefined header/status code 212 | type LazyBodyResponse struct { 213 | StatusCode int 214 | Body ResponseBodyWriter 215 | Headers http.Header 216 | } 217 | 218 | // WriteBody writes the exact body from the struct 219 | func (r *LazyBodyResponse) WriteBody(write BodyWriteFunc) error { 220 | return r.Body.WriteBody(write) 221 | } 222 | 223 | // OpenAPIResponsesSpec returns an empty Responses object 224 | func (r *LazyBodyResponse) OpenAPIResponsesSpec() Responses { 225 | return Responses{} 226 | } 227 | 228 | // WriteHead returns the status code and header for this response object 229 | func (r *LazyBodyResponse) WriteHead(head *ResponseHead) error { 230 | if r.StatusCode > 0 { 231 | head.StatusCode = r.StatusCode 232 | } 233 | for k, v := range r.Headers { 234 | head.Headers[k] = v 235 | } 236 | return nil 237 | } 238 | 239 | // NewLazyBodyResponse creates a response with predefined headers and a lazy body 240 | func NewLazyBodyResponse(head ResponseHead, resp ResponseBodyWriter) *LazyBodyResponse { 241 | return &LazyBodyResponse{ 242 | Body: resp, 243 | Headers: head.Headers, 244 | StatusCode: head.StatusCode, 245 | } 246 | } 247 | -------------------------------------------------------------------------------- /responses_test.go: -------------------------------------------------------------------------------- 1 | package chimera_test 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | 8 | "github.com/matt1484/chimera" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestNoBodyResponseValid(t *testing.T) { 13 | api := chimera.NewAPI() 14 | addResponseTestHandler(t, api, http.MethodGet, "/headertest", &chimera.NoBodyResponse[TestPrimitiveHeaderParams]{Params: testPrimitiveHeaderParams}) 15 | server := httptest.NewServer(api) 16 | resp, err := http.Get(server.URL + "/headertest") 17 | server.Close() 18 | assert.NoError(t, err) 19 | assert.Equal(t, resp.StatusCode, 200) 20 | for k, v := range testValidSimplePrimitiveHeaderValues { 21 | assert.Equal(t, resp.Header.Values(k)[0], v[0]) 22 | } 23 | 24 | api = chimera.NewAPI() 25 | addResponseTestHandler(t, api, http.MethodGet, "/cookietest", &chimera.NoBodyResponse[TestPrimitiveCookieParams]{Params: testPrimitiveCookieParams}) 26 | server = httptest.NewServer(api) 27 | resp, err = http.Get(server.URL + "/cookietest") 28 | server.Close() 29 | assert.NoError(t, err) 30 | assert.Equal(t, resp.StatusCode, 200) 31 | cookie := make(map[string]string) 32 | for _, c := range testValidFormPrimitiveCookieValues { 33 | cookie[c.Name] = c.Value 34 | } 35 | found := make(map[string]struct{}) 36 | for _, c := range resp.Cookies() { 37 | assert.Equal(t, cookie[c.Name], c.Value) 38 | found[c.Name] = struct{}{} 39 | } 40 | assert.Equal(t, len(cookie), len(found)) 41 | } 42 | -------------------------------------------------------------------------------- /route.go: -------------------------------------------------------------------------------- 1 | package chimera 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | ) 7 | 8 | var ( 9 | _ RouteContext = new(routeContext) 10 | ) 11 | 12 | // routeContext contains basic info about a matched Route 13 | type routeContext struct { 14 | path string 15 | method string 16 | responseCode int 17 | } 18 | 19 | // RouteContext contains basic info about a matched Route 20 | type RouteContext interface { 21 | // Path returns the path that the route was setup with (i.e. /route/{var}) 22 | Path() string 23 | // Method returns the method intended to be used by the route 24 | Method() string 25 | // DefaultResponseCode returns the default response code for this route 26 | DefaultResponseCode() int 27 | // GetResponseHead gets the ResponseHead based on the default status code and a ResponseWriter 28 | GetResponseHead(ResponseWriter) (*ResponseHead, error) 29 | // GetResponse turns a ResponseWriter into *Response based on the default status code 30 | GetResponse(ResponseWriter) (*Response, error) 31 | } 32 | 33 | // Path returns the path that the route was setup with (i.e. /route/{var}) 34 | func (r *routeContext) Path() string { 35 | return r.path 36 | } 37 | 38 | // Method returns the method intended to be used by the route 39 | func (r *routeContext) Method() string { 40 | return r.method 41 | } 42 | 43 | // DefaultResponseCode returns the default response code for this route 44 | func (r *routeContext) DefaultResponseCode() int { 45 | return r.responseCode 46 | } 47 | 48 | // GetResponseHead gets the ResponseHead based on the default status code and a ResponseWriter 49 | func (r *routeContext) GetResponseHead(resp ResponseWriter) (*ResponseHead, error) { 50 | head := ResponseHead{ 51 | Headers: make(http.Header), 52 | StatusCode: r.responseCode, 53 | } 54 | err := resp.WriteHead(&head) 55 | return &head, err 56 | } 57 | 58 | // GetResponse turns a ResponseWriter into *Response based on the default status code 59 | func (r *routeContext) GetResponse(resp ResponseWriter) (*Response, error) { 60 | head, err := r.GetResponseHead(resp) 61 | if err != nil { 62 | return nil, err 63 | } 64 | actual := Response{ 65 | StatusCode: head.StatusCode, 66 | Headers: head.Headers, 67 | Body: make([]byte, 0), 68 | } 69 | err = resp.WriteBody(actual.Write) 70 | if err != nil { 71 | return nil, err 72 | } 73 | return &actual, nil 74 | } 75 | 76 | // route contains basic info about an API route 77 | type route struct { 78 | // func(*http.Request) (ResponseWriter, error) 79 | handler http.HandlerFunc 80 | operationSpec *Operation 81 | context *routeContext 82 | defaultCode string 83 | hidden bool 84 | api *API 85 | } 86 | 87 | // Route contains basic info about an API route and allows for inline editing of itself 88 | type Route struct { 89 | route *route 90 | } 91 | 92 | // OpenAPIOperationSpec returns the Operation spec for this route 93 | func (r Route) OpenAPIOperationSpec() *Operation { 94 | return r.route.operationSpec 95 | } 96 | 97 | // WithResponseCode sets the default response code for this route 98 | // NOTE: the first time this is called, the presumption is that default code has been set based on http method 99 | func (r Route) WithResponseCode(code int) Route { 100 | r.route.context.responseCode = code 101 | if r.route.operationSpec == nil { 102 | return r 103 | } 104 | if _, ok := r.route.operationSpec.Responses[r.route.defaultCode]; ok { 105 | r.route.operationSpec.Responses[fmt.Sprint(code)] = r.route.operationSpec.Responses[r.route.defaultCode] 106 | delete(r.route.operationSpec.Responses, r.route.defaultCode) 107 | r.route.defaultCode = fmt.Sprint(code) 108 | } 109 | return r 110 | } 111 | 112 | // WithResponses performs a merge on the operation's responses for this route 113 | func (r Route) WithResponses(resp Responses) Route { 114 | r.route.operationSpec.Responses.Merge(resp) 115 | return r 116 | } 117 | 118 | // WithRequest performs a merge on the operation's request spec for this route 119 | func (r Route) WithRequest(req RequestSpec) Route { 120 | r.route.operationSpec.RequestSpec.Merge(req) 121 | return r 122 | } 123 | 124 | // WithOperation performs a merge on the operation's spec for this route 125 | func (r Route) WithOperation(op Operation) Route { 126 | r.route.operationSpec.Merge(op) 127 | return r 128 | } 129 | 130 | // UsingResponses replaces the operation's responses for this route 131 | func (r Route) UsingResponses(resp Responses) Route { 132 | r.route.operationSpec.Responses = resp 133 | return r 134 | } 135 | 136 | // UsingRequest replaces the operation's request spec for this route 137 | func (r Route) UsingRequest(req RequestSpec) Route { 138 | r.route.operationSpec.RequestSpec = &req 139 | return r 140 | } 141 | 142 | // UsingOperation replaces the operation's spec for this route 143 | func (r Route) UsingOperation(op Operation) Route { 144 | r.route.operationSpec = &op 145 | return r 146 | } 147 | 148 | // Internalize hides the route from the api spec 149 | func (r Route) Internalize() Route { 150 | r.route.hidden = true 151 | rebuildAPI(r.route.api) 152 | return r 153 | } 154 | 155 | // HandlerFunc is a handler function. The generic signature may look odd but its effectively: 156 | // func(req *RequestReader) (*ResponseWriter, error) 157 | type HandlerFunc[ReqPtr RequestReaderPtr[Req], Req any, RespPtr ResponseWriterPtr[Resp], Resp any] func(ReqPtr) (RespPtr, error) 158 | 159 | // HTTPHandler is a function that converts a standard http.HandlerFunc into one that works with chimera 160 | func HTTPHandler(handler http.HandlerFunc) HandlerFunc[*Request, Request, *Response, Response] { 161 | return func(req *Request) (*Response, error) { 162 | response := Response{} 163 | handler(&response, (*http.Request)(req)) 164 | return &response, nil 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /text.go: -------------------------------------------------------------------------------- 1 | package chimera 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "net/http" 7 | "reflect" 8 | 9 | "github.com/invopop/jsonschema" 10 | ) 11 | 12 | var ( 13 | _ RequestReader = new(PlainTextRequest[Nil]) 14 | _ ResponseWriter = new(PlainTextResponse[Nil]) 15 | _ RequestReader = new(PlainText[Nil]) 16 | _ ResponseWriter = new(PlainText[Nil]) 17 | ) 18 | 19 | // PlainTextRequest is any text/plain request that results in a string body 20 | type PlainTextRequest[Params any] struct { 21 | request *http.Request 22 | Body string 23 | Params Params 24 | } 25 | 26 | // Context returns the context that was part of the original http.Request 27 | func (r *PlainTextRequest[Params]) Context() context.Context { 28 | if r.request != nil { 29 | return r.request.Context() 30 | } 31 | return nil 32 | } 33 | 34 | func readPlainTextRequest[Params any](req *http.Request, body *string, params *Params) error { 35 | defer req.Body.Close() 36 | b, err := io.ReadAll(req.Body) 37 | if err != nil { 38 | return err 39 | } 40 | *body = string(b) 41 | 42 | if _, ok := any(params).(*Nil); !ok { 43 | err = UnmarshalParams(req, params) 44 | if err != nil { 45 | return err 46 | } 47 | } 48 | return nil 49 | } 50 | 51 | // ReadRequest reads the body of an http request and assigns it to the Body field using io.ReadAll. 52 | // This function also reads the parameters using UnmarshalParams and assigns it to the Params field. 53 | // NOTE: the body of the request is closed after this function is run. 54 | func (r *PlainTextRequest[Params]) ReadRequest(req *http.Request) error { 55 | r.request = req 56 | return readPlainTextRequest(req, &r.Body, &r.Params) 57 | } 58 | 59 | func textRequestSpec[Params any](schema *RequestSpec) { 60 | schema.RequestBody = &RequestBody{ 61 | Content: map[string]MediaType{ 62 | "text/plain": { 63 | Schema: &jsonschema.Schema{ 64 | Type: "string", 65 | }, 66 | }, 67 | }, 68 | } 69 | 70 | pType := reflect.TypeOf(new(Params)) 71 | for ; pType.Kind() == reflect.Pointer; pType = pType.Elem() { 72 | } 73 | if pType != reflect.TypeOf(Nil{}) { 74 | schema.Parameters = CacheRequestParamsType(pType) 75 | } 76 | } 77 | 78 | // OpenAPIRequestSpec describes the RequestSpec for text/plain requests 79 | func (r *PlainTextRequest[Params]) OpenAPIRequestSpec() RequestSpec { 80 | schema := RequestSpec{} 81 | textRequestSpec[Params](&schema) 82 | return schema 83 | } 84 | 85 | // PlainTextRequest[Params] is any text/plain response that uses a string body 86 | type PlainTextResponse[Params any] struct { 87 | Body string 88 | Params Params 89 | } 90 | 91 | // WriteBody writes the response 92 | func (r *PlainTextResponse[Params]) WriteBody(write BodyWriteFunc) error { 93 | _, err := write([]byte(r.Body)) 94 | return err 95 | } 96 | 97 | func textResponsesSpec[Params any](schema Responses) { 98 | response := ResponseSpec{} 99 | response.Content = map[string]MediaType{ 100 | "text/plain": { 101 | Schema: &jsonschema.Schema{ 102 | Type: "string", 103 | }, 104 | }, 105 | } 106 | 107 | pType := reflect.TypeOf(*new(Params)) 108 | for ; pType.Kind() == reflect.Pointer; pType = pType.Elem() { 109 | } 110 | if pType != reflect.TypeOf(Nil{}) { 111 | response.Headers = make(map[string]Parameter) 112 | for _, param := range CacheResponseParamsType(pType) { 113 | response.Headers[param.Name] = Parameter{ 114 | Schema: param.Schema, 115 | Description: param.Description, 116 | Deprecated: param.Deprecated, 117 | AllowReserved: param.AllowReserved, 118 | AllowEmptyValue: param.AllowEmptyValue, 119 | Required: param.Required, 120 | Explode: param.Explode, 121 | Example: param.Example, 122 | Examples: param.Examples, 123 | } 124 | } 125 | } 126 | schema[""] = response 127 | } 128 | 129 | // OpenAPIResponsesSpec describes the Responses for text/plain requests 130 | func (r *PlainTextResponse[Params]) OpenAPIResponsesSpec() Responses { 131 | schema := make(Responses) 132 | textResponsesSpec[Params](schema) 133 | return schema 134 | } 135 | 136 | // WriteHead write the header for this response object 137 | func (r *PlainTextResponse[Params]) WriteHead(head *ResponseHead) error { 138 | head.Headers.Set("Content-Type", "text/plain") 139 | h, err := MarshalParams(&r.Params) 140 | if err != nil { 141 | return err 142 | } 143 | for k, v := range h { 144 | for _, x := range v { 145 | head.Headers.Add(k, x) 146 | } 147 | } 148 | return nil 149 | } 150 | 151 | // NewPlainTextResponse creates a PlainTextResponse from a string and params 152 | func NewPlainTextResponse[Params any](body string, params Params) *PlainTextResponse[Params] { 153 | return &PlainTextResponse[Params]{ 154 | Body: body, 155 | Params: params, 156 | } 157 | } 158 | 159 | // PlainText[Params] is a helper type that effectively works as both a PlainTextRequest[Params] and PlainTextResponse[Params] 160 | // This is mostly here for convenience 161 | type PlainText[Params any] struct { 162 | request *http.Request 163 | Body string 164 | Params Params 165 | } 166 | 167 | // Context returns the context that was part of the original http.Request 168 | func (r *PlainText[Params]) Context() context.Context { 169 | if r.request != nil { 170 | return r.request.Context() 171 | } 172 | return nil 173 | } 174 | 175 | // ReadRequest reads the body of an http request and assigns it to the Body field using io.ReadAll. 176 | // This function also reads the parameters using UnmarshalParams and assigns it to the Params field. 177 | // NOTE: the body of the request is closed after this function is run. 178 | func (r *PlainText[Params]) ReadRequest(req *http.Request) error { 179 | r.request = req 180 | return readPlainTextRequest(req, &r.Body, &r.Params) 181 | } 182 | 183 | // OpenAPIRequestSpec describes the RequestSpec for text/plain requests 184 | func (r *PlainText[Params]) OpenAPIRequestSpec() RequestSpec { 185 | schema := RequestSpec{} 186 | textRequestSpec[Params](&schema) 187 | return schema 188 | } 189 | 190 | // WriteBody writes the response body 191 | func (r *PlainText[Params]) WriteBody(write BodyWriteFunc) error { 192 | _, err := write([]byte(r.Body)) 193 | return err 194 | } 195 | 196 | // OpenAPIResponsesSpec describes the Responses for text/plain requests 197 | func (r *PlainText[Params]) OpenAPIResponsesSpec() Responses { 198 | schema := make(Responses) 199 | textResponsesSpec[Params](schema) 200 | return schema 201 | } 202 | 203 | // WriteHead writes the header for this response object 204 | func (r *PlainText[Params]) WriteHead(head *ResponseHead) error { 205 | head.Headers.Set("Content-Type", "text/plain") 206 | h, err := MarshalParams(&r.Params) 207 | if err != nil { 208 | return err 209 | } 210 | for k, v := range h { 211 | for _, x := range v { 212 | head.Headers.Add(k, x) 213 | } 214 | } 215 | return nil 216 | } 217 | -------------------------------------------------------------------------------- /xml.go: -------------------------------------------------------------------------------- 1 | package chimera 2 | 3 | // TODO: I want to support json but I need to be able to handle xml.Name and the use of 4 | // "attr"/"chardata"/"any"/"innerxml" in struct tags. Not sure how prevelant these are 5 | // or even the use of xml as whole since JSON is far more prevalent. Fundamentally I 6 | // just need to follow this guide https://swagger.io/docs/specification/data-models/representing-xml/ 7 | // and read all the rules in "encoding/xml". 8 | // TLDR: JSON is way easier, XML TBD 9 | --------------------------------------------------------------------------------