Files
gswagger/route.go
2024-11-08 19:52:34 +01:00

317 lines
8.8 KiB
Go

package swagger
import (
"errors"
"fmt"
"path"
"regexp"
"sort"
"strings"
"github.com/getkin/kin-openapi/openapi3"
"github.com/invopop/jsonschema"
)
var (
// ErrResponses is thrown if error occurs generating responses schemas.
ErrResponses = errors.New("errors generating responses schema")
// ErrRequestBody is thrown if error occurs generating responses schemas.
ErrRequestBody = errors.New("errors generating request body schema")
// ErrPathParams is thrown if error occurs generating path params schemas.
ErrPathParams = errors.New("errors generating path parameters schema")
// ErrQuerystring is thrown if error occurs generating querystring params schemas.
ErrQuerystring = errors.New("errors generating querystring schema")
)
// AddRawRoute add route to router with specific method, path and handler. Add the
// router also to the openapi schema, after validating it
func (r Router[HandlerFunc, Route]) AddRawRoute(method string, routePath string, handler HandlerFunc, operation Operation) (Route, error) {
op := operation.Operation
if op != nil {
err := operation.Validate(r.context)
if err != nil {
return getZero[Route](), err
}
} else {
op = openapi3.NewOperation()
if op.Responses == nil {
op.Responses = openapi3.NewResponses()
}
}
pathWithPrefix := path.Join(r.pathPrefix, routePath)
oasPath := r.router.TransformPathToOasPath(pathWithPrefix)
r.swaggerSchema.AddOperation(oasPath, method, op)
// Handle, when content-type is json, the request/response marshalling? Maybe with a specific option.
return r.router.AddRoute(method, pathWithPrefix, handler), nil
}
// Content is the type of a content.
// The key of the map define the content-type.
type Content map[string]Schema
// Schema contains the value and if properties allow additional properties.
type Schema struct {
Value interface{}
AllowAdditionalProperties bool
}
type Parameter struct {
Content Content
Schema *Schema
Description string
}
// ParameterValue is the struct containing the schema or the content information.
// If content is specified, it takes precedence.
type ParameterValue map[string]Parameter
// ContentValue is the struct containing the content information.
type ContentValue struct {
Content Content
Description string
}
type SecurityRequirements []SecurityRequirement
type SecurityRequirement map[string][]string
// Definitions of the route.
// To see how to use, refer to https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md
type Definitions struct {
// Specification extensions https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#specification-extensions
Extensions map[string]interface{}
// Optional field for documentation
Tags []string
Summary string
Description string
Deprecated bool
// PathParams contains the path parameters. If empty is autocompleted from the path
PathParams ParameterValue
Querystring ParameterValue
Headers ParameterValue
Cookies ParameterValue
RequestBody *ContentValue
Responses map[int]ContentValue
Security SecurityRequirements
}
func newOperationFromDefinition(schema Definitions) Operation {
operation := NewOperation()
operation.Responses = &openapi3.Responses{}
operation.Tags = schema.Tags
operation.Extensions = schema.Extensions
operation.addSecurityRequirements(schema.Security)
operation.Description = schema.Description
operation.Summary = schema.Summary
operation.Deprecated = schema.Deprecated
return operation
}
const (
pathParamsType = "path"
queryParamType = "query"
headerParamType = "header"
cookieParamType = "cookie"
)
// AddRoute add a route with json schema inferred by passed schema.
func (r Router[HandlerFunc, Route]) AddRoute(method string, path string, handler HandlerFunc, schema Definitions) (Route, error) {
operation := newOperationFromDefinition(schema)
err := r.resolveRequestBodySchema(schema.RequestBody, operation)
if err != nil {
return getZero[Route](), fmt.Errorf("%w: %s", ErrRequestBody, err)
}
err = r.resolveResponsesSchema(schema.Responses, operation)
if err != nil {
return getZero[Route](), fmt.Errorf("%w: %s", ErrResponses, err)
}
oasPath := r.router.TransformPathToOasPath(path)
err = r.resolveParameterSchema(pathParamsType, getPathParamsAutoComplete(schema, oasPath), operation)
if err != nil {
return getZero[Route](), fmt.Errorf("%w: %s", ErrPathParams, err)
}
err = r.resolveParameterSchema(queryParamType, schema.Querystring, operation)
if err != nil {
return getZero[Route](), fmt.Errorf("%w: %s", ErrPathParams, err)
}
err = r.resolveParameterSchema(headerParamType, schema.Headers, operation)
if err != nil {
return getZero[Route](), fmt.Errorf("%w: %s", ErrPathParams, err)
}
err = r.resolveParameterSchema(cookieParamType, schema.Cookies, operation)
if err != nil {
return getZero[Route](), fmt.Errorf("%w: %s", ErrPathParams, err)
}
return r.AddRawRoute(method, path, handler, operation)
}
func (r Router[_, _]) getSchemaFromInterface(v interface{}, allowAdditionalProperties bool) (*openapi3.Schema, error) {
if v == nil {
return &openapi3.Schema{}, nil
}
reflector := &jsonschema.Reflector{
DoNotReference: true,
AllowAdditionalProperties: allowAdditionalProperties,
Anonymous: true,
}
jsonSchema := reflector.Reflect(v)
jsonSchema.Version = ""
// Empty definitions. Definitions are not valid in openapi3, which use components.
// In the future, we could add an option to fill the components in openapi spec.
jsonSchema.Definitions = nil
data, err := jsonSchema.MarshalJSON()
if err != nil {
return nil, err
}
schema := openapi3.NewSchema()
err = schema.UnmarshalJSON(data)
if err != nil {
return nil, err
}
return schema, nil
}
func (r Router[_, _]) resolveRequestBodySchema(bodySchema *ContentValue, operation Operation) error {
if bodySchema == nil {
return nil
}
content, err := r.addContentToOASSchema(bodySchema.Content)
if err != nil {
return err
}
requestBody := openapi3.NewRequestBody().WithContent(content)
if bodySchema.Description != "" {
requestBody.WithDescription(bodySchema.Description)
}
operation.AddRequestBody(requestBody)
return nil
}
func (r Router[_, _]) resolveResponsesSchema(responses map[int]ContentValue, operation Operation) error {
if responses == nil {
operation.Responses = openapi3.NewResponses()
}
for statusCode, v := range responses {
response := openapi3.NewResponse()
content, err := r.addContentToOASSchema(v.Content)
if err != nil {
return err
}
response = response.WithContent(content)
response = response.WithDescription(v.Description)
operation.AddResponse(statusCode, response)
}
return nil
}
func (r Router[_, _]) resolveParameterSchema(paramType string, paramConfig ParameterValue, operation Operation) error {
var keys = make([]string, 0, len(paramConfig))
for k := range paramConfig {
keys = append(keys, k)
}
sort.Strings(keys)
for _, key := range keys {
v := paramConfig[key]
var param *openapi3.Parameter
switch paramType {
case pathParamsType:
param = openapi3.NewPathParameter(key)
case queryParamType:
param = openapi3.NewQueryParameter(key)
case headerParamType:
param = openapi3.NewHeaderParameter(key)
case cookieParamType:
param = openapi3.NewCookieParameter(key)
default:
return fmt.Errorf("invalid param type")
}
if v.Description != "" {
param = param.WithDescription(v.Description)
}
if v.Content != nil {
content, err := r.addContentToOASSchema(v.Content)
if err != nil {
return err
}
param.Content = content
} else {
schema := openapi3.NewSchema()
if v.Schema != nil {
var err error
schema, err = r.getSchemaFromInterface(v.Schema.Value, v.Schema.AllowAdditionalProperties)
if err != nil {
return err
}
}
param.WithSchema(schema)
}
operation.AddParameter(param)
}
return nil
}
func (r Router[_, _]) addContentToOASSchema(content Content) (openapi3.Content, error) {
oasContent := openapi3.NewContent()
for k, v := range content {
var err error
schema, err := r.getSchemaFromInterface(v.Value, v.AllowAdditionalProperties)
if err != nil {
return nil, err
}
oasContent[k] = openapi3.NewMediaType().WithSchema(schema)
}
return oasContent, nil
}
func getPathParamsAutoComplete(schema Definitions, path string) ParameterValue {
if schema.PathParams == nil {
re := regexp.MustCompile(`\{([^}]+)\}`)
segments := strings.Split(path, "/")
for _, segment := range segments {
params := re.FindAllStringSubmatch(segment, -1)
if len(params) == 0 {
continue
}
if schema.PathParams == nil {
schema.PathParams = make(ParameterValue)
}
for _, param := range params {
schema.PathParams[param[1]] = Parameter{
Schema: &Schema{Value: ""},
}
}
}
}
return schema.PathParams
}
func getZero[T any]() T {
var result T
return result
}