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/demo-app/cmd/open-api/main.go b/demo-app/cmd/open-api/main.go new file mode 100644 index 0000000..74ff75d --- /dev/null +++ b/demo-app/cmd/open-api/main.go @@ -0,0 +1,90 @@ +package main + +import ( + "context" + "net/http" + + "github.com/platforma-dev/platforma/openapiserver" +) + +type myRespHeaders struct { + XMen []string `header:"X-Men"` + ContentType string `header:"Content-Type"` +} + +type errorRespBody struct { + ErrorMessage string `json:"errorMessage,omitempty"` +} + +type successRespBody struct { + Data []string `json:"data,omitempty"` +} + +type myRespBody struct { + errorRespBody + successRespBody +} + +type myRespWriter = *openapiserver.ResponseWriter[myRespHeaders, myRespBody] + +func main() { + router := openapiserver.NewRouter("/docs/openapi.yml", "/docs") + + resps := map[int]any{ + http.StatusOK: struct { + myRespHeaders + successRespBody + }{}, + http.StatusCreated: struct { + myRespHeaders + successRespBody + }{}, + http.StatusBadRequest: struct { + myRespHeaders + errorRespBody + }{}, + } + + helloGroup := openapiserver.NewGroup(router, "") + + openapiserver.Get( + helloGroup, resps, "/hello", + func(_ context.Context, 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(_ context.Context, 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}}) + }) + + http.ListenAndServe(":8080", router) +} diff --git a/go.mod b/go.mod index 1406126..91d682d 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,8 @@ 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/oaswrap/spec-ui v0.1.4 github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0 golang.org/x/crypto v0.43.0 ) @@ -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/openapiserver/converters.go b/openapiserver/converters.go new file mode 100644 index 0000000..65624c4 --- /dev/null +++ b/openapiserver/converters.go @@ -0,0 +1,276 @@ +// Package openapiserver provides utilities for handling OpenAPI server requests and responses. +package openapiserver + +import ( + "errors" + "fmt" + "net/http" + "reflect" + "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) + + v = v.Elem() + t := v.Type() + + for i := range v.NumField() { + 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: %w", structField.Name, ErrFieldNotSettable) + } + + 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: %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: %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: %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: %w", paramValue, structField.Name, err) + } + + default: + return fmt.Errorf("unsupported field type %s for field %s: %w", field.Kind(), structField.Name, ErrUnsupportedFieldType) + } + } + + return nil +} + +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 := range v.NumField() { + 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 := range fieldValue.Len() { + 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 ErrOutMustBePointerToStruct + } + + v = v.Elem() + if v.Kind() != reflect.Struct { + return ErrOutMustBePointer + } + + t := v.Type() + + for i := range v.NumField() { + 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 ErrCannotSetField + } + + 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 fmt.Errorf("failed to parse int: %w", 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 fmt.Errorf("failed to parse uint: %w", err) + } + field.SetUint(uintVal) + } + + case reflect.Float32, reflect.Float64: + if len(values) > 0 { + floatVal, err := strconv.ParseFloat(values[0], 64) + if err != nil { + return fmt.Errorf("failed to parse float: %w", err) + } + field.SetFloat(floatVal) + } + + case reflect.Bool: + if len(values) > 0 { + boolVal, err := strconv.ParseBool(values[0]) + if err != nil { + return fmt.Errorf("failed to parse bool: %w", err) + } + field.SetBool(boolVal) + } + + default: + return fmt.Errorf("unsupported field type: %s: %w", fieldType.Kind(), ErrUnsupportedFieldType) + } + + 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 fmt.Errorf("failed to parse int in slice: %w", 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 fmt.Errorf("failed to parse int64 in slice: %w", 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 fmt.Errorf("failed to parse float64 in slice: %w", 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 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: %w", elemType.Kind(), ErrUnsupportedSliceElemType) + } + + return nil +} diff --git a/openapiserver/group.go b/openapiserver/group.go new file mode 100644 index 0000000..3acfc4f --- /dev/null +++ b/openapiserver/group.go @@ -0,0 +1,24 @@ +package openapiserver + +import ( + "github.com/oaswrap/spec" + "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) + group := router.spec.Group(pattern) + + return &Group{ + spec: group, + handlerGroup: hg, + } +} diff --git a/openapiserver/handler.go b/openapiserver/handler.go new file mode 100644 index 0000000..5190c82 --- /dev/null +++ b/openapiserver/handler.go @@ -0,0 +1,132 @@ +package openapiserver + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + + "github.com/oaswrap/spec/option" + "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(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]) { + 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{ + option.Request(new(RequestType)), + } + for statusCode, respModel := range resps { + opts = append(opts, option.Response(statusCode, respModel)) + } + + group.spec.Add(method, pattern, opts...) + + // 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, + Data: new(RequestType), + } + + // Path + if err := pathToStruct(r, request.Data); err != nil { + log.ErrorContext(ctx, "failed to parse path parameters", "error", err) + } + + // Query + if err := mapToStruct(r.URL.Query(), "query", request.Data); err != nil { + log.ErrorContext(ctx, "failed to parse query parameters", "error", err) + } + + // Headers + if err := mapToStruct(r.Header, "header", request.Data); err != nil { + log.ErrorContext(ctx, "failed to parse headers", "error", err) + } + + // Body + if err := json.NewDecoder(r.Body).Decode(request.Data); err != nil { + // 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 + writer := ResponseWriter[ResponseHeaders, ResponseBody]{} + handler(ctx, &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.ErrorContext(ctx, "failed to encode body", "error", err) + } + } + }) +} diff --git a/openapiserver/request.go b/openapiserver/request.go new file mode 100644 index 0000000..31c882e --- /dev/null +++ b/openapiserver/request.go @@ -0,0 +1,11 @@ +package openapiserver + +import ( + "net/http" +) + +// Request represents an HTTP request with typed data. +type Request[T any] struct { + HTTPRequest *http.Request + Data *T +} diff --git a/openapiserver/responsewriter.go b/openapiserver/responsewriter.go new file mode 100644 index 0000000..5ea6d4a --- /dev/null +++ b/openapiserver/responsewriter.go @@ -0,0 +1,15 @@ +package openapiserver + +// ResponseWriter provides a typed interface for writing HTTP responses. +type ResponseWriter[Headers, Body any] struct { + StatusCode int + Headers Headers + bodySet bool + 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 new file mode 100644 index 0000000..cef11e2 --- /dev/null +++ b/openapiserver/router.go @@ -0,0 +1,46 @@ +package openapiserver + +import ( + "net/http" + + "github.com/oaswrap/spec" + specui "github.com/oaswrap/spec-ui" + "github.com/platforma-dev/platforma/httpserver" +) + +// Router manages HTTP routes and OpenAPI specifications. +type Router struct { + handlerGroup *httpserver.HandlerGroup + spec spec.Generator + specPath string // OpenAPI specifications path + 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() + + 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: hg, + spec: sp, + specPath: specPath, + docPath: docPath, + } +} + +func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) { + r.handlerGroup.ServeHTTP(w, req) +}