From 24312a538f6a5aa4d00a2f719f5598c8dfca6517 Mon Sep 17 00:00:00 2001 From: Ismael GraHms Date: Sat, 13 Apr 2024 23:32:39 -0700 Subject: [PATCH] feat: generate a basic swagger spec --- examples/main.go | 13 +- router/endpoint.go | 273 ++++++-------- router/endpoint_test.go | 800 ---------------------------------------- router/info.go | 159 ++++++++ router/swagger.go | 389 +++++++++++++++++++ swagger.go | 46 +-- worx.go | 73 +++- 7 files changed, 745 insertions(+), 1008 deletions(-) delete mode 100644 router/endpoint_test.go create mode 100644 router/info.go create mode 100644 router/swagger.go diff --git a/examples/main.go b/examples/main.go index 7ee6acd..1c9c574 100644 --- a/examples/main.go +++ b/examples/main.go @@ -21,25 +21,18 @@ type Product struct { BaseType *string `json:"@baseType" binding:"ignore"` Type *string `json:"type" enums:"physical,digital"` Url *string `json:"@Url" binding:"ignore"` - IsHumeid *bool `json:"isHumeid" binding:"required"` Specification *[]Spec `json:"specification"` Id *string `json:"id" binding:"ignore"` } func main() { - app := worx.NewApplication("/api", "Product Catalog API", "1.0.0", "Product Catalogue API") + app := worx.NewApplication("/api", "Product API", "1.0.0", "Product API") productTags := router.WithTags([]string{"Product", "something"}) product := worx.NewRouter[Product, Product](app, "/products") product.HandleCreate("", createHandler, router.WithName("product name"), productTags) product.HandleRead("", handler, productTags) - product.HandleRead("/:id/machava", handler, productTags, router.WithAllowedHeaders([]router.AllowedFields{ - { - Name: "x-tower", - Description: "", - Required: false, - }, - })) - err := app.Run(":8080") + product.HandleRead("/:id", handler, productTags) + err := app.Run(":8081") if err != nil { panic(err) } diff --git a/router/endpoint.go b/router/endpoint.go index 94c93a2..e100961 100644 --- a/router/endpoint.go +++ b/router/endpoint.go @@ -1,48 +1,23 @@ package router import ( + "context" "encoding/json" "github.com/gin-gonic/gin" "github.com/grahms/godantic" "io" + "net/http" "strconv" "strings" ) -// Endpoint represents information about an API endpoint -type Endpoint struct { - Path string - Methods []Method -} - -type Method struct { - HTTPMethod string - Request interface{} - Response interface{} - Description string - Tags []string - Summery string - Configs EndpointConfigs -} - -var Endpoints map[string]*Endpoint - -// Initialize the Endpoints map -func init() { - Endpoints = make(map[string]*Endpoint) +type Handler interface { + Handler() } -// we can have the global struct here -type APIEndpointer[Req, Resp any] interface { - HandleCreate(uri string, processRequest func(Req, *RequestParams) (*Err, *Resp), opts ...HandleOption) - HandleRead(pathSuffix string, processRequest func(*RequestParams) (*Err, *Resp), opts ...HandleOption) - HandleUpdate(pathString string, requestProcessor func(id string, reqBody Req, params *RequestParams) (*Err, *Resp), opts ...HandleOption) - HandleList(pathString string, requestProcessor func(params *RequestParams, limit int, offset int) ([]*Resp, *Err, int, int), opts ...HandleOption) -} - -func New[In, Out any](path string, group *gin.RouterGroup) *APIEndpoint[In, Out] { - return &APIEndpoint[In, Out]{ +func NewAPIEndpointGroup[Req any, Resp any](path string, group *gin.RouterGroup) *APIEndpoint[Req, Resp] { + return &APIEndpoint[Req, Resp]{ Path: path, Router: group, } @@ -59,67 +34,75 @@ type RequestParams struct { Query map[string]any Headers map[string]string PathParams map[string]string -} -type EndpointConfigs struct { - Name string - Uri string - Tags []string - Descriptions string - AllowedHeaders []AllowedFields - AllowedParams []AllowedFields + TraceID string } -type AllowedFields struct { - Name string - Description string - Required bool -} - -func getConfigs(opts ...HandleOption) *EndpointConfigs { - config := &EndpointConfigs{} - for _, opt := range opts { - opt(config) +func New[In, Out any](path string, group *gin.RouterGroup) *APIEndpoint[In, Out] { + return &APIEndpoint[In, Out]{ + Path: path, + Router: group, } - return config +} +func setTags(path string, config *EndpointConfigs) { + tag := strings.TrimPrefix(path, "/") + tag = strings.ReplaceAll(path, "/", " ") + tag = strings.TrimPrefix(tag, " ") + config.GeneratedTags = []string{tag} } func (r *APIEndpoint[Req, Resp]) HandleCreate(uri string, processRequest func(Req, *RequestParams) (*Err, *Resp), opts ...HandleOption) { config := getConfigs(opts...) - registerEndpoint(r.Router.BasePath()+r.Path+uri, "POST", new(Req), new(Resp), *config) + setTags(r.Path, config) + registerEndpoint(r.Router.BasePath()+r.Path+uri, "POST", new(Req), new(Resp), *config, opts...) + statusCode := http.StatusCreated + if config.StatusCode != nil { + statusCode = *config.StatusCode + } r.Router.POST(r.Path+uri, func(c *gin.Context) { + params := r.extractRequestParams(c) if statusCode, exception := r.validateJSONContentType(c); exception != nil { + c.JSON(statusCode, exception) return } var requestBody Req if err := r.bindJSON(c.Request.Body, &requestBody); err != nil { - c.JSON(r.validator.InputErr(err)) + code, e := r.validator.InputErr(err) + + c.JSON(code, e) return } - params := r.extractRequestParams(c) - perr, response := processRequest(requestBody, ¶ms) if perr != nil { - c.JSON(r.validator.ProcessorErr(perr)) + code, e := r.validator.ProcessorErr(perr) + + c.JSON(code, e) return } - c.JSON(http.StatusCreated, r.convertToMap(*response)) + c.JSON(statusCode, r.convertToMap(*response)) return }) } func (r *APIEndpoint[Req, Resp]) HandleRead(pathSuffix string, processRequest func(*RequestParams) (*Err, *Resp), opts ...HandleOption) { config := getConfigs(opts...) - registerEndpoint(r.Router.BasePath()+r.Path+pathSuffix, "GET", new(Req), new(Resp), *config) + setTags(r.Path, config) + registerEndpoint(r.Router.BasePath()+r.Path+pathSuffix, "GET", new(Req), new(Resp), *config, opts...) + statusCode := http.StatusOK + if config.StatusCode != nil { + statusCode = *config.StatusCode + } r.Router.GET(r.Path+pathSuffix, func(c *gin.Context) { reqValues := r.extractRequestParams(c) perr, resp := processRequest(&reqValues) // handle processor error if perr != nil { - c.JSON(r.validator.ProcessorErr(perr)) + code, e := r.validator.ProcessorErr(perr) + + c.JSON(code, e) return } fields := strings.Replace(c.Query("fields"), " ", "", -1) @@ -130,22 +113,27 @@ func (r *APIEndpoint[Req, Resp]) HandleRead(pathSuffix string, processRequest fu } respWithFields, err := fieldSelector(fieldsList, r.convertToMap(*resp)) if err != nil { - c.JSON(r.validator.ProcessorErr(err)) + code, e := r.validator.ProcessorErr(err) + + c.JSON(code, e) return } - c.JSON(http.StatusOK, respWithFields) + c.JSON(statusCode, respWithFields) return }) } func (r *APIEndpoint[Req, Resp]) HandleUpdate(pathString string, requestProcessor func(id string, reqBody Req, params *RequestParams) (*Err, *Resp), opts ...HandleOption) { - config := getConfigs(opts...) - registerEndpoint(r.Router.BasePath()+r.Path+pathString, "PATCH", new(Req), new(Resp), *config) binder := godantic.Validate{} binder.IgnoreRequired = true binder.IgnoreMinLen = true + config := getConfigs(opts...) + setTags(r.Path, config) + registerEndpoint(r.Router.BasePath()+r.Path+pathString, "PATCH", new(Req), new(Resp), *config, opts...) + statusCode := http.StatusOK + r.Router.PATCH(r.Path+pathString, func(c *gin.Context) { if statusCode, exception := r.validateJSONContentType(c); exception != nil { c.JSON(statusCode, exception) @@ -153,20 +141,25 @@ func (r *APIEndpoint[Req, Resp]) HandleUpdate(pathString string, requestProcesso } var reqBody Req + reqValues := r.extractRequestParams(c) requestDataBytes, err := io.ReadAll(c.Request.Body) if err = binder.BindJSON(requestDataBytes, &reqBody); err != nil { - c.JSON(r.validator.InputErr(err)) + code, e := r.validator.InputErr(err) + + c.JSON(code, e) return } id := c.Param("id") - reqValues := r.extractRequestParams(c) + perr, resp := requestProcessor(id, reqBody, &reqValues) if perr != nil { - c.JSON(r.validator.ProcessorErr(perr)) + code, e := r.validator.ProcessorErr(perr) + + c.JSON(code, e) return } - c.JSON(http.StatusOK, r.convertToMap(*resp)) + c.JSON(statusCode, r.convertToMap(*resp)) return }) } @@ -180,8 +173,28 @@ func (r *APIEndpoint[Req, Resp]) convertToMap(obj interface{}) map[string]interf } func (r *APIEndpoint[Req, Resp]) HandleList(pathString string, requestProcessor func(params *RequestParams, limit int, offset int) ([]*Resp, *Err, int, int), opts ...HandleOption) { + opts = append(opts, WithAllowedParams([]AllowedFields{ + { + Name: "limit", + Description: "page limit", + }, + { + Name: "offset", + Description: "page number", + }, + { + Name: "fields", + Description: "fields to be selected ex: fields=id,name", + }, + })) config := getConfigs(opts...) + setTags(r.Path, config) + registerEndpoint(r.Router.BasePath()+r.Path+pathString, "GET", new(Req), new(Resp), *config) + statusCode := http.StatusOK + if config.StatusCode != nil { + statusCode = *config.StatusCode + } r.Router.GET(r.Path+pathString, func(c *gin.Context) { params := r.extractRequestParams(c) limit, err := strconv.Atoi(c.DefaultQuery("limit", "30")) @@ -208,7 +221,9 @@ func (r *APIEndpoint[Req, Resp]) HandleList(pathString string, requestProcessor resp, perr, amount, total := requestProcessor(¶ms, limit, offset) if perr != nil { - c.JSON(r.validator.ProcessorErr(perr)) + code, e := r.validator.ProcessorErr(perr) + + c.JSON(code, e) return } @@ -224,7 +239,8 @@ func (r *APIEndpoint[Req, Resp]) HandleList(pathString string, requestProcessor respMap := r.convertToMap(*resp) respWithFields, err := fieldSelector(fieldsList, respMap) if err != nil { - c.JSON(r.validator.ProcessorErr(err)) + code, e := r.validator.ProcessorErr(err) + c.JSON(code, e) return } responseMaps = append(responseMaps, respWithFields) @@ -232,11 +248,12 @@ func (r *APIEndpoint[Req, Resp]) HandleList(pathString string, requestProcessor c.Header("X-Total-Count", strconv.Itoa(amount)) c.Header("X-Result-Count", strconv.Itoa(total)) + c.Header("trace-id", params.TraceID) if len(resp) == 0 { c.JSON(http.StatusOK, resp) return } - c.JSON(http.StatusOK, responseMaps) + c.JSON(statusCode, responseMaps) return }) } @@ -278,98 +295,52 @@ func (r *APIEndpoint[Req, Resp]) extractRequestParams(c *gin.Context) RequestPar params.Query[key] = c.Query(key) } } + return params } -func GetAuthenticationHeader(req *RequestParams) (string, error) { - authentication, ok := req.Headers["Authorization"] - if !ok { - return "", ErrorBuilder("AUTHORIZATION_HEADER_NOT_FOUND", "Authentication Header Not Found", "Authentication Header Not Found", http.StatusBadRequest) - } - return authentication, nil -} -func ErrorBuilder(errCode, errReason, message string, statusCode int) *Err { - return &Err{ - ErrCode: errCode, - ErrReason: errReason, - StatusCode: statusCode, - Message: message, +func (r *APIEndpoint[Req, Resp]) HandleCreateWithoutBody(uri string, processRequest func(*RequestParams) (*Err, *Resp), opts ...HandleOption) { + config := getConfigs(opts...) + setTags(r.Path, config) + registerEndpoint(r.Router.BasePath()+r.Path+uri, "POST", new(Req), new(Resp), *config) + statusCode := http.StatusCreated + if config.StatusCode != nil { + statusCode = *config.StatusCode } -} - -func registerEndpoint(path, method string, request, response interface{}, config EndpointConfigs) { - // Check if the endpoint already exists - if endpoint, ok := Endpoints[path]; ok { - // Check if the method already exists for this endpoint - for _, m := range endpoint.Methods { - if m.HTTPMethod == method { - // Method already exists, return or handle as appropriate - return - } - } - // Method does not exist, add it to the endpoint's methods - endpoint.Methods = append(endpoint.Methods, Method{ - HTTPMethod: method, - Request: request, - Response: response, - Description: config.Descriptions, - Tags: config.Tags, - Summery: config.Name, - Configs: config, - }) - } else { - // If the endpoint doesn't exist, create a new entry with the method - Endpoints[path] = &Endpoint{ - Path: path, - Methods: []Method{ - { - HTTPMethod: method, - Request: request, - Response: response, - Description: config.Descriptions, - Tags: config.Tags, - Summery: config.Name, - Configs: config, - }, - }, + r.Router.POST(r.Path+uri, func(c *gin.Context) { + params := r.extractRequestParams(c) + perr, response := processRequest(¶ms) + if perr != nil { + c.JSON(r.validator.ProcessorErr(perr)) + return } - } -} -type HandleOption func(*EndpointConfigs) - -func WithName(name string) HandleOption { - return func(c *EndpointConfigs) { - c.Name = name - } -} - -func WithURI(uri string) HandleOption { - return func(c *EndpointConfigs) { - c.Uri = uri - } + c.JSON(statusCode, r.convertToMap(*response)) + return + }) } -func WithTags(tags []string) HandleOption { - return func(c *EndpointConfigs) { - c.Tags = tags - } -} +func (r *APIEndpoint[Req, Resp]) HandleDelete(pathString string, processRequest func(params *RequestParams) *Err, opts ...HandleOption) { + config := getConfigs(opts...) + setTags(r.Path, config) + registerEndpoint(r.Router.BasePath()+r.Path+pathString, "DELETE", nil, nil, *config, opts...) + statusCode := http.StatusNoContent + config.StatusCode = &statusCode + r.Router.DELETE(r.Path+pathString, func(c *gin.Context) { + params := r.extractRequestParams(c) + perr := processRequest(¶ms) + if perr != nil { + code, e := r.validator.ProcessorErr(perr) -func WithDescriptions(descriptions string) HandleOption { - return func(c *EndpointConfigs) { - c.Descriptions = descriptions - } -} + c.JSON(code, e) + return + } -func WithAllowedHeaders(headers []AllowedFields) HandleOption { - return func(c *EndpointConfigs) { - c.AllowedHeaders = headers - } + c.Status(statusCode) + return + }) } -func WithAllowedParams(params []AllowedFields) HandleOption { - return func(c *EndpointConfigs) { - c.AllowedParams = params - } +func WithContext(params *RequestParams) context.Context { + return context.WithValue(context.Background(), "traceID", params.TraceID) } diff --git a/router/endpoint_test.go b/router/endpoint_test.go deleted file mode 100644 index 9ceec17..0000000 --- a/router/endpoint_test.go +++ /dev/null @@ -1,800 +0,0 @@ -package router - -import ( - "bytes" - "encoding/json" - "errors" - "github.com/gin-gonic/gin" - "github.com/grahms/godantic" - "github.com/stretchr/testify/assert" - "net/http" - "net/http/httptest" - "testing" -) - -type Req struct { - Foo *string `json:"foo"` - Baz *int `json:"baz"` -} - -type Resp struct { - Foo string `json:"foo"` - Baz int `json:"baz"` -} - -func TestCreate(t *testing.T) { - // HandleCreate a new APIEndpoint instance - engine := gin.New() - router := engine.Group("") - e := &APIEndpoint[Req, Req]{ - Path: "/foo", - Router: router, - validator: &Validation{}, - dataBinder: godantic.Validate{}, - } - - // Define a request processor function that returns a response body and a nil error - requestProcessor := func(req Req, _ *RequestParams) (*Err, *Req) { - foo := "bar" - return nil, &Req{ - Foo: &foo, - Baz: nil, - } - } - - // Register the request processor function with the APIEndpoint instance - e.HandleCreate("", EndpointConfigs{}, requestProcessor) - - // HandleCreate a mock HTTP POST request - req, err := http.NewRequest(http.MethodPost, "/foo", bytes.NewBuffer([]byte(`{"foo":"bar","baz":123}`))) - if err != nil { - t.Fatal(err) - } - // Set the Content-Type header of the request - req.Header.Set("Content-Type", "application/json") - - // HandleCreate a response recorder - rr := httptest.NewRecorder() - - // Serve the mock HTTP request - engine.ServeHTTP(rr, req) - - // Check that the response has a 200 status code - assert.Equal(t, http.StatusCreated, rr.Code) - - // Unmarshal the response body into a map - var respMap map[string]interface{} - if err := json.Unmarshal(rr.Body.Bytes(), &respMap); err != nil { - t.Fatal(err) - } - - // Check that the response body is correct - assert.Equal(t, map[string]interface{}{ - "foo": "bar", - }, respMap) -} - -func TestShouldUpdate(t *testing.T) { - // HandleCreate a new APIEndpoint instance - engine := gin.New() - router := engine.Group("") - e := &APIEndpoint[Req, Resp]{ - Path: "/foo", - Router: router, - validator: &Validation{}, - dataBinder: godantic.Validate{}, - } - - // Define a request processor function that returns a response body and a nil error - requestProcessor := func(id string, req Req, _ *RequestParams) (*Err, *Resp) { - return nil, &Resp{ - Foo: "bar", - Baz: 123, - } - } - - // Register the request processor function with the APIEndpoint instance - e.HandleUpdate("/:id", requestProcessor) - - // HandleCreate a mock HTTP POST request - req, err := http.NewRequest(http.MethodPatch, "/foo/id", bytes.NewBuffer([]byte(`{"foo":"bar","baz":123}`))) - if err != nil { - t.Fatal(err) - } - // Set the Content-Type header of the request - req.Header.Set("Content-Type", "application/json") - - // HandleCreate a response recorder - rr := httptest.NewRecorder() - - // Serve the mock HTTP request - engine.ServeHTTP(rr, req) - - // Check that the response has a 200 status code - assert.Equal(t, http.StatusOK, rr.Code) - - // Unmarshal the response body into a map - var respMap map[string]interface{} - if err := json.Unmarshal(rr.Body.Bytes(), &respMap); err != nil { - t.Fatal(err) - } - - // Check that the response body is correct - assert.Equal(t, map[string]interface{}{ - "foo": "bar", - "baz": float64(123), - }, respMap) -} - -func TestShouldUpdateWithWrongContentType(t *testing.T) { - // HandleCreate a new APIEndpoint instance - engine := gin.New() - router := engine.Group("") - e := &APIEndpoint[Req, Resp]{ - Path: "/foo", - Router: router, - validator: &Validation{}, - dataBinder: godantic.Validate{}, - } - - // Define a request processor function that returns a response body and a nil error - requestProcessor := func(id string, req Req, _ *RequestParams) (*Err, *Resp) { - return nil, &Resp{ - Foo: "bar", - Baz: 123, - } - } - - // Register the request processor function with the APIEndpoint instance - e.HandleUpdate("/:id", requestProcessor) - - // HandleCreate a mock HTTP POST request - req, err := http.NewRequest(http.MethodPatch, "/foo/id", bytes.NewBuffer([]byte(`{"foo":"bar","baz":123}`))) - if err != nil { - t.Fatal(err) - } - // Set the Content-Type header of the request - req.Header.Set("Content-Type", "application/xml") - req.Header.Set("foo", "bar") - - // HandleCreate a response recorder - rr := httptest.NewRecorder() - - // Serve the mock HTTP request - engine.ServeHTTP(rr, req) - - // Check that the response has a 200 status code - assert.Equal(t, http.StatusUnprocessableEntity, rr.Code) - -} - -func TestShouldUpdateWithWrongJson(t *testing.T) { - // HandleCreate a new APIEndpoint instance - engine := gin.New() - router := engine.Group("") - e := &APIEndpoint[Req, Resp]{ - Path: "/foo", - Router: router, - validator: &Validation{}, - dataBinder: godantic.Validate{}, - } - - // Define a request processor function that returns a response body and a nil error - requestProcessor := func(id string, req Req, _ *RequestParams) (*Err, *Resp) { - return nil, &Resp{ - Foo: "bar", - Baz: 123, - } - } - - // Register the request processor function with the APIEndpoint instance - e.HandleUpdate("/:id", requestProcessor) - - // HandleCreate a mock HTTP POST request - req, err := http.NewRequest(http.MethodPatch, "/foo/id", bytes.NewBuffer([]byte(`"foo":"bar","baz":123}`))) - if err != nil { - t.Fatal(err) - } - // Set the Content-Type header of the request - req.Header.Set("Content-Type", "application/json") - - // HandleCreate a response recorder - rr := httptest.NewRecorder() - - // Serve the mock HTTP request - engine.ServeHTTP(rr, req) - - // Check that the response has a 200 status code - assert.Equal(t, http.StatusBadRequest, rr.Code) - -} - -func TestShouldNotUpdateWithProccesorErr(t *testing.T) { - // HandleCreate a new APIEndpoint instance - engine := gin.New() - router := engine.Group("") - e := &APIEndpoint[Req, Resp]{ - Path: "/foo", - Router: router, - validator: &Validation{}, - dataBinder: godantic.Validate{}, - } - - // Define a request processor function that returns a response body and a nil error - requestProcessor := func(id string, req Req, _ *RequestParams) (*Err, *Resp) { - return &Err{ - StatusCode: 500, - }, nil - } - - // Register the request processor function with the APIEndpoint instance - e.HandleUpdate("/:id", requestProcessor) - - // HandleCreate a mock HTTP POST request - req, err := http.NewRequest(http.MethodPatch, "/foo/id", bytes.NewBuffer([]byte(`{"foo":"bar","baz":123}`))) - if err != nil { - t.Fatal(err) - } - // Set the Content-Type header of the request - req.Header.Set("Content-Type", "application/json") - - // HandleCreate a response recorder - rr := httptest.NewRecorder() - - // Serve the mock HTTP request - engine.ServeHTTP(rr, req) - - // Check that the response has a 200 status code - assert.Equal(t, http.StatusInternalServerError, rr.Code) - -} - -func TestShouldTryCreateWithWrongJsonFormat(t *testing.T) { - // HandleCreate a new APIEndpoint instance - engine := gin.New() - router := engine.Group("") - e := &APIEndpoint[Req, Resp]{ - Path: "/foo", - Router: router, - validator: &Validation{}, - dataBinder: godantic.Validate{}, - } - - // Define a request processor function that returns a response body and a nil error - requestProcessor := func(req Req, _ *RequestParams) (*Err, *Resp) { - return nil, &Resp{ - Foo: "bar", - Baz: 123, - } - } - - // Register the request processor function with the APIEndpoint instance - e.HandleCreate("", EndpointConfigs{}, requestProcessor) - - // HandleCreate a mock HTTP POST request - req, err := http.NewRequest(http.MethodPost, "/foo", - bytes.NewBuffer([]byte(`{"foo":"bar","baz":123`))) - if err != nil { - t.Fatal(err) - } - // Set the Content-Type header of the request - req.Header.Set("Content-Type", "application/json") - req.Header.Set("foo", "bar") - - // HandleCreate a response recorder - rr := httptest.NewRecorder() - - // Serve the mock HTTP request - engine.ServeHTTP(rr, req) - - // Check that the response has a 200 status code - assert.Equal(t, http.StatusBadRequest, rr.Code) - -} - -func TestShouldTryCreateWithEmptyFIeld(t *testing.T) { - // HandleCreate a new APIEndpoint instance - engine := gin.New() - router := engine.Group("") - e := &APIEndpoint[Req, Resp]{ - Path: "/foo", - Router: router, - validator: &Validation{}, - dataBinder: godantic.Validate{}, - } - - // Define a request processor function that returns a response body and a nil error - requestProcessor := func(req Req, _ *RequestParams) (*Err, *Resp) { - return nil, &Resp{ - Foo: "bar", - Baz: 123, - } - } - - // Register the request processor function with the APIEndpoint instance - e.HandleCreate("", EndpointConfigs{}, requestProcessor) - - // HandleCreate a mock HTTP POST request - req, err := http.NewRequest(http.MethodPost, "/foo", - bytes.NewBuffer([]byte(`{"foo":"","baz":123}`))) - if err != nil { - t.Fatal(err) - } - // Set the Content-Type header of the request - req.Header.Set("Content-Type", "application/json") - - // HandleCreate a response recorder - rr := httptest.NewRecorder() - - // Serve the mock HTTP request - engine.ServeHTTP(rr, req) - - // Check that the response has a 200 status code - assert.Equal(t, http.StatusBadRequest, rr.Code) - -} - -func TestShouldTryCreateWithExtraField(t *testing.T) { - // HandleCreate a new APIEndpoint instance - engine := gin.New() - router := engine.Group("") - e := &APIEndpoint[Req, Resp]{ - Path: "/foo", - Router: router, - validator: &Validation{}, - dataBinder: godantic.Validate{}, - } - - // Define a request processor function that returns a response body and a nil error - requestProcessor := func(req Req, _ *RequestParams) (*Err, *Resp) { - return nil, &Resp{ - Foo: "bar", - Baz: 123, - } - } - - // Register the request processor function with the APIEndpoint instance - e.HandleCreate("", EndpointConfigs{}, requestProcessor) - - // HandleCreate a mock HTTP POST request - req, err := http.NewRequest(http.MethodPost, "/foo", - bytes.NewBuffer([]byte(`{"foo":"bar","ismael":"grahms","baz":123}`))) - if err != nil { - t.Fatal(err) - } - // Set the Content-Type header of the request - req.Header.Set("Content-Type", "application/json") - - // HandleCreate a response recorder - rr := httptest.NewRecorder() - - // Serve the mock HTTP request - engine.ServeHTTP(rr, req) - - // Check that the response has a 200 status code - assert.Equal(t, http.StatusBadRequest, rr.Code) - -} - -func TestShouldTryCreateWithRequestProcessorError(t *testing.T) { - // HandleCreate a new APIEndpoint instance - engine := gin.New() - router := engine.Group("") - e := &APIEndpoint[Req, Resp]{ - Path: "/foo", - Router: router, - validator: &Validation{}, - dataBinder: godantic.Validate{}, - } - - // Define a request processor function that returns a response body and a nil error - requestProcessor := func(req Req, _ *RequestParams) (*Err, *Resp) { - perr := &Err{ - err: errors.New("i'm an error"), - StatusCode: 500, - ErrCode: "ERR_CODE", - ErrReason: "REASON", - } - return perr, &Resp{ - Foo: "bar", - Baz: 123, - } - } - - // Register the request processor function with the APIEndpoint instance - e.HandleCreate("", EndpointConfigs{}, requestProcessor) - - // HandleCreate a mock HTTP POST request - req, err := http.NewRequest(http.MethodPost, "/foo", - bytes.NewBuffer([]byte(`{"foo":"bar","baz":123}`))) - if err != nil { - t.Fatal(err) - } - // Set the Content-Type header of the request - req.Header.Set("Content-Type", "application/json") - - // HandleCreate a response recorder - rr := httptest.NewRecorder() - - // Serve the mock HTTP request - engine.ServeHTTP(rr, req) - - // Check that the response has a 200 status code - assert.Equal(t, http.StatusInternalServerError, rr.Code) - -} - -func TestCreateWithInvalidBody(t *testing.T) { - // HandleCreate a new APIEndpoint instance - engine := gin.New() - router := engine.Group("") - e := &APIEndpoint[Req, Resp]{ - Path: "/foo", - Router: router, - validator: &Validation{}, - dataBinder: godantic.Validate{}, - } - - // Define a request processor function that returns a response body and a nil error - requestProcessor := func(req Req, _ *RequestParams) (*Err, *Resp) { - return nil, &Resp{ - Foo: "bar", - Baz: 123, - } - } - - // Register the request processor function with the APIEndpoint instance - e.HandleCreate("", EndpointConfigs{}, requestProcessor) - - // HandleCreate a mock HTTP POST request - req, err := http.NewRequest(http.MethodPost, "/foo", bytes.NewBuffer([]byte(`{"fo}`))) - if err != nil { - t.Fatal(err) - } - // Set the Content-Type header of the request - req.Header.Set("Content-Type", "application/json") - - // HandleCreate a response recorder - rr := httptest.NewRecorder() - - // Serve the mock HTTP request - engine.ServeHTTP(rr, req) - - // Check that the response has a 200 status code - assert.Equal(t, http.StatusBadRequest, rr.Code) - -} - -func TestCreateWithEmptyBody(t *testing.T) { - // HandleCreate a new APIEndpoint instance - engine := gin.New() - router := engine.Group("") - e := &APIEndpoint[Req, Resp]{ - Path: "/foo", - Router: router, - validator: &Validation{}, - dataBinder: godantic.Validate{}, - } - - // Define a request processor function that returns a response body and a nil error - requestProcessor := func(req Req, _ *RequestParams) (*Err, *Resp) { - return nil, &Resp{ - Foo: "bar", - Baz: 123, - } - } - - // Register the request processor function with the APIEndpoint instance - e.HandleCreate("", EndpointConfigs{}, requestProcessor) - - // HandleCreate a mock HTTP POST request - req, err := http.NewRequest(http.MethodPost, "/foo", bytes.NewBuffer([]byte(`{}`))) - if err != nil { - t.Fatal(err) - } - // Set the Content-Type header of the request - req.Header.Set("Content-Type", "application/json") - - // HandleCreate a response recorder - rr := httptest.NewRecorder() - - // Serve the mock HTTP request - engine.ServeHTTP(rr, req) - - // Check that the response has a 200 status code - assert.Equal(t, http.StatusBadRequest, rr.Code) - -} -func TestCreateWithWrongContentType(t *testing.T) { - // HandleCreate a new APIEndpoint instance - engine := gin.New() - router := engine.Group("") - e := &APIEndpoint[Req, Resp]{ - Path: "/foo", - Router: router, - validator: &Validation{}, - dataBinder: godantic.Validate{}, - } - - // Define a request processor function that returns a response body and a nil error - requestProcessor := func(req Req, _ *RequestParams) (*Err, *Resp) { - return nil, &Resp{ - Foo: "bar", - Baz: 123, - } - } - - // Register the request processor function with the APIEndpoint instance - e.HandleCreate("", EndpointConfigs{}, requestProcessor) - - // HandleCreate a mock HTTP POST request - req, err := http.NewRequest(http.MethodPost, "/foo", bytes.NewBuffer([]byte(`{"foo":"bar","baz":123}`))) - if err != nil { - t.Fatal(err) - } - - // HandleCreate a response recorder - rr := httptest.NewRecorder() - - // Serve the mock HTTP request - engine.ServeHTTP(rr, req) - - // Check that the response has a 200 status code - assert.Equal(t, http.StatusUnprocessableEntity, rr.Code) - -} - -func TestRead(t *testing.T) { - // HandleCreate a new APIEndpoint instance - engine := gin.New() - router := engine.Group("") - e := &APIEndpoint[Req, Resp]{ - Path: "/foo", - Router: router, - validator: &Validation{}, - dataBinder: godantic.Validate{}, - } - - // Define a request processor function that returns a response body and a nil error - requestProcessor := func(values *RequestParams) (*Err, *Resp) { - return nil, &Resp{ - Foo: "bar", - Baz: 123, - } - } - - // Register the request processor function with the APIEndpoint instance - e.HandleRead("/:id", requestProcessor) - - // HandleCreate a mock HTTP POST request with headers - req, err := http.NewRequest(http.MethodGet, "/foo/id?fields=foo,baz", bytes.NewBuffer([]byte(`{"foo":"bar","baz":123}`))) - if err != nil { - t.Fatal(err) - } - req.Header.Set("foo", "bar") - - // HandleCreate a response recorder - rr := httptest.NewRecorder() - - // Serve the mock HTTP request - engine.ServeHTTP(rr, req) - - // Check that the response has a 200 status code - assert.Equal(t, http.StatusOK, rr.Code) - - // Unmarshal the response body into a map - var respMap map[string]interface{} - if err := json.Unmarshal(rr.Body.Bytes(), &respMap); err != nil { - t.Fatal(err) - } - - // Check that the response body is correct - assert.Equal(t, map[string]interface{}{ - "foo": "bar", - "baz": float64(123), - }, respMap) -} - -func TestReadWithoutFields(t *testing.T) { - // HandleCreate a new APIEndpoint instance - engine := gin.New() - router := engine.Group("") - e := &APIEndpoint[Req, Resp]{ - Path: "/foo", - Router: router, - validator: &Validation{}, - dataBinder: godantic.Validate{}, - } - - // Define a request processor function that returns a response body and a nil error - requestProcessor := func(values *RequestParams) (*Err, *Resp) { - return nil, &Resp{ - Foo: "bar", - Baz: 123, - } - } - - // Register the request processor function with the APIEndpoint instance - e.HandleRead("/:id", requestProcessor) - - // HandleCreate a mock HTTP POST request - req, err := http.NewRequest(http.MethodGet, "/foo/id", bytes.NewBuffer([]byte(`{"foo":"bar","baz":123}`))) - if err != nil { - t.Fatal(err) - } - - // HandleCreate a response recorder - rr := httptest.NewRecorder() - - // Serve the mock HTTP request - engine.ServeHTTP(rr, req) - - // Check that the response has a 200 status code - assert.Equal(t, http.StatusOK, rr.Code) - - // Unmarshal the response body into a map - var respMap map[string]interface{} - if err := json.Unmarshal(rr.Body.Bytes(), &respMap); err != nil { - t.Fatal(err) - } - - // Check that the response body is correct - assert.Equal(t, map[string]interface{}{ - "foo": "bar", - "baz": float64(123), - }, respMap) -} - -func TestReadWithoutProcessorError(t *testing.T) { - // HandleCreate a new APIEndpoint instance - engine := gin.New() - router := engine.Group("") - e := &APIEndpoint[Req, Resp]{ - Path: "/foo", - Router: router, - validator: &Validation{}, - dataBinder: godantic.Validate{}, - } - - // Define a request processor function that returns a response body and a nil error - requestProcessor := func(values *RequestParams) (*Err, *Resp) { - return &Err{ - StatusCode: 500, - ErrCode: "someErrCode", - ErrReason: "NoReason", - Message: "Wassap error", - }, nil - } - - // Register the request processor function with the APIEndpoint instance - e.HandleRead("/:id", requestProcessor) - - // HandleCreate a mock HTTP POST request - req, err := http.NewRequest(http.MethodGet, "/foo/id", bytes.NewBuffer([]byte(`{"foo":"bar","baz":123}`))) - if err != nil { - t.Fatal(err) - } - - // HandleCreate a response recorder - rr := httptest.NewRecorder() - - // Serve the mock HTTP request - engine.ServeHTTP(rr, req) - - // Check that the response has a 200 status code - assert.Equal(t, http.StatusInternalServerError, rr.Code) - - // Unmarshal the response body into a map - var respMap map[string]interface{} - if err := json.Unmarshal(rr.Body.Bytes(), &respMap); err != nil { - t.Fatal(err) - } - - // Check that the response body is correct - assert.Equal(t, map[string]interface{}{"code": "someErrCode", "message": "Wassap error", "reason": "NoReason"}, respMap) -} - -func TestReadWithInvalidFIelds(t *testing.T) { - // HandleCreate a new APIEndpoint instance - engine := gin.New() - router := engine.Group("") - e := &APIEndpoint[Req, Resp]{ - Path: "/foo", - Router: router, - validator: &Validation{}, - dataBinder: godantic.Validate{}, - } - - // Define a request processor function that returns a response body and a nil error - requestProcessor := func(values *RequestParams) (*Err, *Resp) { - return nil, &Resp{ - Foo: "bar", - Baz: 123, - } - } - - // Register the request processor function with the APIEndpoint instance - e.HandleRead("/:id", requestProcessor) - - // HandleCreate a mock HTTP POST request - req, err := http.NewRequest(http.MethodGet, "/foo/id?fields=foo,baz,grahms", bytes.NewBuffer([]byte(`{"foo":"bar","baz":123}`))) - if err != nil { - t.Fatal(err) - } - - // HandleCreate a response recorder - rr := httptest.NewRecorder() - - // Serve the mock HTTP request - engine.ServeHTTP(rr, req) - - // Check that the response has a 200 status code - assert.Equal(t, http.StatusBadRequest, rr.Code) - -} - -//func removeNilPointers(data map[string]interface{}) { -// for key, value := range data { -// if value == nil { -// delete(data, key) -// continue -// } -// -// switch v := value.(type) { -// -// case map[string]interface{}: -// removeNilPointers(v) -// -// if len(v) == 0 { -// delete(data, key) -// } -// case []interface{}: -// removeNilPointersFromArray(v) -// if len(v) == 0 { -// delete(data, key) -// } -// } -// } -//} -// -//func removeNilPointersFromArray(arr []interface{}) { -// for i := 0; i < len(arr); i++ { -// if arr[i] == nil { -// arr = append(arr[:i], arr[i+1:]...) -// i-- -// } else if m, ok := arr[i].(map[string]interface{}); ok { -// removeNilPointers(m) -// if len(m) == 0 { -// arr = append(arr[:i], arr[i+1:]...) -// i-- -// } -// } else if subArr, ok := arr[i].([]interface{}); ok { -// removeNilPointersFromArray(subArr) -// if len(subArr) == 0 { -// arr = append(arr[:i], arr[i+1:]...) -// i-- -// } -// } -// } -//} - -type errorReader struct{} - -func (e *errorReader) Read(p []byte) (n int, err error) { - return 0, errors.New("mocked read error") -} - -func TestBindJSON_ReadError(t *testing.T) { - r := &APIEndpoint[Req, Resp]{} // assuming APIEndpoint has been defined elsewhere - - var v any // 'any' is a placeholder; adjust as per your actual type - err := r.bindJSON(&errorReader{}, &v) - - if err == nil { - t.Fatal("expected an error, got nil") - } - - if err.Error() != "mocked read error" { - t.Fatalf("expected error: mocked read error, got: %s", err.Error()) - } -} diff --git a/router/info.go b/router/info.go new file mode 100644 index 0000000..308a7d7 --- /dev/null +++ b/router/info.go @@ -0,0 +1,159 @@ +package router + +import ( + "regexp" + "strings" +) + +// Endpoint represents information about an API endpoint +type Endpoint struct { + Path string + Methods []Method +} + +type Method struct { + HTTPMethod string + Request interface{} + Response interface{} + Description string + StatusCode *int + Tags []string + Summery string + Configs EndpointConfigs +} + +type EndpointConfigs struct { + Name string + Uri string + StatusCode *int + Tags []string + GeneratedTags []string + Descriptions string + AllowedHeaders []AllowedFields + AllowedParams []AllowedFields + PathParams []AllowedFields +} + +type AllowedFields struct { + Name string + Description string + Required bool +} + +func getConfigs(opts ...HandleOption) *EndpointConfigs { + config := &EndpointConfigs{} + for _, opt := range opts { + opt(config) + } + return config +} + +var Endpoints map[string]*Endpoint + +// Initialize the Endpoints map +func init() { + Endpoints = make(map[string]*Endpoint) +} + +func registerEndpoint(path, method string, request, response interface{}, config EndpointConfigs, opts ...HandleOption) { + opts = analyzePathParameters(path, opts...) + re := regexp.MustCompile(`/:(\w+)(/|$)`) + path = re.ReplaceAllString(path, "/{$1}$2") + re = regexp.MustCompile(`{(\w+)}(/|$)`) + _ = re.FindAllStringSubmatch(path, -1) + config = *getConfigs(opts...) + + if endpoint, ok := Endpoints[path]; ok { + for _, m := range endpoint.Methods { + if m.HTTPMethod == method { + return + } + } + // Method does not exist, add it to the endpoint's methods + endpoint.Methods = append(endpoint.Methods, Method{ + HTTPMethod: method, + Request: request, + Response: response, + Description: config.Descriptions, + Tags: config.Tags, + Summery: config.Name, + Configs: config, + }) + } else { + // If the endpoint doesn't exist, create a new entry with the method + Endpoints[path] = &Endpoint{ + Path: path, + Methods: []Method{ + { + HTTPMethod: method, + Request: request, + Response: response, + Description: config.Descriptions, + Tags: config.Tags, + Summery: config.Name, + Configs: config, + }, + }, + } + } +} + +type HandleOption func(*EndpointConfigs) + +func WithName(name string) HandleOption { + return func(c *EndpointConfigs) { + c.Name = name + } +} + +func WithStatusCode(statusCode int) HandleOption { + return func(c *EndpointConfigs) { + c.StatusCode = &statusCode + } +} + +func WithTags(tags []string) HandleOption { + return func(c *EndpointConfigs) { + c.Tags = append(tags) + } +} + +func WithDescriptions(descriptions string) HandleOption { + return func(c *EndpointConfigs) { + c.Descriptions = descriptions + } +} + +func WithAllowedHeaders(headers []AllowedFields) HandleOption { + return func(c *EndpointConfigs) { + c.AllowedHeaders = headers + } +} + +func WithAllowedParams(params []AllowedFields) HandleOption { + return func(c *EndpointConfigs) { + c.AllowedParams = params + } +} +func withPathParams(params []AllowedFields) HandleOption { + return func(c *EndpointConfigs) { + c.PathParams = params + } +} + +func analyzePathParameters(path string, opts ...HandleOption) []HandleOption { + segments := strings.Split(path, "/") + for _, segment := range segments { + if strings.HasPrefix(segment, ":") { + paramName := strings.TrimPrefix(segment, ":") + pathParamConfig := []AllowedFields{ + { + Name: paramName, + Required: true, + }, + } + opts = append(opts, withPathParams(pathParamConfig)) + } + } + return opts +} diff --git a/router/swagger.go b/router/swagger.go new file mode 100644 index 0000000..3f1b706 --- /dev/null +++ b/router/swagger.go @@ -0,0 +1,389 @@ +package router + +import ( + "errors" + "reflect" + "strconv" + "strings" + "time" +) + +type Map map[string]any +type OpenAPI struct { + swagger Map + title string + version string + description string + paths Map + endpoints map[string]*Endpoint +} + +func NewOpenAPI(title, version, description string) *OpenAPI { + swagger := make(Map) + swagger["openapi"] = "3.0.0" + swagger["info"] = Map{ + "title": title, + "version": version, + "description": description, + } + return &OpenAPI{ + swagger: swagger, + title: title, + version: version, + description: description, + paths: make(Map), + } +} + +func (o *OpenAPI) SetEndpoints(endpoints map[string]*Endpoint) *OpenAPI { + o.endpoints = endpoints + return o +} + +func (o *OpenAPI) Build() (Map, error) { + if len(o.endpoints) == 0 { + return nil, errors.New("no endpoints provided") + } + + for _, endpoint := range o.endpoints { + o.paths[endpoint.Path] = o.buildPathItem(endpoint) + } + o.swagger["paths"] = o.paths + return o.swagger, nil +} + +func (o *OpenAPI) buildPathItem(endpoint *Endpoint) Map { + pathItem := make(Map) + path := endpoint.Path + + for _, method := range endpoint.Methods { + pathItem[strings.ToLower(method.HTTPMethod)] = o.buildOperation(method) + } + + o.paths[path] = pathItem + return pathItem +} + +func (o *OpenAPI) buildOperation(method Method) Map { + statusCode := "200" + if method.StatusCode != nil { + statusCode = strconv.Itoa(*method.StatusCode) + } + + operation := Map{ + "responses": Map{ + statusCode: o.buildResponse(), + }, + } + + if method.HTTPMethod != "GET" && method.Request != nil { + operation["requestBody"] = o.buildRequestBody(method.Request) + } + + if method.Response != nil { + schema := Schema{} + operation["responses"].(Map)["200"].(Map)["content"].(Map)["application/json"].(Map)["schema"] = schema.Build(method.Response, "response") + } + tags := method.Tags + + tags = append(method.Configs.Tags) + if tags != nil { + operation["tags"] = tags + } + + operation["description"] = method.Description + operation["summary"] = method.Configs.Name + + parameters := o.buildParameters(method.Configs.AllowedHeaders, method.Configs.AllowedParams, method.Configs.PathParams) + if len(parameters) > 0 { + operation["parameters"] = parameters + } + + return operation +} + +func (o *OpenAPI) buildErrResponse(code, reason, message string) Map { + return Map{ + "description": message, + "content": Map{ + "application/json": Map{ + "schema": Map{ + "type": "object", + "example": Map{ + "code": code, + "reason": reason, + "message": message, + }, + }, + }, + }, + } +} + +func (o *OpenAPI) buildResponse() Map { + return Map{ + "description": "Successful operation", + "content": Map{ + "application/json": Map{ + "schema": Map{ + "type": "object", + }, + }, + }, + } +} + +func (o *OpenAPI) buildRequestBody(request interface{}) Map { + s := Schema{} + requestSchema := s.Build(request, "request") + return Map{ + "required": true, + "content": Map{ + "application/json": Map{ + "schema": requestSchema, + }, + }, + } +} + +func (o *OpenAPI) buildParameters(headers, queryParams, pathParams []AllowedFields) []Map { + parameters := make([]Map, 0) + + for _, header := range headers { + parameters = append(parameters, o.buildHeaderParameter(header)) + } + + for _, param := range queryParams { + parameters = append(parameters, o.buildQueryParamParameter(param)) + } + + for _, param := range pathParams { + parameters = append(parameters, o.buildPathParamParameter(param)) + } + + return parameters +} + +func (o *OpenAPI) buildHeaderParameter(header AllowedFields) Map { + return o.buildParam(header, "header") +} + +func (o *OpenAPI) buildQueryParamParameter(param AllowedFields) Map { + return o.buildParam(param, "query") +} + +func (o *OpenAPI) buildPathParamParameter(param AllowedFields) Map { + return o.buildParam(param, "path") +} + +func (o *OpenAPI) buildParam(param AllowedFields, t string) Map { + return Map{ + "in": t, + "name": param.Name, + "description": param.Description, + "required": param.Required, + "schema": Map{ + "type": "string", + }, + } +} + +type Schema struct{} + +func (sc *Schema) Build(input interface{}, structType string) map[string]interface{} { + + if reflect.TypeOf(input).Kind() == reflect.Ptr { + input = reflect.ValueOf(input).Elem().Interface() + } + t := reflect.TypeOf(input) + + schema := make(map[string]interface{}) + schema["type"] = "object" + + properties := make(map[string]interface{}) + required := make([]string, 0) + + for i := 0; i < t.NumField(); i++ { + field := t.Field(i) + fieldName := sc.getFieldName(field) + if len(fieldName) == 0 { + continue + } + fieldName = field.Tag.Get("json") + required = sc.checkRequired(field, required) + if len(field.Tag.Get("binding")) > 0 { + binding := field.Tag.Get("binding") + switch binding { + case "ignore": + if structType == "request" { + continue + } + + } + } + + fieldSchema := sc.buildFieldSchema(field, structType) + + properties[fieldName] = fieldSchema + + required = sc.checkRequired(field, required) + } + + schema["properties"] = properties + + if len(required) > 0 { + schema["required"] = required + } + + return schema + +} + +func (sc *Schema) buildFieldSchema(field reflect.StructField, sType string) map[string]interface{} { + fieldSchema := make(map[string]interface{}) + + fieldName := field.Tag.Get("json") + if fieldName == "" { + fieldName = field.Name + } + + fieldSchema["name"] = fieldName + description := field.Tag.Get("description") + if description != "" { + fieldSchema["description"] = description + } + + fieldType := field.Type + if fieldType.Kind() == reflect.Ptr { + fieldType = fieldType.Elem() + } + + switch fieldType.Kind() { + + case reflect.Struct: + if fieldType == reflect.TypeOf(time.Time{}) { + fieldSchema["type"] = "string" + fieldSchema["format"] = "date-time" + + } else { + nestedSchema := sc.Build(reflect.New(fieldType).Elem().Interface(), sType) + fieldSchema["type"] = "object" + fieldSchema["properties"] = nestedSchema["properties"] + // If nested struct has required fields, include them in the parent schema + if requiredFields, ok := nestedSchema["required"]; ok { + if required, ok := requiredFields.([]string); ok { + fieldSchema["required"] = required + } + } + } + case reflect.Slice: + + sliceType := fieldType.Elem() + if sliceType.Kind() == reflect.Ptr { + + sliceType = sliceType.Elem() + } + if sliceType.Kind() == reflect.Struct { + + nestedSchema := sc.Build(reflect.New(sliceType).Elem().Interface(), sType) + fieldSchema["type"] = "array" + fieldSchema["items"] = nestedSchema + } else if sliceType.Kind() == reflect.Slice { + + nestedSchema := sc.buildNestedListSchema(sliceType) + fieldSchema["type"] = "array" + fieldSchema["items"] = nestedSchema + } else { + + fieldSchema["type"] = "array" + fieldSchema["items"] = sc.getPrimitiveTypeSchema(sliceType) + } + default: + fieldSchema = sc.getPrimitiveTypeSchema(fieldType) + enums := field.Tag.Get("enums") + if enums != "" { + enumValues := strings.Split(enums, ",") + fieldSchema["enum"] = enumValues + } + } + + regex := field.Tag.Get("regex") + if regex != "" { + + fieldSchema["pattern"] = regex + } + + example := field.Tag.Get("example") + if example != "" { + + fieldSchema["example"] = example + } + + return fieldSchema +} + +func (sc *Schema) getPrimitiveTypeSchema(fieldType reflect.Type) map[string]interface{} { + schema := make(map[string]interface{}) + switch fieldType.Kind() { + case reflect.String: + schema["type"] = "string" + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + schema["type"] = "integer" + schema["format"] = "int64" + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + schema["type"] = "integer" + schema["format"] = "uint64" + case reflect.Float32, reflect.Float64: + schema["type"] = "number" + schema["format"] = "float" + case reflect.Bool: + schema["type"] = "boolean" + default: + schema["type"] = "string" + + } + return schema +} + +func (sc *Schema) buildNestedListSchema(sliceType reflect.Type) map[string]interface{} { + nestedSchema := make(map[string]interface{}) + nestedSchema["type"] = "array" + nestedSchema["items"] = sc.getPrimitiveTypeSchema(sliceType.Elem()) + return nestedSchema +} +func (sc *Schema) getFieldName(field reflect.StructField) string { + if len(field.Tag.Get("json")) > 0 { + return field.Tag.Get("json") + } + return field.Name +} +func (sc *Schema) checkRequired(field reflect.StructField, required []string) []string { + if binding := field.Tag.Get("binding"); len(binding) > 0 && binding == "required" { + required = append(required, sc.getFieldName(field)) + } + return required +} + +const SwagTempl = ` + + + + + Swagger UI + + + +
+ + + + +` diff --git a/swagger.go b/swagger.go index 29c1544..8551057 100644 --- a/swagger.go +++ b/swagger.go @@ -56,11 +56,8 @@ func (o *OpenAPI) Build() (Map, error) { func (o *OpenAPI) buildPathItem(endpoint *router.Endpoint) Map { pathItem := make(Map) path := endpoint.Path - // Find all dynamic segments in the path and replace them with {param}/ re := regexp.MustCompile(`/:(\w+)(/|$)`) path = re.ReplaceAllString(path, "/{$1}/") - - // Build path parameters for each dynamic segment re = regexp.MustCompile(`{(\w+)}/`) matches := re.FindAllStringSubmatch(path, -1) for _, match := range matches { @@ -68,7 +65,6 @@ func (o *OpenAPI) buildPathItem(endpoint *router.Endpoint) Map { if pathItem["parameters"] == nil { pathItem["parameters"] = []Map{} } - // Add path parameter to the path parameters map pathItemParams := make(Map) pathItemParams["name"] = paramName pathItemParams["in"] = "path" @@ -194,12 +190,10 @@ func (sc *Schema) buildSchemaFromStructByType(structType reflect.Type, schemaTyp properties := make(map[string]interface{}) required := make([]string, 0) - // Iterate over the fields of the struct for i := 0; i < structType.NumField(); i++ { field := structType.Field(i) fieldName := field.Name - // Check if the field is required based on struct tags if jsonTag := field.Tag.Get("json"); jsonTag != "" { fieldName = jsonTag } @@ -207,14 +201,10 @@ func (sc *Schema) buildSchemaFromStructByType(structType reflect.Type, schemaTyp required = append(required, fieldName) } - // Determine the schema for the field fieldSchema := sc.buildFieldFromSchema(field, schemaType) - - // Add the field schema to the properties map properties[fieldName] = fieldSchema } - // Add properties and required fields to the schema schema["properties"] = properties if len(required) > 0 { schema["required"] = required @@ -225,7 +215,6 @@ func (sc *Schema) buildSchemaFromStructByType(structType reflect.Type, schemaTyp schema["items"] = sc.buildSchemaFromStructByType(structType, "object") default: - // Unsupported schema type fmt.Println("Unsupported schema type:", schemaType) } @@ -280,7 +269,6 @@ func (sc *Schema) Build(input interface{}, structType string) map[string]interfa schema["properties"] = properties - // Add required fields to the schema if any if len(required) > 0 { schema["required"] = required } @@ -302,7 +290,7 @@ func (sc *Schema) buildNestedSchema(fieldValue reflect.Value, structType string) } else if fieldValue.Kind() == reflect.Struct { return sc.Build(fieldValue.Interface(), structType) } - // Otherwise, return an empty schema + return map[string]interface{}{} } @@ -351,9 +339,9 @@ func (sc *Schema) buildSchemaForStruct(structValue interface{}, structType strin func (sc *Schema) getStructTypeFromList(listType reflect.Type) reflect.Type { if listType.Kind() == reflect.Slice || listType.Kind() == reflect.Array { - // Get the element type of the slice/array + elementType := listType.Elem() - // If the element type is a struct, return it + if elementType.Kind() == reflect.Struct { return elementType } @@ -383,14 +371,11 @@ func (sc *Schema) buildFieldFromSchema(field reflect.StructField, inputType stri case reflect.Float32, reflect.Float64: jsonType = "number" case reflect.Struct: - // If the field is a struct, recursively build schema for it return sc.Build(reflect.New(fieldType), inputType) case reflect.Bool: jsonType = "boolean" case reflect.Slice, reflect.Array: - // Get the element type of the slice/array elementType := fieldType.Elem() - // If the element type is a struct, return it if elementType.Kind() == reflect.Struct { return sc.Build(reflect.New(fieldType).Elem().Interface(), inputType) } @@ -403,7 +388,6 @@ func (sc *Schema) buildFieldFromSchema(field reflect.StructField, inputType stri "type": jsonType, } - // Check if the field has enums if enums := field.Tag.Get("enums"); len(enums) > 0 { enumValues := strings.Split(strings.TrimSpace(enums), ",") schema["enum"] = enumValues @@ -417,27 +401,3 @@ func (sc *Schema) buildSchemaForObject() map[string]interface{} { "type": "object", } } - -var swagTempl = ` - - - - - Swagger UI - - - -
- - - - -` diff --git a/worx.go b/worx.go index ebf6093..696a64a 100644 --- a/worx.go +++ b/worx.go @@ -22,6 +22,7 @@ type Application struct { } func NewRouter[In, Out any](app *Application, path string) *router.APIEndpoint[In, Out] { + return router.New[In, Out](path, app.router.Group("")) } @@ -41,7 +42,7 @@ func NewApplication(path, name, version, description string, middlewares ...gin. noMethod(r) noRoute(r) g := r.Group(path) - return &Application{ + app := &Application{ name: name, path: path, router: g, @@ -49,21 +50,34 @@ func NewApplication(path, name, version, description string, middlewares ...gin. version: version, description: description, } + return app } type _ any func (a *Application) Run(address string) error { + a.renderDocs() + return a.engine.Run(address) +} - s, err := New(a.name, a.version, a.description).SetEndpoints(router.Endpoints).Build() +func (a *Application) renderDocs() { + s, err := router.NewOpenAPI(a.name, a.version, a.description).SetEndpoints(router.Endpoints).Build() if err != nil { panic(err) } bJ, _ := json.Marshal(s) - a.router.GET("/spec", RenderSwagg(string(bJ))) // Serve swagger ui + a.engine.GET("/spec", RenderSwagg(string(bJ))) // Serve swagger ui + a.engine.GET("/openapi.json", func(c *gin.Context) { + + c.Header("Content-Type", "application/json") + c.String(200, string(bJ)) + }) + a.engine.GET("", func(c *gin.Context) { + + c.Data(200, "text/html; charset=utf-8", []byte(redocHTML)) + }) - return a.engine.Run(address) } func noRoute(r *gin.Engine) { r.NoRoute(func(c *gin.Context) { @@ -200,3 +214,54 @@ func RenderSwagg(spec string) func(c *gin.Context) { } } + +const swagTempl = ` + + + + + Swagger UI + + + +
+ + + + +` +const redocHTML = ` + + + + Redoc + + + + + + + + + + + + + + + `