From 7769803836dde8f7974a90afd8caf84023dda85f Mon Sep 17 00:00:00 2001 From: Denis Mishankov Date: Fri, 28 Nov 2025 15:33:21 +0300 Subject: [PATCH 01/18] try open api router concept --- demo-app/cmd/open-api/main.go | 40 +++++++ go.mod | 6 ++ go.sum | 26 +++++ httpserver-v2/converters.go | 198 ++++++++++++++++++++++++++++++++++ httpserver-v2/httpserver.go | 144 +++++++++++++++++++++++++ 5 files changed, 414 insertions(+) create mode 100644 demo-app/cmd/open-api/main.go create mode 100644 httpserver-v2/converters.go create mode 100644 httpserver-v2/httpserver.go diff --git a/demo-app/cmd/open-api/main.go b/demo-app/cmd/open-api/main.go new file mode 100644 index 0000000..ecbbbcc --- /dev/null +++ b/demo-app/cmd/open-api/main.go @@ -0,0 +1,40 @@ +package main + +import ( + "net/http" + + httpserverv2 "github.com/platforma-dev/platforma/httpserver-v2" +) + +func main() { + router := httpserverv2.NewRouter("/openapi.yml", "/docs") + + type myQuery struct { + Name []string `query:"name"` + } + + type myReqHeaders struct { + UserAgent []string `header:"User-Agent"` + } + + type myRespHeaders struct { + XMen string `header:"X-Men"` + ContentType string `header:"Content-Type"` + } + + type myRespBody struct { + Data string `json:"data"` + } + + type myRequest = httpserverv2.Request[myQuery, myReqHeaders, any] + type myRespWriter = *httpserverv2.ResponseWriter[myRespHeaders, myRespBody] + + httpserverv2.Get(router, "/hello", func(w myRespWriter, r *myRequest) { + w.Headers.XMen = r.Query.Name[0] + w.Headers.ContentType = "application/json" + + w.SetBody(myRespBody{Data: "Hi, " + r.Query.Name[1]}) + }) + + http.ListenAndServe(":8080", router) +} diff --git a/go.mod b/go.mod index 1406126..090a719 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/google/uuid v1.6.0 github.com/jmoiron/sqlx v1.4.0 github.com/lib/pq v1.10.9 + github.com/oaswrap/spec v0.3.6 github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0 golang.org/x/crypto v0.43.0 ) @@ -42,6 +43,7 @@ require ( github.com/moby/sys/userns v0.1.0 // indirect github.com/moby/term v0.5.0 // indirect github.com/morikuni/aec v1.0.0 // indirect + github.com/oaswrap/spec-ui v0.1.4 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pkg/errors v0.9.1 // indirect @@ -50,6 +52,9 @@ require ( github.com/shirou/gopsutil/v4 v4.25.6 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/stretchr/testify v1.11.1 // indirect + github.com/swaggest/jsonschema-go v0.3.78 // indirect + github.com/swaggest/openapi-go v0.2.60 // indirect + github.com/swaggest/refl v1.4.0 // indirect github.com/testcontainers/testcontainers-go v0.40.0 // indirect github.com/tklauser/go-sysconf v0.3.12 // indirect github.com/tklauser/numcpus v0.6.1 // indirect @@ -63,5 +68,6 @@ require ( golang.org/x/sys v0.37.0 // indirect google.golang.org/grpc v1.75.1 // indirect google.golang.org/protobuf v1.36.10 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 4073ba4..fb9b944 100644 --- a/go.sum +++ b/go.sum @@ -8,6 +8,10 @@ github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOEl github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/bool64/dev v0.2.39 h1:kP8DnMGlWXhGYJEZE/J0l/gVBdbuhoPGL+MJG4QbofE= +github.com/bool64/dev v0.2.39/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg= +github.com/bool64/shared v0.1.5 h1:fp3eUhBsrSjNCQPcSdQqZxxh9bBwrYiZ+zOKFkM0/2E= +github.com/bool64/shared v0.1.5/go.mod h1:081yz68YC9jeFB3+Bbmno2RFWvGKv1lPKkMP6MHJlPs= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= @@ -53,6 +57,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4= +github.com/iancoleman/orderedmap v0.3.0 h1:5cbR2grmZR/DiVt+VJopEhtVs9YGInGIxAoMJn+Ichc= +github.com/iancoleman/orderedmap v0.3.0/go.mod h1:XuLcCUkdL5owUCQeF2Ue9uuw1EptkJDkXXS7VoV7XGE= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= @@ -97,6 +103,10 @@ github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/oaswrap/spec v0.3.6 h1:igKJvrrEYP/pK5I4TzEzYVcdbbr8eJ1gfALUXgZ/Oc8= +github.com/oaswrap/spec v0.3.6/go.mod h1:e6cGQJcVCkQozwsw8T0ydSWEgQPA/dHFmQME4KawOYU= +github.com/oaswrap/spec-ui v0.1.4 h1:XM2Z/ZS2Su90EtDSVuOHGr2+DLpVc2933mxkn6F4aeU= +github.com/oaswrap/spec-ui v0.1.4/go.mod h1:D8EnD6zbYJ3q65wdltw6QHXfw+nut5XwSSA1xtlSEQQ= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= @@ -109,6 +119,8 @@ github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= +github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= github.com/shirou/gopsutil/v4 v4.25.6 h1:kLysI2JsKorfaFPcYmcJqbzROzsBWEOAtw6A7dIfqXs= github.com/shirou/gopsutil/v4 v4.25.6/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= @@ -119,6 +131,14 @@ github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/swaggest/assertjson v1.9.0 h1:dKu0BfJkIxv/xe//mkCrK5yZbs79jL7OVf9Ija7o2xQ= +github.com/swaggest/assertjson v1.9.0/go.mod h1:b+ZKX2VRiUjxfUIal0HDN85W0nHPAYUbYH5WkkSsFsU= +github.com/swaggest/jsonschema-go v0.3.78 h1:5+YFQrLxOR8z6CHvgtZc42WRy/Q9zRQQ4HoAxlinlHw= +github.com/swaggest/jsonschema-go v0.3.78/go.mod h1:4nniXBuE+FIGkOGuidjOINMH7OEqZK3HCSbfDuLRI0g= +github.com/swaggest/openapi-go v0.2.60 h1:kglHH/WIfqAglfuWL4tu0LPakqNYySzklUWx06SjSKo= +github.com/swaggest/openapi-go v0.2.60/go.mod h1:jmFOuYdsWGtHU0BOuILlHZQJxLqHiAE6en+baE+QQUk= +github.com/swaggest/refl v1.4.0 h1:CftOSdTqRqs100xpFOT/Rifss5xBV/CT0S/FN60Xe9k= +github.com/swaggest/refl v1.4.0/go.mod h1:4uUVFVfPJ0NSX9FPwMPspeHos9wPFlCMGoPRllUbpvA= github.com/testcontainers/testcontainers-go v0.40.0 h1:pSdJYLOVgLE8YdUY2FHQ1Fxu+aMnb6JfVz1mxk7OeMU= github.com/testcontainers/testcontainers-go v0.40.0/go.mod h1:FSXV5KQtX2HAMlm7U3APNyLkkap35zNLxukw9oBi/MY= github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0 h1:s2bIayFXlbDFexo96y+htn7FzuhpXLYJNnIuglNKqOk= @@ -127,6 +147,10 @@ github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFA github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= +github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCOA= +github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg= +github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M= +github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= @@ -179,6 +203,8 @@ google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/httpserver-v2/converters.go b/httpserver-v2/converters.go new file mode 100644 index 0000000..efc037c --- /dev/null +++ b/httpserver-v2/converters.go @@ -0,0 +1,198 @@ +package httpserverv2 + +import ( + "errors" + "fmt" + "net/http" + "reflect" + "strconv" +) + +func mapFromStruct[T ~map[string][]string](in any, tag string) T { + out := make(map[string][]string) + v := reflect.ValueOf(in) + t := v.Type() + + for i := 0; i < v.NumField(); i++ { + field := t.Field(i) + key := field.Tag.Get(tag) + if key == "" { + continue + } + if tag == "header" { + key = http.CanonicalHeaderKey(key) + } + + fieldValue := v.Field(i) + var values []string + + // Check if the field is a slice + if fieldValue.Kind() == reflect.Slice { + for j := 0; j < fieldValue.Len(); j++ { + elem := fieldValue.Index(j) + values = append(values, fmt.Sprintf("%v", elem.Interface())) + } + } else { + // Convert non-slice fields to string + values = []string{fmt.Sprintf("%v", fieldValue.Interface())} + } + + out[key] = values + } + return out +} + +func mapToStruct[T ~map[string][]string](m T, tag string, out any) error { + v := reflect.ValueOf(out) + if v.Kind() != reflect.Pointer || v.IsNil() { + return errors.New("out must be a non-nil pointer to struct") + } + + v = v.Elem() + if v.Kind() != reflect.Struct { + return errors.New("out must be a pointer to struct") + } + + t := v.Type() + + for i := 0; i < v.NumField(); i++ { + field := v.Field(i) + fieldType := t.Field(i) + + // Get the header tag + tag := fieldType.Tag.Get(tag) + if tag == "" { + continue + } + + // Look up the value in the map + values, exists := m[tag] + if !exists || len(values) == 0 { + continue + } + + // Set the field value based on its type + if err := setField(field, values); err != nil { + return fmt.Errorf("field %s: %w", fieldType.Name, err) + } + } + + return nil +} + +// setField sets a struct field value with enhanced type support +func setField(field reflect.Value, values []string) error { + if !field.CanSet() { + return errors.New("cannot set field") + } + + fieldType := field.Type() + + switch fieldType.Kind() { + case reflect.String: + field.SetString(values[0]) + + case reflect.Slice: + return setSliceField(field, values) + + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + if len(values) > 0 { + intVal, err := strconv.ParseInt(values[0], 10, 64) + if err != nil { + return err + } + field.SetInt(intVal) + } + + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + if len(values) > 0 { + uintVal, err := strconv.ParseUint(values[0], 10, 64) + if err != nil { + return err + } + field.SetUint(uintVal) + } + + case reflect.Float32, reflect.Float64: + if len(values) > 0 { + floatVal, err := strconv.ParseFloat(values[0], 64) + if err != nil { + return err + } + field.SetFloat(floatVal) + } + + case reflect.Bool: + if len(values) > 0 { + boolVal, err := strconv.ParseBool(values[0]) + if err != nil { + return err + } + field.SetBool(boolVal) + } + + default: + return fmt.Errorf("unsupported field type: %s", fieldType.Kind()) + } + + return nil +} + +// setSliceField handles slice types +func setSliceField(field reflect.Value, values []string) error { + elemType := field.Type().Elem() + + switch elemType.Kind() { + case reflect.String: + field.Set(reflect.ValueOf(values)) + + case reflect.Int: + intSlice := make([]int, len(values)) + for i, v := range values { + intVal, err := strconv.Atoi(v) + if err != nil { + return err + } + intSlice[i] = intVal + } + field.Set(reflect.ValueOf(intSlice)) + + case reflect.Int64: + intSlice := make([]int64, len(values)) + for i, v := range values { + intVal, err := strconv.ParseInt(v, 10, 64) + if err != nil { + return err + } + intSlice[i] = intVal + } + field.Set(reflect.ValueOf(intSlice)) + + case reflect.Float64: + floatSlice := make([]float64, len(values)) + for i, v := range values { + floatVal, err := strconv.ParseFloat(v, 64) + if err != nil { + return err + } + floatSlice[i] = floatVal + } + field.Set(reflect.ValueOf(floatSlice)) + + case reflect.Bool: + boolSlice := make([]bool, len(values)) + for i, v := range values { + boolVal, err := strconv.ParseBool(v) + if err != nil { + return err + } + boolSlice[i] = boolVal + } + field.Set(reflect.ValueOf(boolSlice)) + + default: + return fmt.Errorf("unsupported slice element type: %s", elemType.Kind()) + } + + return nil +} diff --git a/httpserver-v2/httpserver.go b/httpserver-v2/httpserver.go new file mode 100644 index 0000000..c597871 --- /dev/null +++ b/httpserver-v2/httpserver.go @@ -0,0 +1,144 @@ +package httpserverv2 + +import ( + "encoding/json" + "net/http" + + "github.com/platforma-dev/platforma/log" + + "github.com/oaswrap/spec" + "github.com/oaswrap/spec/option" +) + +type Router struct { + mux http.ServeMux + specPath string // OpenAPI specifications path + docPath string // OpenAPI interactive documentation path +} + +func NewRouter(specPath, docPath string) *Router { + return &Router{ + mux: *http.NewServeMux(), + specPath: specPath, + docPath: docPath, + } +} + +func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) { + r.mux.ServeHTTP(w, req) +} + +type Request[Query, Headers, Body any] struct { + httpRequest *http.Request + Query Query + Headers Headers + bodyDecoded bool + body *Body +} + +func (r *Request[Query, Headers, Body]) Body() (*Body, error) { + if r.bodyDecoded { + return r.body, nil + } + + if err := json.NewDecoder(r.httpRequest.Body).Decode(r.body); err != nil { + return nil, err + } + r.bodyDecoded = true + + return r.body, nil +} + +type ResponseWriter[Headers, Body any] struct { + StatusCode int + Headers Headers + bodySet bool + body Body +} + +func (w *ResponseWriter[Headers, Body]) SetBody(b Body) { + w.body = b + w.bodySet = true +} + +type Handler[Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody any] func(w *ResponseWriter[ResponseHeaders, ResponseBody], r *Request[Query, RequestHeaders, RequestBody]) + +func Get[Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody any](router *Router, pattern string, handler Handler[Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody]) { + // Prepare open api spec + r := spec.NewRouter() + + // Add routes + v1 := r.Group("") + + v1.Get(pattern, + option.Summary("User login"), + option.Request(new(Query)), + option.Request(new(RequestHeaders)), + option.Request(new(RequestBody)), + option.Response(200, new(ResponseHeaders)), + option.Response(201, new(ResponseBody)), + ) + + if err := r.WriteSchemaTo("openapi.yaml"); err != nil { + log.Error(err.Error()) + } + + router.mux.HandleFunc(pattern, func(w http.ResponseWriter, r *http.Request) { + // Convert http request to user request + request := &Request[Query, RequestHeaders, RequestBody]{ + httpRequest: r, + } + // Query + var query Query + mapToStruct(r.URL.Query(), "query", &query) + request.Query = query + + // Headers + var requestHeaders RequestHeaders + mapToStruct(r.Header, "header", &requestHeaders) + request.Headers = requestHeaders + + // Call user handle + writer := ResponseWriter[ResponseHeaders, ResponseBody]{} + handler(&writer, request) + + // Headers + headers := mapFromStruct[map[string][]string](writer.Headers, "header") + for name, values := range headers { + for _, value := range values { + w.Header().Add(name, value) + } + } + + // Status code + if writer.StatusCode == 0 { + writer.StatusCode = http.StatusOK + } + w.WriteHeader(writer.StatusCode) + + // Body + if writer.bodySet { + if err := json.NewEncoder(w).Encode(writer.body); err != nil { + log.Error("failed to encode body", "error", err) + } + } + }) +} + +func main() { + type myQuery struct { + Name string `query:"name"` + } + + type myRespHeaders struct { + XMen string `header:"X-Men"` + } + + type myRequest = Request[myQuery, any, any] + type myRespWriter = *ResponseWriter[myRespHeaders, any] + + router := &Router{} + Get(router, "/hey", func(w myRespWriter, r *myRequest) { + w.Headers.XMen = r.Query.Name + }) +} From d8ef43d7fd06eea4ca8d4446fe26c26c66fb91b4 Mon Sep 17 00:00:00 2001 From: Denis Mishankov Date: Fri, 28 Nov 2025 21:45:57 +0300 Subject: [PATCH 02/18] multiple responses in open api spec --- demo-app/cmd/open-api/main.go | 59 +++++++++++++++++++++++------------ httpserver-v2/httpserver.go | 34 ++++++-------------- 2 files changed, 48 insertions(+), 45 deletions(-) diff --git a/demo-app/cmd/open-api/main.go b/demo-app/cmd/open-api/main.go index ecbbbcc..abe401c 100644 --- a/demo-app/cmd/open-api/main.go +++ b/demo-app/cmd/open-api/main.go @@ -6,34 +6,53 @@ import ( httpserverv2 "github.com/platforma-dev/platforma/httpserver-v2" ) -func main() { - router := httpserverv2.NewRouter("/openapi.yml", "/docs") +type myQuery struct { + Name []string `query:"name"` +} - type myQuery struct { - Name []string `query:"name"` - } +type myReqHeaders struct { + UserAgent []string `header:"User-Agent"` +} - type myReqHeaders struct { - UserAgent []string `header:"User-Agent"` - } +type myRespHeaders struct { + XMen []string `header:"X-Men"` + ContentType string `header:"Content-Type"` +} - type myRespHeaders struct { - XMen string `header:"X-Men"` - ContentType string `header:"Content-Type"` - } +type myRespBody struct { + Data []string `json:"data"` +} - type myRespBody struct { - Data string `json:"data"` - } +type errorResp struct { + ErrorMessage string `json:"errorMessage"` +} - type myRequest = httpserverv2.Request[myQuery, myReqHeaders, any] - type myRespWriter = *httpserverv2.ResponseWriter[myRespHeaders, myRespBody] +type myRequest = httpserverv2.Request[myQuery, myReqHeaders, any] +type myRespWriter = *httpserverv2.ResponseWriter[myRespHeaders, myRespBody] + +func main() { + router := httpserverv2.NewRouter("/docs/openapi.yml", "/docs") + + resps := map[int]any{ + http.StatusOK: struct { + myRespHeaders + myRespBody + }{}, + http.StatusCreated: struct { + myRespHeaders + myRespBody + }{}, + http.StatusBadRequest: struct { + myRespHeaders + errorResp + }{}, + } - httpserverv2.Get(router, "/hello", func(w myRespWriter, r *myRequest) { - w.Headers.XMen = r.Query.Name[0] + httpserverv2.Get(router, resps, "/hello", func(w myRespWriter, r *myRequest) { + w.Headers.XMen = r.Query.Name w.Headers.ContentType = "application/json" - w.SetBody(myRespBody{Data: "Hi, " + r.Query.Name[1]}) + w.SetBody(myRespBody{Data: r.Query.Name}) }) http.ListenAndServe(":8080", router) diff --git a/httpserver-v2/httpserver.go b/httpserver-v2/httpserver.go index c597871..55d7d19 100644 --- a/httpserver-v2/httpserver.go +++ b/httpserver-v2/httpserver.go @@ -63,26 +63,28 @@ func (w *ResponseWriter[Headers, Body]) SetBody(b Body) { type Handler[Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody any] func(w *ResponseWriter[ResponseHeaders, ResponseBody], r *Request[Query, RequestHeaders, RequestBody]) -func Get[Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody any](router *Router, pattern string, handler Handler[Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody]) { +func Get[Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody any](router *Router, resps map[int]any, pattern string, handler Handler[Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody]) { // Prepare open api spec r := spec.NewRouter() - // Add routes v1 := r.Group("") - v1.Get(pattern, - option.Summary("User login"), + opts := []option.OperationOption{ option.Request(new(Query)), option.Request(new(RequestHeaders)), option.Request(new(RequestBody)), - option.Response(200, new(ResponseHeaders)), - option.Response(201, new(ResponseBody)), - ) + } + for statusCode, respModel := range resps { + opts = append(opts, option.Response(statusCode, respModel)) + } + + v1.Get(pattern, opts...) if err := r.WriteSchemaTo("openapi.yaml"); err != nil { log.Error(err.Error()) } + // Add handler logic to mux router.mux.HandleFunc(pattern, func(w http.ResponseWriter, r *http.Request) { // Convert http request to user request request := &Request[Query, RequestHeaders, RequestBody]{ @@ -124,21 +126,3 @@ func Get[Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody any]( } }) } - -func main() { - type myQuery struct { - Name string `query:"name"` - } - - type myRespHeaders struct { - XMen string `header:"X-Men"` - } - - type myRequest = Request[myQuery, any, any] - type myRespWriter = *ResponseWriter[myRespHeaders, any] - - router := &Router{} - Get(router, "/hey", func(w myRespWriter, r *myRequest) { - w.Headers.XMen = r.Query.Name - }) -} From 655a5cf8404b3a72bf162f6fe7c2f70639b47980 Mon Sep 17 00:00:00 2001 From: Denis Mishankov Date: Fri, 28 Nov 2025 23:01:13 +0300 Subject: [PATCH 03/18] try any for body --- demo-app/cmd/open-api/main.go | 7 +++++++ httpserver-v2/httpserver.go | 4 ++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/demo-app/cmd/open-api/main.go b/demo-app/cmd/open-api/main.go index abe401c..bf74dc2 100644 --- a/demo-app/cmd/open-api/main.go +++ b/demo-app/cmd/open-api/main.go @@ -52,6 +52,13 @@ func main() { w.Headers.XMen = r.Query.Name w.Headers.ContentType = "application/json" + if r.Query.Name[0] == "xavier" { + w.StatusCode = http.StatusBadRequest + w.SetBody(errorResp{ErrorMessage: "banned superhero"}) + + return + } + w.SetBody(myRespBody{Data: r.Query.Name}) }) diff --git a/httpserver-v2/httpserver.go b/httpserver-v2/httpserver.go index 55d7d19..13e474a 100644 --- a/httpserver-v2/httpserver.go +++ b/httpserver-v2/httpserver.go @@ -53,10 +53,10 @@ type ResponseWriter[Headers, Body any] struct { StatusCode int Headers Headers bodySet bool - body Body + body any } -func (w *ResponseWriter[Headers, Body]) SetBody(b Body) { +func (w *ResponseWriter[Headers, Body]) SetBody(b any) { w.body = b w.bodySet = true } From a1a9a1e14f46eaae8a1689c6747e606a42d8f2f1 Mon Sep 17 00:00:00 2001 From: Denis Mishankov Date: Fri, 28 Nov 2025 23:31:47 +0300 Subject: [PATCH 04/18] new approach --- demo-app/cmd/open-api/main.go | 23 ++++++++++++++--------- httpserver-v2/httpserver.go | 4 ++-- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/demo-app/cmd/open-api/main.go b/demo-app/cmd/open-api/main.go index bf74dc2..d0ab715 100644 --- a/demo-app/cmd/open-api/main.go +++ b/demo-app/cmd/open-api/main.go @@ -19,12 +19,17 @@ type myRespHeaders struct { ContentType string `header:"Content-Type"` } -type myRespBody struct { - Data []string `json:"data"` +type errorRespBody struct { + ErrorMessage string `json:"errorMessage,omitempty"` +} + +type successRespBody struct { + Data []string `json:"data,omitempty"` } -type errorResp struct { - ErrorMessage string `json:"errorMessage"` +type myRespBody struct { + errorRespBody + successRespBody } type myRequest = httpserverv2.Request[myQuery, myReqHeaders, any] @@ -36,15 +41,15 @@ func main() { resps := map[int]any{ http.StatusOK: struct { myRespHeaders - myRespBody + successRespBody }{}, http.StatusCreated: struct { myRespHeaders - myRespBody + successRespBody }{}, http.StatusBadRequest: struct { myRespHeaders - errorResp + errorRespBody }{}, } @@ -54,12 +59,12 @@ func main() { if r.Query.Name[0] == "xavier" { w.StatusCode = http.StatusBadRequest - w.SetBody(errorResp{ErrorMessage: "banned superhero"}) + w.SetBody(myRespBody{errorRespBody: errorRespBody{ErrorMessage: "superhero banned"}}) return } - w.SetBody(myRespBody{Data: r.Query.Name}) + w.SetBody(myRespBody{successRespBody: successRespBody{Data: r.Query.Name}}) }) http.ListenAndServe(":8080", router) diff --git a/httpserver-v2/httpserver.go b/httpserver-v2/httpserver.go index 13e474a..55d7d19 100644 --- a/httpserver-v2/httpserver.go +++ b/httpserver-v2/httpserver.go @@ -53,10 +53,10 @@ type ResponseWriter[Headers, Body any] struct { StatusCode int Headers Headers bodySet bool - body any + body Body } -func (w *ResponseWriter[Headers, Body]) SetBody(b any) { +func (w *ResponseWriter[Headers, Body]) SetBody(b Body) { w.body = b w.bodySet = true } From 041045ea170f4f3de339af30e36c081073ca3975 Mon Sep 17 00:00:00 2001 From: Denis Mishankov Date: Fri, 28 Nov 2025 23:53:43 +0300 Subject: [PATCH 05/18] Migrate from httpserverv2 to openapiserver package The package has been renamed from `github.com/platforma-dev/platforma/httpserver-v2` to `github.com/platforma-dev/platforma/openapiserver`. All imports and type references have been updated accordingly. --- demo-app/cmd/open-api/main.go | 10 +++++----- {httpserver-v2 => openapiserver}/converters.go | 2 +- {httpserver-v2 => openapiserver}/httpserver.go | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) rename {httpserver-v2 => openapiserver}/converters.go (99%) rename {httpserver-v2 => openapiserver}/httpserver.go (99%) diff --git a/demo-app/cmd/open-api/main.go b/demo-app/cmd/open-api/main.go index d0ab715..bb68a5d 100644 --- a/demo-app/cmd/open-api/main.go +++ b/demo-app/cmd/open-api/main.go @@ -3,7 +3,7 @@ package main import ( "net/http" - httpserverv2 "github.com/platforma-dev/platforma/httpserver-v2" + "github.com/platforma-dev/platforma/openapiserver" ) type myQuery struct { @@ -32,11 +32,11 @@ type myRespBody struct { successRespBody } -type myRequest = httpserverv2.Request[myQuery, myReqHeaders, any] -type myRespWriter = *httpserverv2.ResponseWriter[myRespHeaders, myRespBody] +type myRequest = openapiserver.Request[myQuery, myReqHeaders, any] +type myRespWriter = *openapiserver.ResponseWriter[myRespHeaders, myRespBody] func main() { - router := httpserverv2.NewRouter("/docs/openapi.yml", "/docs") + router := openapiserver.NewRouter("/docs/openapi.yml", "/docs") resps := map[int]any{ http.StatusOK: struct { @@ -53,7 +53,7 @@ func main() { }{}, } - httpserverv2.Get(router, resps, "/hello", func(w myRespWriter, r *myRequest) { + openapiserver.Get(router, resps, "/hello", func(w myRespWriter, r *myRequest) { w.Headers.XMen = r.Query.Name w.Headers.ContentType = "application/json" diff --git a/httpserver-v2/converters.go b/openapiserver/converters.go similarity index 99% rename from httpserver-v2/converters.go rename to openapiserver/converters.go index efc037c..1d123e5 100644 --- a/httpserver-v2/converters.go +++ b/openapiserver/converters.go @@ -1,4 +1,4 @@ -package httpserverv2 +package openapiserver import ( "errors" diff --git a/httpserver-v2/httpserver.go b/openapiserver/httpserver.go similarity index 99% rename from httpserver-v2/httpserver.go rename to openapiserver/httpserver.go index 55d7d19..f9293f7 100644 --- a/httpserver-v2/httpserver.go +++ b/openapiserver/httpserver.go @@ -1,4 +1,4 @@ -package httpserverv2 +package openapiserver import ( "encoding/json" From fc1dd2d840c28541ac525068487f0052bb67db84 Mon Sep 17 00:00:00 2001 From: Denis Mishankov Date: Fri, 28 Nov 2025 23:58:56 +0300 Subject: [PATCH 06/18] aplit to files --- openapiserver/request.go | 27 ++++++++++++++++ openapiserver/responsewriter.go | 13 ++++++++ openapiserver/{httpserver.go => router.go} | 37 ++-------------------- 3 files changed, 42 insertions(+), 35 deletions(-) create mode 100644 openapiserver/request.go create mode 100644 openapiserver/responsewriter.go rename openapiserver/{httpserver.go => router.go} (80%) diff --git a/openapiserver/request.go b/openapiserver/request.go new file mode 100644 index 0000000..4d618b9 --- /dev/null +++ b/openapiserver/request.go @@ -0,0 +1,27 @@ +package openapiserver + +import ( + "encoding/json" + "net/http" +) + +type Request[Query, Headers, Body any] struct { + httpRequest *http.Request + Query Query + Headers Headers + bodyDecoded bool + body *Body +} + +func (r *Request[Query, Headers, Body]) Body() (*Body, error) { + if r.bodyDecoded { + return r.body, nil + } + + if err := json.NewDecoder(r.httpRequest.Body).Decode(r.body); err != nil { + return nil, err + } + r.bodyDecoded = true + + return r.body, nil +} diff --git a/openapiserver/responsewriter.go b/openapiserver/responsewriter.go new file mode 100644 index 0000000..f41d3b1 --- /dev/null +++ b/openapiserver/responsewriter.go @@ -0,0 +1,13 @@ +package openapiserver + +type ResponseWriter[Headers, Body any] struct { + StatusCode int + Headers Headers + bodySet bool + body Body +} + +func (w *ResponseWriter[Headers, Body]) SetBody(b Body) { + w.body = b + w.bodySet = true +} diff --git a/openapiserver/httpserver.go b/openapiserver/router.go similarity index 80% rename from openapiserver/httpserver.go rename to openapiserver/router.go index f9293f7..e7142ff 100644 --- a/openapiserver/httpserver.go +++ b/openapiserver/router.go @@ -4,14 +4,14 @@ import ( "encoding/json" "net/http" - "github.com/platforma-dev/platforma/log" - "github.com/oaswrap/spec" "github.com/oaswrap/spec/option" + "github.com/platforma-dev/platforma/log" ) type Router struct { mux http.ServeMux + spec any specPath string // OpenAPI specifications path docPath string // OpenAPI interactive documentation path } @@ -28,39 +28,6 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) { r.mux.ServeHTTP(w, req) } -type Request[Query, Headers, Body any] struct { - httpRequest *http.Request - Query Query - Headers Headers - bodyDecoded bool - body *Body -} - -func (r *Request[Query, Headers, Body]) Body() (*Body, error) { - if r.bodyDecoded { - return r.body, nil - } - - if err := json.NewDecoder(r.httpRequest.Body).Decode(r.body); err != nil { - return nil, err - } - r.bodyDecoded = true - - return r.body, nil -} - -type ResponseWriter[Headers, Body any] struct { - StatusCode int - Headers Headers - bodySet bool - body Body -} - -func (w *ResponseWriter[Headers, Body]) SetBody(b Body) { - w.body = b - w.bodySet = true -} - type Handler[Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody any] func(w *ResponseWriter[ResponseHeaders, ResponseBody], r *Request[Query, RequestHeaders, RequestBody]) func Get[Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody any](router *Router, resps map[int]any, pattern string, handler Handler[Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody]) { From 528daed828c6f9cbf654d51eca7e460289aaa5c5 Mon Sep 17 00:00:00 2001 From: Denis Mishankov Date: Sat, 29 Nov 2025 00:34:05 +0300 Subject: [PATCH 07/18] add group --- demo-app/cmd/open-api/main.go | 6 ++- openapiserver/group.go | 20 ++++++++++ openapiserver/handler.go | 67 ++++++++++++++++++++++++++++++++++ openapiserver/router.go | 69 ++--------------------------------- 4 files changed, 96 insertions(+), 66 deletions(-) create mode 100644 openapiserver/group.go create mode 100644 openapiserver/handler.go diff --git a/demo-app/cmd/open-api/main.go b/demo-app/cmd/open-api/main.go index bb68a5d..2b5ec33 100644 --- a/demo-app/cmd/open-api/main.go +++ b/demo-app/cmd/open-api/main.go @@ -53,7 +53,9 @@ func main() { }{}, } - openapiserver.Get(router, resps, "/hello", func(w myRespWriter, r *myRequest) { + helloGroup := openapiserver.NewGroup(router, "") + + openapiserver.Get(helloGroup, resps, "/hello", func(w myRespWriter, r *myRequest) { w.Headers.XMen = r.Query.Name w.Headers.ContentType = "application/json" @@ -67,5 +69,7 @@ func main() { w.SetBody(myRespBody{successRespBody: successRespBody{Data: r.Query.Name}}) }) + router.OpenAPI() + http.ListenAndServe(":8080", router) } diff --git a/openapiserver/group.go b/openapiserver/group.go new file mode 100644 index 0000000..32341e0 --- /dev/null +++ b/openapiserver/group.go @@ -0,0 +1,20 @@ +package openapiserver + +import ( + "net/http" + + "github.com/oaswrap/spec" +) + +type Group struct { + spec spec.Router + mux *http.ServeMux +} + +func NewGroup(router *Router, pattern string) *Group { + group := router.spec.Group(pattern) + return &Group{ + spec: group, + mux: http.NewServeMux(), + } +} diff --git a/openapiserver/handler.go b/openapiserver/handler.go new file mode 100644 index 0000000..ba081a1 --- /dev/null +++ b/openapiserver/handler.go @@ -0,0 +1,67 @@ +package openapiserver + +import ( + "encoding/json" + "net/http" + + "github.com/oaswrap/spec/option" + "github.com/platforma-dev/platforma/log" +) + +type Handler[Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody any] func(w *ResponseWriter[ResponseHeaders, ResponseBody], r *Request[Query, RequestHeaders, RequestBody]) + +func Get[Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody any](group *Group, resps map[int]any, pattern string, handler Handler[Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody]) { + // Prepare open api spec + opts := []option.OperationOption{ + option.Request(new(Query)), + option.Request(new(RequestHeaders)), + option.Request(new(RequestBody)), + } + for statusCode, respModel := range resps { + opts = append(opts, option.Response(statusCode, respModel)) + } + + group.spec.Get(pattern, opts...) + + // Add handler logic to mux + group.mux.HandleFunc(pattern, func(w http.ResponseWriter, r *http.Request) { + // Convert http request to user request + request := &Request[Query, RequestHeaders, RequestBody]{ + httpRequest: r, + } + // Query + var query Query + mapToStruct(r.URL.Query(), "query", &query) + request.Query = query + + // Headers + var requestHeaders RequestHeaders + mapToStruct(r.Header, "header", &requestHeaders) + request.Headers = requestHeaders + + // Call user handle + writer := ResponseWriter[ResponseHeaders, ResponseBody]{} + handler(&writer, request) + + // Headers + headers := mapFromStruct[map[string][]string](writer.Headers, "header") + for name, values := range headers { + for _, value := range values { + w.Header().Add(name, value) + } + } + + // Status code + if writer.StatusCode == 0 { + writer.StatusCode = http.StatusOK + } + w.WriteHeader(writer.StatusCode) + + // Body + if writer.bodySet { + if err := json.NewEncoder(w).Encode(writer.body); err != nil { + log.Error("failed to encode body", "error", err) + } + } + }) +} diff --git a/openapiserver/router.go b/openapiserver/router.go index e7142ff..f601924 100644 --- a/openapiserver/router.go +++ b/openapiserver/router.go @@ -1,17 +1,15 @@ package openapiserver import ( - "encoding/json" "net/http" "github.com/oaswrap/spec" - "github.com/oaswrap/spec/option" "github.com/platforma-dev/platforma/log" ) type Router struct { mux http.ServeMux - spec any + spec spec.Generator specPath string // OpenAPI specifications path docPath string // OpenAPI interactive documentation path } @@ -19,6 +17,7 @@ type Router struct { func NewRouter(specPath, docPath string) *Router { return &Router{ mux: *http.NewServeMux(), + spec: spec.NewRouter(), specPath: specPath, docPath: docPath, } @@ -28,68 +27,8 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) { r.mux.ServeHTTP(w, req) } -type Handler[Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody any] func(w *ResponseWriter[ResponseHeaders, ResponseBody], r *Request[Query, RequestHeaders, RequestBody]) - -func Get[Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody any](router *Router, resps map[int]any, pattern string, handler Handler[Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody]) { - // Prepare open api spec - r := spec.NewRouter() - - v1 := r.Group("") - - opts := []option.OperationOption{ - option.Request(new(Query)), - option.Request(new(RequestHeaders)), - option.Request(new(RequestBody)), - } - for statusCode, respModel := range resps { - opts = append(opts, option.Response(statusCode, respModel)) - } - - v1.Get(pattern, opts...) - - if err := r.WriteSchemaTo("openapi.yaml"); err != nil { +func (r *Router) OpenAPI() { + if err := r.spec.WriteSchemaTo("openapi.yaml"); err != nil { log.Error(err.Error()) } - - // Add handler logic to mux - router.mux.HandleFunc(pattern, func(w http.ResponseWriter, r *http.Request) { - // Convert http request to user request - request := &Request[Query, RequestHeaders, RequestBody]{ - httpRequest: r, - } - // Query - var query Query - mapToStruct(r.URL.Query(), "query", &query) - request.Query = query - - // Headers - var requestHeaders RequestHeaders - mapToStruct(r.Header, "header", &requestHeaders) - request.Headers = requestHeaders - - // Call user handle - writer := ResponseWriter[ResponseHeaders, ResponseBody]{} - handler(&writer, request) - - // Headers - headers := mapFromStruct[map[string][]string](writer.Headers, "header") - for name, values := range headers { - for _, value := range values { - w.Header().Add(name, value) - } - } - - // Status code - if writer.StatusCode == 0 { - writer.StatusCode = http.StatusOK - } - w.WriteHeader(writer.StatusCode) - - // Body - if writer.bodySet { - if err := json.NewEncoder(w).Encode(writer.body); err != nil { - log.Error("failed to encode body", "error", err) - } - } - }) } From f39ed75ca8c3a0b47c10d9cc9b71bd8f680a7839 Mon Sep 17 00:00:00 2001 From: Denis Mishankov Date: Sat, 29 Nov 2025 09:20:35 +0300 Subject: [PATCH 08/18] fix actual routing --- openapiserver/group.go | 14 ++++++++------ openapiserver/handler.go | 2 +- openapiserver/router.go | 19 ++++++++++--------- 3 files changed, 19 insertions(+), 16 deletions(-) diff --git a/openapiserver/group.go b/openapiserver/group.go index 32341e0..04aec68 100644 --- a/openapiserver/group.go +++ b/openapiserver/group.go @@ -1,20 +1,22 @@ package openapiserver import ( - "net/http" - "github.com/oaswrap/spec" + "github.com/platforma-dev/platforma/httpserver" ) type Group struct { - spec spec.Router - mux *http.ServeMux + spec spec.Router + handlerGroup *httpserver.HandlerGroup } func NewGroup(router *Router, pattern string) *Group { + hg := httpserver.NewHandlerGroup() + router.handlerGroup.HandleGroup(pattern, hg) group := router.spec.Group(pattern) + return &Group{ - spec: group, - mux: http.NewServeMux(), + spec: group, + handlerGroup: hg, } } diff --git a/openapiserver/handler.go b/openapiserver/handler.go index ba081a1..f2a49fa 100644 --- a/openapiserver/handler.go +++ b/openapiserver/handler.go @@ -24,7 +24,7 @@ func Get[Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody any]( group.spec.Get(pattern, opts...) // Add handler logic to mux - group.mux.HandleFunc(pattern, func(w http.ResponseWriter, r *http.Request) { + group.handlerGroup.HandleFunc(pattern, func(w http.ResponseWriter, r *http.Request) { // Convert http request to user request request := &Request[Query, RequestHeaders, RequestBody]{ httpRequest: r, diff --git a/openapiserver/router.go b/openapiserver/router.go index f601924..8f04f97 100644 --- a/openapiserver/router.go +++ b/openapiserver/router.go @@ -4,27 +4,28 @@ import ( "net/http" "github.com/oaswrap/spec" + "github.com/platforma-dev/platforma/httpserver" "github.com/platforma-dev/platforma/log" ) type Router struct { - mux http.ServeMux - spec spec.Generator - specPath string // OpenAPI specifications path - docPath string // OpenAPI interactive documentation path + handlerGroup *httpserver.HandlerGroup + spec spec.Generator + specPath string // OpenAPI specifications path + docPath string // OpenAPI interactive documentation path } func NewRouter(specPath, docPath string) *Router { return &Router{ - mux: *http.NewServeMux(), - spec: spec.NewRouter(), - specPath: specPath, - docPath: docPath, + handlerGroup: httpserver.NewHandlerGroup(), + spec: spec.NewRouter(), + specPath: specPath, + docPath: docPath, } } func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) { - r.mux.ServeHTTP(w, req) + r.handlerGroup.ServeHTTP(w, req) } func (r *Router) OpenAPI() { From 8d57959d680b8a644debee5de84517ad0aedb9c9 Mon Sep 17 00:00:00 2001 From: Denis Mishankov Date: Sat, 29 Nov 2025 09:42:23 +0300 Subject: [PATCH 09/18] try path and new methods --- demo-app/cmd/open-api/main.go | 16 +++++++++++++ openapiserver/handler.go | 42 ++++++++++++++++++++++++++++++++--- openapiserver/request.go | 5 +++-- 3 files changed, 58 insertions(+), 5 deletions(-) diff --git a/demo-app/cmd/open-api/main.go b/demo-app/cmd/open-api/main.go index 2b5ec33..3e45f74 100644 --- a/demo-app/cmd/open-api/main.go +++ b/demo-app/cmd/open-api/main.go @@ -69,6 +69,22 @@ func main() { w.SetBody(myRespBody{successRespBody: successRespBody{Data: r.Query.Name}}) }) + openapiserver.Put( + helloGroup, resps, "/hello/{id}", + func(w myRespWriter, r *openapiserver.Request[myQuery, myReqHeaders, any]) { + w.Headers.XMen = r.Query.Name + w.Headers.ContentType = "application/json" + + if r.Query.Name[0] == "xavier" { + w.StatusCode = http.StatusBadRequest + w.SetBody(myRespBody{errorRespBody: errorRespBody{ErrorMessage: "superhero banned"}}) + + return + } + + w.SetBody(myRespBody{successRespBody: successRespBody{Data: r.Query.Name}}) + }) + router.OpenAPI() http.ListenAndServe(":8080", router) diff --git a/openapiserver/handler.go b/openapiserver/handler.go index f2a49fa..3f9dbdb 100644 --- a/openapiserver/handler.go +++ b/openapiserver/handler.go @@ -8,9 +8,45 @@ import ( "github.com/platforma-dev/platforma/log" ) -type Handler[Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody any] func(w *ResponseWriter[ResponseHeaders, ResponseBody], r *Request[Query, RequestHeaders, RequestBody]) +type Handler[Path, Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody any] func(w *ResponseWriter[ResponseHeaders, ResponseBody], r *Request[Path, Query, RequestHeaders, RequestBody]) -func Get[Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody any](group *Group, resps map[int]any, pattern string, handler Handler[Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody]) { +func Get[Path, Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody any](group *Group, resps map[int]any, pattern string, handler Handler[Path, Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody]) { + Handle(group, resps, http.MethodGet, pattern, handler) +} + +func Head[Path, Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody any](group *Group, resps map[int]any, pattern string, handler Handler[Path, Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody]) { + Handle(group, resps, http.MethodHead, pattern, handler) +} + +func Post[Path, Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody any](group *Group, resps map[int]any, pattern string, handler Handler[Path, Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody]) { + Handle(group, resps, http.MethodPost, pattern, handler) +} + +func Put[Path, Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody any](group *Group, resps map[int]any, pattern string, handler Handler[Path, Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody]) { + Handle(group, resps, http.MethodPut, pattern, handler) +} + +func Patch[Path, Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody any](group *Group, resps map[int]any, pattern string, handler Handler[Path, Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody]) { + Handle(group, resps, http.MethodPatch, pattern, handler) +} + +func Delete[Path, Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody any](group *Group, resps map[int]any, pattern string, handler Handler[Path, Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody]) { + Handle(group, resps, http.MethodDelete, pattern, handler) +} + +func Connect[Path, Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody any](group *Group, resps map[int]any, pattern string, handler Handler[Path, Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody]) { + Handle(group, resps, http.MethodConnect, pattern, handler) +} + +func Options[Path, Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody any](group *Group, resps map[int]any, pattern string, handler Handler[Path, Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody]) { + Handle(group, resps, http.MethodOptions, pattern, handler) +} + +func Trace[Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody any](group *Group, resps map[int]any, pattern string, handler Handler[Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody]) { + Handle(group, resps, http.MethodTrace, pattern, handler) +} + +func Handle[Path, Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody any](group *Group, resps map[int]any, method string, pattern string, handler Handler[Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody]) { // Prepare open api spec opts := []option.OperationOption{ option.Request(new(Query)), @@ -21,7 +57,7 @@ func Get[Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody any]( opts = append(opts, option.Response(statusCode, respModel)) } - group.spec.Get(pattern, opts...) + group.spec.Add(method, pattern, opts...) // Add handler logic to mux group.handlerGroup.HandleFunc(pattern, func(w http.ResponseWriter, r *http.Request) { diff --git a/openapiserver/request.go b/openapiserver/request.go index 4d618b9..1ad3b9a 100644 --- a/openapiserver/request.go +++ b/openapiserver/request.go @@ -5,15 +5,16 @@ import ( "net/http" ) -type Request[Query, Headers, Body any] struct { +type Request[Path, Query, Headers, Body any] struct { httpRequest *http.Request + Path Path Query Query Headers Headers bodyDecoded bool body *Body } -func (r *Request[Query, Headers, Body]) Body() (*Body, error) { +func (r *Request[Path, Query, Headers, Body]) Body() (*Body, error) { if r.bodyDecoded { return r.body, nil } From 55abf3146b3be7ec71ca23a9fd9567657061082a Mon Sep 17 00:00:00 2001 From: Denis Mishankov Date: Sat, 29 Nov 2025 10:05:18 +0300 Subject: [PATCH 10/18] better request --- demo-app/cmd/open-api/main.go | 41 +++++++++--------- openapiserver/handler.go | 78 +++++++++++++++++------------------ openapiserver/request.go | 24 ++--------- 3 files changed, 61 insertions(+), 82 deletions(-) diff --git a/demo-app/cmd/open-api/main.go b/demo-app/cmd/open-api/main.go index 3e45f74..231e4d0 100644 --- a/demo-app/cmd/open-api/main.go +++ b/demo-app/cmd/open-api/main.go @@ -6,11 +6,8 @@ import ( "github.com/platforma-dev/platforma/openapiserver" ) -type myQuery struct { - Name []string `query:"name"` -} - -type myReqHeaders struct { +type myReq struct { + Name []string `query:"name"` UserAgent []string `header:"User-Agent"` } @@ -32,7 +29,7 @@ type myRespBody struct { successRespBody } -type myRequest = openapiserver.Request[myQuery, myReqHeaders, any] +type myRequest = openapiserver.Request[myReq] type myRespWriter = *openapiserver.ResponseWriter[myRespHeaders, myRespBody] func main() { @@ -55,35 +52,35 @@ func main() { helloGroup := openapiserver.NewGroup(router, "") - openapiserver.Get(helloGroup, resps, "/hello", func(w myRespWriter, r *myRequest) { - w.Headers.XMen = r.Query.Name + openapiserver.Get(helloGroup, resps, "/hello", func(w myRespWriter, r myRequest) { + w.Headers.XMen = r.Data.Name w.Headers.ContentType = "application/json" - if r.Query.Name[0] == "xavier" { + if r.Data.Name[0] == "xavier" { w.StatusCode = http.StatusBadRequest w.SetBody(myRespBody{errorRespBody: errorRespBody{ErrorMessage: "superhero banned"}}) return } - w.SetBody(myRespBody{successRespBody: successRespBody{Data: r.Query.Name}}) + w.SetBody(myRespBody{successRespBody: successRespBody{Data: r.Data.Name}}) }) - openapiserver.Put( - helloGroup, resps, "/hello/{id}", - func(w myRespWriter, r *openapiserver.Request[myQuery, myReqHeaders, any]) { - w.Headers.XMen = r.Query.Name - w.Headers.ContentType = "application/json" + // openapiserver.Put( + // helloGroup, resps, "/hello/{id}", + // func(w myRespWriter, r *openapiserver.Request[myQuery, myReqHeaders, any]) { + // w.Headers.XMen = r.Query.Name + // w.Headers.ContentType = "application/json" - if r.Query.Name[0] == "xavier" { - w.StatusCode = http.StatusBadRequest - w.SetBody(myRespBody{errorRespBody: errorRespBody{ErrorMessage: "superhero banned"}}) + // if r.Query.Name[0] == "xavier" { + // w.StatusCode = http.StatusBadRequest + // w.SetBody(myRespBody{errorRespBody: errorRespBody{ErrorMessage: "superhero banned"}}) - return - } + // return + // } - w.SetBody(myRespBody{successRespBody: successRespBody{Data: r.Query.Name}}) - }) + // w.SetBody(myRespBody{successRespBody: successRespBody{Data: r.Query.Name}}) + // }) router.OpenAPI() diff --git a/openapiserver/handler.go b/openapiserver/handler.go index 3f9dbdb..e6f25c5 100644 --- a/openapiserver/handler.go +++ b/openapiserver/handler.go @@ -8,50 +8,48 @@ import ( "github.com/platforma-dev/platforma/log" ) -type Handler[Path, Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody any] func(w *ResponseWriter[ResponseHeaders, ResponseBody], r *Request[Path, Query, RequestHeaders, RequestBody]) +type Handler[RequestType, ResponseHeaders, ResponseBody any] func(w *ResponseWriter[ResponseHeaders, ResponseBody], r Request[RequestType]) -func Get[Path, Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody any](group *Group, resps map[int]any, pattern string, handler Handler[Path, Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody]) { +func Get[RequestType, ResponseHeaders, ResponseBody any](group *Group, resps map[int]any, pattern string, handler Handler[RequestType, ResponseHeaders, ResponseBody]) { Handle(group, resps, http.MethodGet, pattern, handler) } -func Head[Path, Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody any](group *Group, resps map[int]any, pattern string, handler Handler[Path, Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody]) { - Handle(group, resps, http.MethodHead, pattern, handler) -} +// func Head[Path, Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody any](group *Group, resps map[int]any, pattern string, handler Handler[Path, Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody]) { +// Handle(group, resps, http.MethodHead, pattern, handler) +// } -func Post[Path, Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody any](group *Group, resps map[int]any, pattern string, handler Handler[Path, Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody]) { - Handle(group, resps, http.MethodPost, pattern, handler) -} +// func Post[Path, Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody any](group *Group, resps map[int]any, pattern string, handler Handler[Path, Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody]) { +// Handle(group, resps, http.MethodPost, pattern, handler) +// } -func Put[Path, Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody any](group *Group, resps map[int]any, pattern string, handler Handler[Path, Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody]) { - Handle(group, resps, http.MethodPut, pattern, handler) -} +// func Put[Path, Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody any](group *Group, resps map[int]any, pattern string, handler Handler[Path, Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody]) { +// Handle(group, resps, http.MethodPut, pattern, handler) +// } -func Patch[Path, Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody any](group *Group, resps map[int]any, pattern string, handler Handler[Path, Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody]) { - Handle(group, resps, http.MethodPatch, pattern, handler) -} +// func Patch[Path, Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody any](group *Group, resps map[int]any, pattern string, handler Handler[Path, Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody]) { +// Handle(group, resps, http.MethodPatch, pattern, handler) +// } -func Delete[Path, Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody any](group *Group, resps map[int]any, pattern string, handler Handler[Path, Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody]) { - Handle(group, resps, http.MethodDelete, pattern, handler) -} +// func Delete[Path, Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody any](group *Group, resps map[int]any, pattern string, handler Handler[Path, Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody]) { +// Handle(group, resps, http.MethodDelete, pattern, handler) +// } -func Connect[Path, Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody any](group *Group, resps map[int]any, pattern string, handler Handler[Path, Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody]) { - Handle(group, resps, http.MethodConnect, pattern, handler) -} +// func Connect[Path, Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody any](group *Group, resps map[int]any, pattern string, handler Handler[Path, Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody]) { +// Handle(group, resps, http.MethodConnect, pattern, handler) +// } -func Options[Path, Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody any](group *Group, resps map[int]any, pattern string, handler Handler[Path, Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody]) { - Handle(group, resps, http.MethodOptions, pattern, handler) -} +// func Options[Path, Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody any](group *Group, resps map[int]any, pattern string, handler Handler[Path, Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody]) { +// Handle(group, resps, http.MethodOptions, pattern, handler) +// } -func Trace[Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody any](group *Group, resps map[int]any, pattern string, handler Handler[Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody]) { - Handle(group, resps, http.MethodTrace, pattern, handler) -} +// func Trace[Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody any](group *Group, resps map[int]any, pattern string, handler Handler[Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody]) { +// Handle(group, resps, http.MethodTrace, pattern, handler) +// } -func Handle[Path, Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody any](group *Group, resps map[int]any, method string, pattern string, handler Handler[Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody]) { +func Handle[RequestType, ResponseHeaders, ResponseBody any](group *Group, resps map[int]any, method string, pattern string, handler Handler[RequestType, ResponseHeaders, ResponseBody]) { // Prepare open api spec opts := []option.OperationOption{ - option.Request(new(Query)), - option.Request(new(RequestHeaders)), - option.Request(new(RequestBody)), + option.Request(new(RequestType)), } for statusCode, respModel := range resps { opts = append(opts, option.Response(statusCode, respModel)) @@ -62,22 +60,24 @@ func Handle[Path, Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseB // Add handler logic to mux group.handlerGroup.HandleFunc(pattern, func(w http.ResponseWriter, r *http.Request) { // Convert http request to user request - request := &Request[Query, RequestHeaders, RequestBody]{ - httpRequest: r, + request := &Request[RequestType]{ + HttpRequest: r, + Data: new(RequestType), } // Query - var query Query - mapToStruct(r.URL.Query(), "query", &query) - request.Query = query + mapToStruct(r.URL.Query(), "query", request.Data) // Headers - var requestHeaders RequestHeaders - mapToStruct(r.Header, "header", &requestHeaders) - request.Headers = requestHeaders + mapToStruct(r.Header, "header", request.Data) + + // Body + if err := json.NewDecoder(r.Body).Decode(request.Data); err != nil { + log.Error("failed to decode body", "error", err) + } // Call user handle writer := ResponseWriter[ResponseHeaders, ResponseBody]{} - handler(&writer, request) + handler(&writer, *request) // Headers headers := mapFromStruct[map[string][]string](writer.Headers, "header") diff --git a/openapiserver/request.go b/openapiserver/request.go index 1ad3b9a..e629696 100644 --- a/openapiserver/request.go +++ b/openapiserver/request.go @@ -1,28 +1,10 @@ package openapiserver import ( - "encoding/json" "net/http" ) -type Request[Path, Query, Headers, Body any] struct { - httpRequest *http.Request - Path Path - Query Query - Headers Headers - bodyDecoded bool - body *Body -} - -func (r *Request[Path, Query, Headers, Body]) Body() (*Body, error) { - if r.bodyDecoded { - return r.body, nil - } - - if err := json.NewDecoder(r.httpRequest.Body).Decode(r.body); err != nil { - return nil, err - } - r.bodyDecoded = true - - return r.body, nil +type Request[T any] struct { + HttpRequest *http.Request + Data *T } From 3551e544d607f3c605cd67b2d25d4b539bfbbab1 Mon Sep 17 00:00:00 2001 From: Denis Mishankov Date: Sat, 29 Nov 2025 10:56:17 +0300 Subject: [PATCH 11/18] now support path paramaeters --- demo-app/cmd/open-api/main.go | 73 ++++++++++++++++++----------------- openapiserver/converters.go | 70 +++++++++++++++++++++++++++++++++ openapiserver/handler.go | 12 ++++-- 3 files changed, 116 insertions(+), 39 deletions(-) diff --git a/demo-app/cmd/open-api/main.go b/demo-app/cmd/open-api/main.go index 231e4d0..c187401 100644 --- a/demo-app/cmd/open-api/main.go +++ b/demo-app/cmd/open-api/main.go @@ -6,11 +6,6 @@ import ( "github.com/platforma-dev/platforma/openapiserver" ) -type myReq struct { - Name []string `query:"name"` - UserAgent []string `header:"User-Agent"` -} - type myRespHeaders struct { XMen []string `header:"X-Men"` ContentType string `header:"Content-Type"` @@ -29,7 +24,6 @@ type myRespBody struct { successRespBody } -type myRequest = openapiserver.Request[myReq] type myRespWriter = *openapiserver.ResponseWriter[myRespHeaders, myRespBody] func main() { @@ -52,35 +46,44 @@ func main() { helloGroup := openapiserver.NewGroup(router, "") - openapiserver.Get(helloGroup, resps, "/hello", func(w myRespWriter, r myRequest) { - w.Headers.XMen = r.Data.Name - w.Headers.ContentType = "application/json" - - if r.Data.Name[0] == "xavier" { - w.StatusCode = http.StatusBadRequest - w.SetBody(myRespBody{errorRespBody: errorRespBody{ErrorMessage: "superhero banned"}}) - - return - } - - w.SetBody(myRespBody{successRespBody: successRespBody{Data: r.Data.Name}}) - }) - - // openapiserver.Put( - // helloGroup, resps, "/hello/{id}", - // func(w myRespWriter, r *openapiserver.Request[myQuery, myReqHeaders, any]) { - // w.Headers.XMen = r.Query.Name - // w.Headers.ContentType = "application/json" - - // if r.Query.Name[0] == "xavier" { - // w.StatusCode = http.StatusBadRequest - // w.SetBody(myRespBody{errorRespBody: errorRespBody{ErrorMessage: "superhero banned"}}) - - // return - // } - - // w.SetBody(myRespBody{successRespBody: successRespBody{Data: r.Query.Name}}) - // }) + openapiserver.Get( + helloGroup, resps, "/hello", + func(w myRespWriter, r openapiserver.Request[struct { + Name []string `query:"name"` + UserAgent []string `header:"User-Agent"` + }]) { + w.Headers.XMen = r.Data.Name + w.Headers.ContentType = "application/json" + + if r.Data.Name[0] == "xavier" { + w.StatusCode = http.StatusBadRequest + w.SetBody(myRespBody{errorRespBody: errorRespBody{ErrorMessage: "superhero banned"}}) + + return + } + + w.SetBody(myRespBody{successRespBody: successRespBody{Data: r.Data.Name}}) + }) + + openapiserver.Put( + helloGroup, resps, "/hello/{id}", + func(w myRespWriter, r openapiserver.Request[struct { + Id string `path:"id"` + Name []string `query:"name"` + UserAgent []string `header:"User-Agent"` + }]) { + w.Headers.XMen = r.Data.Name + w.Headers.ContentType = "application/json" + + if r.Data.Name[0] == "xavier" { + w.StatusCode = http.StatusBadRequest + w.SetBody(myRespBody{errorRespBody: errorRespBody{ErrorMessage: "superhero banned"}}) + + return + } + + w.SetBody(myRespBody{successRespBody: successRespBody{Data: r.Data.Name}}) + }) router.OpenAPI() diff --git a/openapiserver/converters.go b/openapiserver/converters.go index 1d123e5..ee11d70 100644 --- a/openapiserver/converters.go +++ b/openapiserver/converters.go @@ -8,6 +8,76 @@ import ( "strconv" ) +func pathToStruct(r *http.Request, target any) error { + v := reflect.ValueOf(target) + if v.Kind() != reflect.Pointer || v.Elem().Kind() != reflect.Struct { + return fmt.Errorf("target must be a pointer to a struct") + } + + v = v.Elem() + t := v.Type() + + for i := 0; i < v.NumField(); i++ { + field := v.Field(i) + structField := t.Field(i) + + // Get path tag value + pathTag := structField.Tag.Get("path") + if pathTag == "" { + continue + } + + // Get path parameter value + paramValue := r.PathValue(pathTag) + if paramValue == "" { + continue + } + + // Set field based on its type + if !field.CanSet() { + return fmt.Errorf("field %s cannot be set", structField.Name) + } + + switch field.Kind() { + case reflect.String: + field.SetString(paramValue) + + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + if intValue, err := strconv.ParseInt(paramValue, 10, 64); err == nil { + field.SetInt(intValue) + } else { + return fmt.Errorf("invalid int value %s for field %s", paramValue, structField.Name) + } + + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + if uintValue, err := strconv.ParseUint(paramValue, 10, 64); err == nil { + field.SetUint(uintValue) + } else { + return fmt.Errorf("invalid uint value %s for field %s", paramValue, structField.Name) + } + + case reflect.Bool: + if boolValue, err := strconv.ParseBool(paramValue); err == nil { + field.SetBool(boolValue) + } else { + return fmt.Errorf("invalid bool value %s for field %s", paramValue, structField.Name) + } + + case reflect.Float32, reflect.Float64: + if floatValue, err := strconv.ParseFloat(paramValue, 64); err == nil { + field.SetFloat(floatValue) + } else { + return fmt.Errorf("invalid float value %s for field %s", paramValue, structField.Name) + } + + default: + return fmt.Errorf("unsupported field type %s for field %s", field.Kind(), structField.Name) + } + } + + return nil +} + func mapFromStruct[T ~map[string][]string](in any, tag string) T { out := make(map[string][]string) v := reflect.ValueOf(in) diff --git a/openapiserver/handler.go b/openapiserver/handler.go index e6f25c5..56be378 100644 --- a/openapiserver/handler.go +++ b/openapiserver/handler.go @@ -22,9 +22,9 @@ func Get[RequestType, ResponseHeaders, ResponseBody any](group *Group, resps map // Handle(group, resps, http.MethodPost, pattern, handler) // } -// func Put[Path, Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody any](group *Group, resps map[int]any, pattern string, handler Handler[Path, Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody]) { -// Handle(group, resps, http.MethodPut, pattern, handler) -// } +func Put[RequestType, ResponseHeaders, ResponseBody any](group *Group, resps map[int]any, pattern string, handler Handler[RequestType, ResponseHeaders, ResponseBody]) { + Handle(group, resps, http.MethodPut, pattern, handler) +} // func Patch[Path, Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody any](group *Group, resps map[int]any, pattern string, handler Handler[Path, Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody]) { // Handle(group, resps, http.MethodPatch, pattern, handler) @@ -58,12 +58,16 @@ func Handle[RequestType, ResponseHeaders, ResponseBody any](group *Group, resps group.spec.Add(method, pattern, opts...) // Add handler logic to mux - group.handlerGroup.HandleFunc(pattern, func(w http.ResponseWriter, r *http.Request) { + group.handlerGroup.HandleFunc(method+" "+pattern, func(w http.ResponseWriter, r *http.Request) { // Convert http request to user request request := &Request[RequestType]{ HttpRequest: r, Data: new(RequestType), } + + // Path + pathToStruct(r, request.Data) + // Query mapToStruct(r.URL.Query(), "query", request.Data) From 5254bfb59aaf1701c8d1ab069965f631d75c841f Mon Sep 17 00:00:00 2001 From: Denis Mishankov Date: Sat, 29 Nov 2025 20:02:50 +0300 Subject: [PATCH 12/18] Add HTTP method handler functions Uncomment and standardize Head, Post, Patch, Delete, Connect, Options, and Trace handler functions to use consistent RequestType parameter. --- openapiserver/handler.go | 42 ++++++++++++++++++++-------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/openapiserver/handler.go b/openapiserver/handler.go index 56be378..524bb36 100644 --- a/openapiserver/handler.go +++ b/openapiserver/handler.go @@ -14,37 +14,37 @@ func Get[RequestType, ResponseHeaders, ResponseBody any](group *Group, resps map Handle(group, resps, http.MethodGet, pattern, handler) } -// func Head[Path, Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody any](group *Group, resps map[int]any, pattern string, handler Handler[Path, Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody]) { -// Handle(group, resps, http.MethodHead, pattern, handler) -// } +func Head[RequestType, ResponseHeaders, ResponseBody any](group *Group, resps map[int]any, pattern string, handler Handler[RequestType, ResponseHeaders, ResponseBody]) { + Handle(group, resps, http.MethodHead, pattern, handler) +} -// func Post[Path, Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody any](group *Group, resps map[int]any, pattern string, handler Handler[Path, Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody]) { -// Handle(group, resps, http.MethodPost, pattern, handler) -// } +func Post[RequestType, ResponseHeaders, ResponseBody any](group *Group, resps map[int]any, pattern string, handler Handler[RequestType, ResponseHeaders, ResponseBody]) { + Handle(group, resps, http.MethodPost, pattern, handler) +} func Put[RequestType, ResponseHeaders, ResponseBody any](group *Group, resps map[int]any, pattern string, handler Handler[RequestType, ResponseHeaders, ResponseBody]) { Handle(group, resps, http.MethodPut, pattern, handler) } -// func Patch[Path, Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody any](group *Group, resps map[int]any, pattern string, handler Handler[Path, Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody]) { -// Handle(group, resps, http.MethodPatch, pattern, handler) -// } +func Patch[RequestType, ResponseHeaders, ResponseBody any](group *Group, resps map[int]any, pattern string, handler Handler[RequestType, ResponseHeaders, ResponseBody]) { + Handle(group, resps, http.MethodPatch, pattern, handler) +} -// func Delete[Path, Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody any](group *Group, resps map[int]any, pattern string, handler Handler[Path, Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody]) { -// Handle(group, resps, http.MethodDelete, pattern, handler) -// } +func Delete[RequestType, ResponseHeaders, ResponseBody any](group *Group, resps map[int]any, pattern string, handler Handler[RequestType, ResponseHeaders, ResponseBody]) { + Handle(group, resps, http.MethodDelete, pattern, handler) +} -// func Connect[Path, Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody any](group *Group, resps map[int]any, pattern string, handler Handler[Path, Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody]) { -// Handle(group, resps, http.MethodConnect, pattern, handler) -// } +func Connect[RequestType, ResponseHeaders, ResponseBody any](group *Group, resps map[int]any, pattern string, handler Handler[RequestType, ResponseHeaders, ResponseBody]) { + Handle(group, resps, http.MethodConnect, pattern, handler) +} -// func Options[Path, Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody any](group *Group, resps map[int]any, pattern string, handler Handler[Path, Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody]) { -// Handle(group, resps, http.MethodOptions, pattern, handler) -// } +func Options[RequestType, ResponseHeaders, ResponseBody any](group *Group, resps map[int]any, pattern string, handler Handler[RequestType, ResponseHeaders, ResponseBody]) { + Handle(group, resps, http.MethodOptions, pattern, handler) +} -// func Trace[Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody any](group *Group, resps map[int]any, pattern string, handler Handler[Query, RequestHeaders, RequestBody, ResponseHeaders, ResponseBody]) { -// Handle(group, resps, http.MethodTrace, pattern, handler) -// } +func Trace[RequestType, ResponseHeaders, ResponseBody any](group *Group, resps map[int]any, pattern string, handler Handler[RequestType, ResponseHeaders, ResponseBody]) { + Handle(group, resps, http.MethodTrace, pattern, handler) +} func Handle[RequestType, ResponseHeaders, ResponseBody any](group *Group, resps map[int]any, method string, pattern string, handler Handler[RequestType, ResponseHeaders, ResponseBody]) { // Prepare open api spec From 9ee819bc6ba97ffa044580440136ee5653788683 Mon Sep 17 00:00:00 2001 From: Denis Mishankov Date: Sun, 30 Nov 2025 00:36:40 +0300 Subject: [PATCH 13/18] Add spec-ui dependency and integrate OpenAPI docs The github.com/oaswrap/spec-ui dependency is now a direct requirement instead of indirect. The router now serves OpenAPI documentation at the configured spec and docs paths using the Scalar renderer. --- go.mod | 2 +- openapiserver/router.go | 20 ++++++++++++++++++-- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 090a719..91d682d 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/jmoiron/sqlx v1.4.0 github.com/lib/pq v1.10.9 github.com/oaswrap/spec v0.3.6 + github.com/oaswrap/spec-ui v0.1.4 github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0 golang.org/x/crypto v0.43.0 ) @@ -43,7 +44,6 @@ require ( github.com/moby/sys/userns v0.1.0 // indirect github.com/moby/term v0.5.0 // indirect github.com/morikuni/aec v1.0.0 // indirect - github.com/oaswrap/spec-ui v0.1.4 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pkg/errors v0.9.1 // indirect diff --git a/openapiserver/router.go b/openapiserver/router.go index 8f04f97..b95ddaf 100644 --- a/openapiserver/router.go +++ b/openapiserver/router.go @@ -4,6 +4,7 @@ import ( "net/http" "github.com/oaswrap/spec" + specui "github.com/oaswrap/spec-ui" "github.com/platforma-dev/platforma/httpserver" "github.com/platforma-dev/platforma/log" ) @@ -16,9 +17,24 @@ type Router struct { } func NewRouter(specPath, docPath string) *Router { + hg := httpserver.NewHandlerGroup() + sp := spec.NewRouter() + + if specPath != "" { + openapiHandler := specui.NewHandler( + specui.WithDocsPath(docPath), + specui.WithSpecPath(specPath), + specui.WithSpecGenerator(sp), + specui.WithScalar(), + ) + + hg.Handle(openapiHandler.SpecPath(), openapiHandler.Spec()) + hg.Handle(openapiHandler.DocsPath(), openapiHandler.Docs()) + } + return &Router{ - handlerGroup: httpserver.NewHandlerGroup(), - spec: spec.NewRouter(), + handlerGroup: hg, + spec: sp, specPath: specPath, docPath: docPath, } From 2964d85510b00de6d4bbb93b63bf69224773741b Mon Sep 17 00:00:00 2001 From: Denis Mishankov Date: Sun, 30 Nov 2025 00:51:57 +0300 Subject: [PATCH 14/18] Remove OpenAPI schema generation call The OpenAPI schema generation is no longer needed as the specification is now handled through other means. The unused log import is also removed. --- demo-app/cmd/open-api/main.go | 2 -- openapiserver/router.go | 7 ------- 2 files changed, 9 deletions(-) diff --git a/demo-app/cmd/open-api/main.go b/demo-app/cmd/open-api/main.go index c187401..795423f 100644 --- a/demo-app/cmd/open-api/main.go +++ b/demo-app/cmd/open-api/main.go @@ -85,7 +85,5 @@ func main() { w.SetBody(myRespBody{successRespBody: successRespBody{Data: r.Data.Name}}) }) - router.OpenAPI() - http.ListenAndServe(":8080", router) } diff --git a/openapiserver/router.go b/openapiserver/router.go index b95ddaf..9e1e0a0 100644 --- a/openapiserver/router.go +++ b/openapiserver/router.go @@ -6,7 +6,6 @@ import ( "github.com/oaswrap/spec" specui "github.com/oaswrap/spec-ui" "github.com/platforma-dev/platforma/httpserver" - "github.com/platforma-dev/platforma/log" ) type Router struct { @@ -43,9 +42,3 @@ func NewRouter(specPath, docPath string) *Router { func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) { r.handlerGroup.ServeHTTP(w, req) } - -func (r *Router) OpenAPI() { - if err := r.spec.WriteSchemaTo("openapi.yaml"); err != nil { - log.Error(err.Error()) - } -} From 7a20554785555f6aed82b0030383fb378a513426 Mon Sep 17 00:00:00 2001 From: Denis Mishankov Date: Sun, 30 Nov 2025 10:13:35 +0300 Subject: [PATCH 15/18] fix lint issues --- .golangci.yml | 3 ++ openapiserver/converters.go | 60 +++++++++++++++++++-------------- openapiserver/group.go | 2 ++ openapiserver/handler.go | 25 +++++++++++--- openapiserver/request.go | 3 +- openapiserver/responsewriter.go | 2 ++ openapiserver/router.go | 2 ++ 7 files changed, 66 insertions(+), 31 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index 33e4659..c5e8103 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -50,6 +50,9 @@ linters: - wrapcheck settings: + exhaustive: + default-signifies-exhaustive: true + iface: enable: - identical # Identifies interfaces in the same package that have identical method sets. diff --git a/openapiserver/converters.go b/openapiserver/converters.go index ee11d70..65624c4 100644 --- a/openapiserver/converters.go +++ b/openapiserver/converters.go @@ -1,3 +1,4 @@ +// Package openapiserver provides utilities for handling OpenAPI server requests and responses. package openapiserver import ( @@ -8,16 +9,23 @@ import ( "strconv" ) +// Error definitions for converter functions. +var ( + ErrFieldNotSettable = errors.New("field is not settable") + ErrOutMustBePointerToStruct = errors.New("out must be a non-nil pointer to struct") + ErrOutMustBePointer = errors.New("out must be a pointer to struct") + ErrCannotSetField = errors.New("cannot set field") + ErrUnsupportedFieldType = errors.New("unsupported field type") + ErrUnsupportedSliceElemType = errors.New("unsupported slice element type") +) + func pathToStruct(r *http.Request, target any) error { v := reflect.ValueOf(target) - if v.Kind() != reflect.Pointer || v.Elem().Kind() != reflect.Struct { - return fmt.Errorf("target must be a pointer to a struct") - } v = v.Elem() t := v.Type() - for i := 0; i < v.NumField(); i++ { + for i := range v.NumField() { field := v.Field(i) structField := t.Field(i) @@ -35,7 +43,7 @@ func pathToStruct(r *http.Request, target any) error { // Set field based on its type if !field.CanSet() { - return fmt.Errorf("field %s cannot be set", structField.Name) + return fmt.Errorf("field %s cannot be set: %w", structField.Name, ErrFieldNotSettable) } switch field.Kind() { @@ -46,32 +54,32 @@ func pathToStruct(r *http.Request, target any) error { if intValue, err := strconv.ParseInt(paramValue, 10, 64); err == nil { field.SetInt(intValue) } else { - return fmt.Errorf("invalid int value %s for field %s", paramValue, structField.Name) + return fmt.Errorf("invalid int value %s for field %s: %w", paramValue, structField.Name, err) } case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: if uintValue, err := strconv.ParseUint(paramValue, 10, 64); err == nil { field.SetUint(uintValue) } else { - return fmt.Errorf("invalid uint value %s for field %s", paramValue, structField.Name) + return fmt.Errorf("invalid uint value %s for field %s: %w", paramValue, structField.Name, err) } case reflect.Bool: if boolValue, err := strconv.ParseBool(paramValue); err == nil { field.SetBool(boolValue) } else { - return fmt.Errorf("invalid bool value %s for field %s", paramValue, structField.Name) + return fmt.Errorf("invalid bool value %s for field %s: %w", paramValue, structField.Name, err) } case reflect.Float32, reflect.Float64: if floatValue, err := strconv.ParseFloat(paramValue, 64); err == nil { field.SetFloat(floatValue) } else { - return fmt.Errorf("invalid float value %s for field %s", paramValue, structField.Name) + return fmt.Errorf("invalid float value %s for field %s: %w", paramValue, structField.Name, err) } default: - return fmt.Errorf("unsupported field type %s for field %s", field.Kind(), structField.Name) + return fmt.Errorf("unsupported field type %s for field %s: %w", field.Kind(), structField.Name, ErrUnsupportedFieldType) } } @@ -83,7 +91,7 @@ func mapFromStruct[T ~map[string][]string](in any, tag string) T { v := reflect.ValueOf(in) t := v.Type() - for i := 0; i < v.NumField(); i++ { + for i := range v.NumField() { field := t.Field(i) key := field.Tag.Get(tag) if key == "" { @@ -98,7 +106,7 @@ func mapFromStruct[T ~map[string][]string](in any, tag string) T { // Check if the field is a slice if fieldValue.Kind() == reflect.Slice { - for j := 0; j < fieldValue.Len(); j++ { + for j := range fieldValue.Len() { elem := fieldValue.Index(j) values = append(values, fmt.Sprintf("%v", elem.Interface())) } @@ -115,17 +123,17 @@ func mapFromStruct[T ~map[string][]string](in any, tag string) T { func mapToStruct[T ~map[string][]string](m T, tag string, out any) error { v := reflect.ValueOf(out) if v.Kind() != reflect.Pointer || v.IsNil() { - return errors.New("out must be a non-nil pointer to struct") + return ErrOutMustBePointerToStruct } v = v.Elem() if v.Kind() != reflect.Struct { - return errors.New("out must be a pointer to struct") + return ErrOutMustBePointer } t := v.Type() - for i := 0; i < v.NumField(); i++ { + for i := range v.NumField() { field := v.Field(i) fieldType := t.Field(i) @@ -153,7 +161,7 @@ func mapToStruct[T ~map[string][]string](m T, tag string, out any) error { // setField sets a struct field value with enhanced type support func setField(field reflect.Value, values []string) error { if !field.CanSet() { - return errors.New("cannot set field") + return ErrCannotSetField } fieldType := field.Type() @@ -169,7 +177,7 @@ func setField(field reflect.Value, values []string) error { if len(values) > 0 { intVal, err := strconv.ParseInt(values[0], 10, 64) if err != nil { - return err + return fmt.Errorf("failed to parse int: %w", err) } field.SetInt(intVal) } @@ -178,7 +186,7 @@ func setField(field reflect.Value, values []string) error { if len(values) > 0 { uintVal, err := strconv.ParseUint(values[0], 10, 64) if err != nil { - return err + return fmt.Errorf("failed to parse uint: %w", err) } field.SetUint(uintVal) } @@ -187,7 +195,7 @@ func setField(field reflect.Value, values []string) error { if len(values) > 0 { floatVal, err := strconv.ParseFloat(values[0], 64) if err != nil { - return err + return fmt.Errorf("failed to parse float: %w", err) } field.SetFloat(floatVal) } @@ -196,13 +204,13 @@ func setField(field reflect.Value, values []string) error { if len(values) > 0 { boolVal, err := strconv.ParseBool(values[0]) if err != nil { - return err + return fmt.Errorf("failed to parse bool: %w", err) } field.SetBool(boolVal) } default: - return fmt.Errorf("unsupported field type: %s", fieldType.Kind()) + return fmt.Errorf("unsupported field type: %s: %w", fieldType.Kind(), ErrUnsupportedFieldType) } return nil @@ -221,7 +229,7 @@ func setSliceField(field reflect.Value, values []string) error { for i, v := range values { intVal, err := strconv.Atoi(v) if err != nil { - return err + return fmt.Errorf("failed to parse int in slice: %w", err) } intSlice[i] = intVal } @@ -232,7 +240,7 @@ func setSliceField(field reflect.Value, values []string) error { for i, v := range values { intVal, err := strconv.ParseInt(v, 10, 64) if err != nil { - return err + return fmt.Errorf("failed to parse int64 in slice: %w", err) } intSlice[i] = intVal } @@ -243,7 +251,7 @@ func setSliceField(field reflect.Value, values []string) error { for i, v := range values { floatVal, err := strconv.ParseFloat(v, 64) if err != nil { - return err + return fmt.Errorf("failed to parse float64 in slice: %w", err) } floatSlice[i] = floatVal } @@ -254,14 +262,14 @@ func setSliceField(field reflect.Value, values []string) error { for i, v := range values { boolVal, err := strconv.ParseBool(v) if err != nil { - return err + return fmt.Errorf("failed to parse bool in slice: %w", err) } boolSlice[i] = boolVal } field.Set(reflect.ValueOf(boolSlice)) default: - return fmt.Errorf("unsupported slice element type: %s", elemType.Kind()) + return fmt.Errorf("unsupported slice element type: %s: %w", elemType.Kind(), ErrUnsupportedSliceElemType) } return nil diff --git a/openapiserver/group.go b/openapiserver/group.go index 04aec68..3acfc4f 100644 --- a/openapiserver/group.go +++ b/openapiserver/group.go @@ -5,11 +5,13 @@ import ( "github.com/platforma-dev/platforma/httpserver" ) +// Group represents a group of routes with a common path prefix. type Group struct { spec spec.Router handlerGroup *httpserver.HandlerGroup } +// NewGroup creates a new route group with the specified pattern. func NewGroup(router *Router, pattern string) *Group { hg := httpserver.NewHandlerGroup() router.handlerGroup.HandleGroup(pattern, hg) diff --git a/openapiserver/handler.go b/openapiserver/handler.go index 524bb36..d8170cb 100644 --- a/openapiserver/handler.go +++ b/openapiserver/handler.go @@ -8,44 +8,55 @@ import ( "github.com/platforma-dev/platforma/log" ) +// Handler defines a function type for handling HTTP requests with typed request and response parameters. type Handler[RequestType, ResponseHeaders, ResponseBody any] func(w *ResponseWriter[ResponseHeaders, ResponseBody], r Request[RequestType]) +// Get registers a GET route handler. func Get[RequestType, ResponseHeaders, ResponseBody any](group *Group, resps map[int]any, pattern string, handler Handler[RequestType, ResponseHeaders, ResponseBody]) { Handle(group, resps, http.MethodGet, pattern, handler) } +// Head registers a HEAD route handler. func Head[RequestType, ResponseHeaders, ResponseBody any](group *Group, resps map[int]any, pattern string, handler Handler[RequestType, ResponseHeaders, ResponseBody]) { Handle(group, resps, http.MethodHead, pattern, handler) } +// Post registers a POST route handler. func Post[RequestType, ResponseHeaders, ResponseBody any](group *Group, resps map[int]any, pattern string, handler Handler[RequestType, ResponseHeaders, ResponseBody]) { Handle(group, resps, http.MethodPost, pattern, handler) } +// Put registers a PUT route handler. func Put[RequestType, ResponseHeaders, ResponseBody any](group *Group, resps map[int]any, pattern string, handler Handler[RequestType, ResponseHeaders, ResponseBody]) { Handle(group, resps, http.MethodPut, pattern, handler) } +// Patch registers a PATCH route handler. func Patch[RequestType, ResponseHeaders, ResponseBody any](group *Group, resps map[int]any, pattern string, handler Handler[RequestType, ResponseHeaders, ResponseBody]) { Handle(group, resps, http.MethodPatch, pattern, handler) } +// Delete registers a DELETE route handler. func Delete[RequestType, ResponseHeaders, ResponseBody any](group *Group, resps map[int]any, pattern string, handler Handler[RequestType, ResponseHeaders, ResponseBody]) { Handle(group, resps, http.MethodDelete, pattern, handler) } +// Connect registers a CONNECT route handler. func Connect[RequestType, ResponseHeaders, ResponseBody any](group *Group, resps map[int]any, pattern string, handler Handler[RequestType, ResponseHeaders, ResponseBody]) { Handle(group, resps, http.MethodConnect, pattern, handler) } +// Options registers an OPTIONS route handler. func Options[RequestType, ResponseHeaders, ResponseBody any](group *Group, resps map[int]any, pattern string, handler Handler[RequestType, ResponseHeaders, ResponseBody]) { Handle(group, resps, http.MethodOptions, pattern, handler) } +// Trace registers a TRACE route handler. func Trace[RequestType, ResponseHeaders, ResponseBody any](group *Group, resps map[int]any, pattern string, handler Handler[RequestType, ResponseHeaders, ResponseBody]) { Handle(group, resps, http.MethodTrace, pattern, handler) } +// Handle registers a route handler for a specific HTTP method. func Handle[RequestType, ResponseHeaders, ResponseBody any](group *Group, resps map[int]any, method string, pattern string, handler Handler[RequestType, ResponseHeaders, ResponseBody]) { // Prepare open api spec opts := []option.OperationOption{ @@ -61,18 +72,24 @@ func Handle[RequestType, ResponseHeaders, ResponseBody any](group *Group, resps group.handlerGroup.HandleFunc(method+" "+pattern, func(w http.ResponseWriter, r *http.Request) { // Convert http request to user request request := &Request[RequestType]{ - HttpRequest: r, + HTTPRequest: r, Data: new(RequestType), } // Path - pathToStruct(r, request.Data) + if err := pathToStruct(r, request.Data); err != nil { + log.Error("failed to parse path parameters", "error", err) + } // Query - mapToStruct(r.URL.Query(), "query", request.Data) + if err := mapToStruct(r.URL.Query(), "query", request.Data); err != nil { + log.Error("failed to parse query parameters", "error", err) + } // Headers - mapToStruct(r.Header, "header", request.Data) + if err := mapToStruct(r.Header, "header", request.Data); err != nil { + log.Error("failed to parse headers", "error", err) + } // Body if err := json.NewDecoder(r.Body).Decode(request.Data); err != nil { diff --git a/openapiserver/request.go b/openapiserver/request.go index e629696..31c882e 100644 --- a/openapiserver/request.go +++ b/openapiserver/request.go @@ -4,7 +4,8 @@ import ( "net/http" ) +// Request represents an HTTP request with typed data. type Request[T any] struct { - HttpRequest *http.Request + HTTPRequest *http.Request Data *T } diff --git a/openapiserver/responsewriter.go b/openapiserver/responsewriter.go index f41d3b1..5ea6d4a 100644 --- a/openapiserver/responsewriter.go +++ b/openapiserver/responsewriter.go @@ -1,5 +1,6 @@ package openapiserver +// ResponseWriter provides a typed interface for writing HTTP responses. type ResponseWriter[Headers, Body any] struct { StatusCode int Headers Headers @@ -7,6 +8,7 @@ type ResponseWriter[Headers, Body any] struct { body Body } +// SetBody sets the response body. func (w *ResponseWriter[Headers, Body]) SetBody(b Body) { w.body = b w.bodySet = true diff --git a/openapiserver/router.go b/openapiserver/router.go index 9e1e0a0..cef11e2 100644 --- a/openapiserver/router.go +++ b/openapiserver/router.go @@ -8,6 +8,7 @@ import ( "github.com/platforma-dev/platforma/httpserver" ) +// Router manages HTTP routes and OpenAPI specifications. type Router struct { handlerGroup *httpserver.HandlerGroup spec spec.Generator @@ -15,6 +16,7 @@ type Router struct { docPath string // OpenAPI interactive documentation path } +// NewRouter creates a new router with OpenAPI specification support. func NewRouter(specPath, docPath string) *Router { hg := httpserver.NewHandlerGroup() sp := spec.NewRouter() From 4506811aa3602d5d3fb2f0c971d92c6966854504 Mon Sep 17 00:00:00 2001 From: Denis Mishankov Date: Sun, 30 Nov 2025 10:30:31 +0300 Subject: [PATCH 16/18] Add context parameter to handler functions The handler interface now includes a context parameter as the first argument to all handler functions, enabling proper context propagation throughout request handling. --- demo-app/cmd/open-api/main.go | 5 +++-- openapiserver/handler.go | 7 +++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/demo-app/cmd/open-api/main.go b/demo-app/cmd/open-api/main.go index 795423f..74ff75d 100644 --- a/demo-app/cmd/open-api/main.go +++ b/demo-app/cmd/open-api/main.go @@ -1,6 +1,7 @@ package main import ( + "context" "net/http" "github.com/platforma-dev/platforma/openapiserver" @@ -48,7 +49,7 @@ func main() { openapiserver.Get( helloGroup, resps, "/hello", - func(w myRespWriter, r openapiserver.Request[struct { + func(_ context.Context, w myRespWriter, r openapiserver.Request[struct { Name []string `query:"name"` UserAgent []string `header:"User-Agent"` }]) { @@ -67,7 +68,7 @@ func main() { openapiserver.Put( helloGroup, resps, "/hello/{id}", - func(w myRespWriter, r openapiserver.Request[struct { + func(_ context.Context, w myRespWriter, r openapiserver.Request[struct { Id string `path:"id"` Name []string `query:"name"` UserAgent []string `header:"User-Agent"` diff --git a/openapiserver/handler.go b/openapiserver/handler.go index d8170cb..19dc219 100644 --- a/openapiserver/handler.go +++ b/openapiserver/handler.go @@ -1,6 +1,7 @@ package openapiserver import ( + "context" "encoding/json" "net/http" @@ -9,7 +10,7 @@ import ( ) // Handler defines a function type for handling HTTP requests with typed request and response parameters. -type Handler[RequestType, ResponseHeaders, ResponseBody any] func(w *ResponseWriter[ResponseHeaders, ResponseBody], r Request[RequestType]) +type Handler[RequestType, ResponseHeaders, ResponseBody any] func(ctx context.Context, w *ResponseWriter[ResponseHeaders, ResponseBody], r Request[RequestType]) // Get registers a GET route handler. func Get[RequestType, ResponseHeaders, ResponseBody any](group *Group, resps map[int]any, pattern string, handler Handler[RequestType, ResponseHeaders, ResponseBody]) { @@ -70,6 +71,8 @@ func Handle[RequestType, ResponseHeaders, ResponseBody any](group *Group, resps // Add handler logic to mux group.handlerGroup.HandleFunc(method+" "+pattern, func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + // Convert http request to user request request := &Request[RequestType]{ HTTPRequest: r, @@ -98,7 +101,7 @@ func Handle[RequestType, ResponseHeaders, ResponseBody any](group *Group, resps // Call user handle writer := ResponseWriter[ResponseHeaders, ResponseBody]{} - handler(&writer, *request) + handler(ctx, &writer, *request) // Headers headers := mapFromStruct[map[string][]string](writer.Headers, "header") From 649d3b3c6e0cbf7ca131fc786ae29393f95b96f4 Mon Sep 17 00:00:00 2001 From: Denis Mishankov Date: Sun, 30 Nov 2025 10:34:49 +0300 Subject: [PATCH 17/18] Add context to OpenAPI server error logging Update log.Error calls to log.ErrorContext to include request context in error messages for better traceability --- openapiserver/handler.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/openapiserver/handler.go b/openapiserver/handler.go index 19dc219..796d4d4 100644 --- a/openapiserver/handler.go +++ b/openapiserver/handler.go @@ -81,22 +81,22 @@ func Handle[RequestType, ResponseHeaders, ResponseBody any](group *Group, resps // Path if err := pathToStruct(r, request.Data); err != nil { - log.Error("failed to parse path parameters", "error", err) + log.ErrorContext(ctx, "failed to parse path parameters", "error", err) } // Query if err := mapToStruct(r.URL.Query(), "query", request.Data); err != nil { - log.Error("failed to parse query parameters", "error", err) + log.ErrorContext(ctx, "failed to parse query parameters", "error", err) } // Headers if err := mapToStruct(r.Header, "header", request.Data); err != nil { - log.Error("failed to parse headers", "error", err) + log.ErrorContext(ctx, "failed to parse headers", "error", err) } // Body if err := json.NewDecoder(r.Body).Decode(request.Data); err != nil { - log.Error("failed to decode body", "error", err) + log.ErrorContext(ctx, "failed to decode body", "error", err) } // Call user handle @@ -120,7 +120,7 @@ func Handle[RequestType, ResponseHeaders, ResponseBody any](group *Group, resps // Body if writer.bodySet { if err := json.NewEncoder(w).Encode(writer.body); err != nil { - log.Error("failed to encode body", "error", err) + log.ErrorContext(ctx, "failed to encode body", "error", err) } } }) From a483c1a481c20eea8d4578bd113517e43ec2da7d Mon Sep 17 00:00:00 2001 From: Denis Mishankov Date: Sun, 30 Nov 2025 10:39:59 +0300 Subject: [PATCH 18/18] Suppress log for empty request body --- openapiserver/handler.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/openapiserver/handler.go b/openapiserver/handler.go index 796d4d4..5190c82 100644 --- a/openapiserver/handler.go +++ b/openapiserver/handler.go @@ -3,6 +3,8 @@ package openapiserver import ( "context" "encoding/json" + "errors" + "io" "net/http" "github.com/oaswrap/spec/option" @@ -96,7 +98,10 @@ func Handle[RequestType, ResponseHeaders, ResponseBody any](group *Group, resps // Body if err := json.NewDecoder(r.Body).Decode(request.Data); err != nil { - log.ErrorContext(ctx, "failed to decode body", "error", err) + // If body is empty, do not log this error + if !errors.Is(err, io.EOF) { + log.ErrorContext(ctx, "failed to decode body", "error", err) + } } // Call user handle