• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

go-fuego / fuego / 12292712605

12 Dec 2024 08:39AM UTC coverage: 93.03% (+0.1%) from 92.901%
12292712605

Pull #262

github

EwenQuim
Removed interface and use a direct struct
Pull Request #262: Decorrelates openapi registration from server

58 of 61 new or added lines in 5 files covered. (95.08%)

10 existing lines in 1 file now uncovered.

2149 of 2310 relevant lines covered (93.03%)

1.06 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

93.21
/openapi.go
1
package fuego
2

3
import (
4
        "context"
5
        "encoding/json"
6
        "errors"
7
        "fmt"
8
        "log/slog"
9
        "net/http"
10
        "os"
11
        "path/filepath"
12
        "reflect"
13
        "regexp"
14
        "slices"
15
        "strconv"
16
        "strings"
17

18
        "github.com/getkin/kin-openapi/openapi3"
19
        "github.com/getkin/kin-openapi/openapi3gen"
20
)
21

22
func NewOpenAPI() *OpenAPI {
1✔
23
        desc := NewOpenApiSpec()
1✔
24
        return &OpenAPI{
1✔
25
                description:            &desc,
1✔
26
                generator:              openapi3gen.NewGenerator(),
1✔
27
                globalOpenAPIResponses: []openAPIError{},
1✔
28
        }
1✔
29
}
1✔
30

31
// Holds the OpenAPI OpenAPIDescription (OAD) and OpenAPI capabilities.
32
type OpenAPI struct {
33
        description            *openapi3.T
34
        generator              *openapi3gen.Generator
35
        globalOpenAPIResponses []openAPIError
36
}
37

38
func (d *OpenAPI) Description() *openapi3.T {
1✔
39
        return d.description
1✔
40
}
1✔
41

42
func (d *OpenAPI) Generator() *openapi3gen.Generator {
1✔
43
        return d.generator
1✔
44
}
1✔
45

46
func NewOpenApiSpec() openapi3.T {
1✔
47
        info := &openapi3.Info{
1✔
48
                Title:       "OpenAPI",
1✔
49
                Description: openapiDescription,
1✔
50
                Version:     "0.0.1",
1✔
51
        }
1✔
52
        spec := openapi3.T{
1✔
53
                OpenAPI:  "3.1.0",
1✔
54
                Info:     info,
1✔
55
                Paths:    &openapi3.Paths{},
1✔
56
                Servers:  []*openapi3.Server{},
1✔
57
                Security: openapi3.SecurityRequirements{},
1✔
58
                Components: &openapi3.Components{
1✔
59
                        Schemas:       make(map[string]*openapi3.SchemaRef),
1✔
60
                        RequestBodies: make(map[string]*openapi3.RequestBodyRef),
1✔
61
                        Responses:     make(map[string]*openapi3.ResponseRef),
1✔
62
                },
1✔
63
        }
1✔
64
        return spec
1✔
65
}
1✔
66

67
// Hide prevents the routes in this server or group from being included in the OpenAPI spec.
68
func (s *Server) Hide() *Server {
1✔
69
        s.DisableOpenapi = true
1✔
70
        return s
1✔
71
}
1✔
72

73
// Show allows displaying the routes. Activated by default so useless in most cases,
74
// but this can be useful if you deactivated the parent group.
75
func (s *Server) Show() *Server {
1✔
76
        s.DisableOpenapi = false
1✔
77
        return s
1✔
78
}
1✔
79

80
func declareAllTagsFromOperations(s *Server) {
1✔
81
        for _, pathItem := range s.OpenAPI.Description().Paths.Map() {
2✔
82
                for _, op := range pathItem.Operations() {
2✔
83
                        for _, tag := range op.Tags {
2✔
84
                                if s.OpenAPI.Description().Tags.Get(tag) == nil {
2✔
85
                                        s.OpenAPI.Description().Tags = append(s.OpenAPI.Description().Tags, &openapi3.Tag{
1✔
86
                                                Name: tag,
1✔
87
                                        })
1✔
88
                                }
1✔
89
                        }
90
                }
91
        }
92
}
93

94
// OutputOpenAPISpec takes the OpenAPI spec and outputs it to a JSON file and/or serves it on a URL.
95
// Also serves a Swagger UI.
96
// To modify its behavior, use the [WithOpenAPIConfig] option.
97
func (s *Server) OutputOpenAPISpec() openapi3.T {
1✔
98
        declareAllTagsFromOperations(s)
1✔
99

1✔
100
        // Validate
1✔
101
        err := s.OpenAPI.Description().Validate(context.Background())
1✔
102
        if err != nil {
1✔
103
                slog.Error("Error validating spec", "error", err)
×
104
        }
×
105

106
        // Marshal spec to JSON
107
        jsonSpec, err := s.marshalSpec()
1✔
108
        if err != nil {
1✔
109
                slog.Error("Error marshaling spec to JSON", "error", err)
×
110
        }
×
111

112
        if !s.OpenAPIConfig.DisableSwagger {
2✔
113
                s.registerOpenAPIRoutes(jsonSpec)
1✔
114
        }
1✔
115

116
        if !s.OpenAPIConfig.DisableLocalSave {
2✔
117
                err := s.saveOpenAPIToFile(s.OpenAPIConfig.JsonFilePath, jsonSpec)
1✔
118
                if err != nil {
1✔
119
                        slog.Error("Error saving spec to local path", "error", err, "path", s.OpenAPIConfig.JsonFilePath)
×
120
                }
×
121
        }
122

123
        return *s.OpenAPI.Description()
1✔
124
}
125

126
func (s *Server) marshalSpec() ([]byte, error) {
1✔
127
        if s.OpenAPIConfig.PrettyFormatJson {
2✔
128
                return json.MarshalIndent(s.OpenAPI.Description(), "", "        ")
1✔
129
        }
1✔
130
        return json.Marshal(s.OpenAPI.Description())
1✔
131
}
132

133
func (s *Server) saveOpenAPIToFile(jsonSpecLocalPath string, jsonSpec []byte) error {
1✔
134
        jsonFolder := filepath.Dir(jsonSpecLocalPath)
1✔
135

1✔
136
        err := os.MkdirAll(jsonFolder, 0o750)
1✔
137
        if err != nil {
1✔
138
                return errors.New("error creating docs directory")
×
139
        }
×
140

141
        f, err := os.Create(jsonSpecLocalPath) // #nosec G304 (file path provided by developer, not by user)
1✔
142
        if err != nil {
2✔
143
                return errors.New("error creating file")
1✔
144
        }
1✔
145
        defer f.Close()
1✔
146

1✔
147
        _, err = f.Write(jsonSpec)
1✔
148
        if err != nil {
1✔
149
                return errors.New("error writing file ")
×
150
        }
×
151

152
        s.printOpenAPIMessage("JSON file: " + jsonSpecLocalPath)
1✔
153
        return nil
1✔
154
}
155

156
// Registers the routes to serve the OpenAPI spec and Swagger UI.
157
func (s *Server) registerOpenAPIRoutes(jsonSpec []byte) {
1✔
158
        GetStd(s, s.OpenAPIConfig.JsonUrl, func(w http.ResponseWriter, r *http.Request) {
2✔
159
                w.Header().Set("Content-Type", "application/json")
1✔
160
                _, _ = w.Write(jsonSpec)
1✔
161
        })
1✔
162
        s.printOpenAPIMessage(fmt.Sprintf("JSON spec: %s://%s%s", s.proto(), s.Server.Addr, s.OpenAPIConfig.JsonUrl))
1✔
163

1✔
164
        if !s.OpenAPIConfig.DisableSwaggerUI {
2✔
165
                Register(s, Route[any, any]{
1✔
166
                        BaseRoute: BaseRoute{
1✔
167
                                Method: http.MethodGet,
1✔
168
                                Path:   s.OpenAPIConfig.SwaggerUrl + "/",
1✔
169
                        },
1✔
170
                }, s.OpenAPIConfig.UIHandler(s.OpenAPIConfig.JsonUrl))
1✔
171
                s.printOpenAPIMessage(fmt.Sprintf("OpenAPI UI: %s://%s%s/index.html", s.proto(), s.Server.Addr, s.OpenAPIConfig.SwaggerUrl))
1✔
172
        }
1✔
173
}
174

175
func (s *Server) printOpenAPIMessage(msg string) {
1✔
176
        if !s.disableStartupMessages {
2✔
177
                slog.Info(msg)
1✔
178
        }
1✔
179
}
180

181
func validateJsonSpecUrl(jsonSpecUrl string) bool {
1✔
182
        jsonSpecUrlRegexp := regexp.MustCompile(`^\/[\/a-zA-Z0-9\-\_]+(.json)$`)
1✔
183
        return jsonSpecUrlRegexp.MatchString(jsonSpecUrl)
1✔
184
}
1✔
185

186
func validateSwaggerUrl(swaggerUrl string) bool {
1✔
187
        swaggerUrlRegexp := regexp.MustCompile(`^\/[\/a-zA-Z0-9\-\_]+[a-zA-Z0-9\-\_]$`)
1✔
188
        return swaggerUrlRegexp.MatchString(swaggerUrl)
1✔
189
}
1✔
190

191
// RegisterOpenAPIOperation registers an OpenAPI operation.
192
func RegisterOpenAPIOperation[T, B any](s *OpenAPI, route Route[T, B]) (*openapi3.Operation, error) {
1✔
193
        if route.Operation == nil {
2✔
194
                route.Operation = openapi3.NewOperation()
1✔
195
        }
1✔
196

197
        // Request Body
198
        if route.Operation.RequestBody == nil {
2✔
199
                bodyTag := SchemaTagFromType(s, *new(B))
1✔
200

1✔
201
                if bodyTag.Name != "unknown-interface" {
2✔
202
                        requestBody := newRequestBody[B](bodyTag, route.AcceptedContentTypes)
1✔
203

1✔
204
                        // add request body to operation
1✔
205
                        route.Operation.RequestBody = &openapi3.RequestBodyRef{
1✔
206
                                Value: requestBody,
1✔
207
                        }
1✔
208
                }
1✔
209
        }
210

211
        // Response - globals
212
        for _, openAPIGlobalResponse := range s.globalOpenAPIResponses {
2✔
213
                addResponseIfNotSet(s, route.Operation, openAPIGlobalResponse.Code, openAPIGlobalResponse.Description, openAPIGlobalResponse.ErrorType)
1✔
214
        }
1✔
215

216
        // Automatically add non-declared 200 (or other) Response
217
        if route.DefaultStatusCode == 0 {
2✔
218
                route.DefaultStatusCode = 200
1✔
219
        }
1✔
220
        defaultStatusCode := strconv.Itoa(route.DefaultStatusCode)
1✔
221
        responseDefault := route.Operation.Responses.Value(defaultStatusCode)
1✔
222
        if responseDefault == nil {
2✔
223
                response := openapi3.NewResponse().WithDescription(http.StatusText(route.DefaultStatusCode))
1✔
224
                route.Operation.AddResponse(route.DefaultStatusCode, response)
1✔
225
                responseDefault = route.Operation.Responses.Value(defaultStatusCode)
1✔
226
        }
1✔
227

228
        // Automatically add non-declared Content for 200 (or other) Response
229
        if responseDefault.Value.Content == nil {
2✔
230
                responseSchema := SchemaTagFromType(s, *new(T))
1✔
231
                content := openapi3.NewContentWithSchemaRef(&responseSchema.SchemaRef, []string{"application/json", "application/xml"})
1✔
232
                responseDefault.Value.WithContent(content)
1✔
233
        }
1✔
234

235
        // Automatically add non-declared Path parameters
236
        for _, pathParam := range parsePathParams(route.Path) {
2✔
237
                if exists := route.Operation.Parameters.GetByInAndName("path", pathParam); exists != nil {
2✔
238
                        continue
1✔
239
                }
240
                parameter := openapi3.NewPathParameter(pathParam)
1✔
241
                parameter.Schema = openapi3.NewStringSchema().NewRef()
1✔
242
                if strings.HasSuffix(pathParam, "...") {
1✔
UNCOV
243
                        parameter.Description += " (might contain slashes)"
×
UNCOV
244
                }
×
245

246
                route.Operation.AddParameter(parameter)
1✔
247
        }
248
        for _, params := range route.Operation.Parameters {
2✔
249
                if params.Value.In == "path" {
2✔
250
                        if !strings.Contains(route.Path, "{"+params.Value.Name) {
2✔
251
                                return nil, fmt.Errorf("path parameter '%s' is not declared in the path", params.Value.Name)
1✔
252
                        }
1✔
253
                }
254
        }
255

256
        s.Description().AddOperation(route.Path, route.Method, route.Operation)
1✔
257

1✔
258
        return route.Operation, nil
1✔
259
}
260

261
func newRequestBody[RequestBody any](tag SchemaTag, consumes []string) *openapi3.RequestBody {
1✔
262
        content := openapi3.NewContentWithSchemaRef(&tag.SchemaRef, consumes)
1✔
263
        return openapi3.NewRequestBody().
1✔
264
                WithRequired(true).
1✔
265
                WithDescription("Request body for " + reflect.TypeOf(*new(RequestBody)).String()).
1✔
266
                WithContent(content)
1✔
267
}
1✔
268

269
// SchemaTag is a struct that holds the name of the struct and the associated openapi3.SchemaRef
270
type SchemaTag struct {
271
        openapi3.SchemaRef
272
        Name string
273
}
274

275
func SchemaTagFromType(s *OpenAPI, v any) SchemaTag {
1✔
276
        if v == nil {
2✔
277
                // ensure we add unknown-interface to our schemas
1✔
278
                schema := s.getOrCreateSchema("unknown-interface", struct{}{})
1✔
279
                return SchemaTag{
1✔
280
                        Name: "unknown-interface",
1✔
281
                        SchemaRef: openapi3.SchemaRef{
1✔
282
                                Ref:   "#/components/schemas/unknown-interface",
1✔
283
                                Value: schema,
1✔
284
                        },
1✔
285
                }
1✔
286
        }
1✔
287

288
        return dive(s, reflect.TypeOf(v), SchemaTag{}, 5)
1✔
289
}
290

291
// dive returns a schemaTag which includes the generated openapi3.SchemaRef and
292
// the name of the struct being passed in.
293
// If the type is a pointer, map, channel, function, or unsafe pointer,
294
// it will dive into the type and return the name of the type it points to.
295
// If the type is a slice or array type it will dive into the type as well as
296
// build and openapi3.Schema where Type is array and Ref is set to the proper
297
// components Schema
298
func dive(s *OpenAPI, t reflect.Type, tag SchemaTag, maxDepth int) SchemaTag {
1✔
299
        if maxDepth == 0 {
2✔
300
                return SchemaTag{
1✔
301
                        Name: "default",
1✔
302
                        SchemaRef: openapi3.SchemaRef{
1✔
303
                                Ref: "#/components/schemas/default",
1✔
304
                        },
1✔
305
                }
1✔
306
        }
1✔
307

308
        switch t.Kind() {
1✔
309
        case reflect.Ptr, reflect.Map, reflect.Chan, reflect.Func, reflect.UnsafePointer:
1✔
310
                return dive(s, t.Elem(), tag, maxDepth-1)
1✔
311

312
        case reflect.Slice, reflect.Array:
1✔
313
                item := dive(s, t.Elem(), tag, maxDepth-1)
1✔
314
                tag.Name = item.Name
1✔
315
                tag.Value = openapi3.NewArraySchema()
1✔
316
                tag.Value.Items = &item.SchemaRef
1✔
317
                return tag
1✔
318

319
        default:
1✔
320
                tag.Name = t.Name()
1✔
321
                if t.Kind() == reflect.Struct && strings.HasPrefix(tag.Name, "DataOrTemplate") {
2✔
322
                        return dive(s, t.Field(0).Type, tag, maxDepth-1)
1✔
323
                }
1✔
324
                tag.Ref = "#/components/schemas/" + tag.Name
1✔
325
                tag.Value = s.getOrCreateSchema(tag.Name, reflect.New(t).Interface())
1✔
326

1✔
327
                return tag
1✔
328
        }
329
}
330

331
// getOrCreateSchema is used to get a schema from the OpenAPI spec.
332
// If the schema does not exist, it will create a new schema and add it to the OpenAPI spec.
333
func (s *OpenAPI) getOrCreateSchema(key string, v any) *openapi3.Schema {
1✔
334
        schemaRef, ok := s.Description().Components.Schemas[key]
1✔
335
        if !ok {
2✔
336
                schemaRef = s.createSchema(key, v)
1✔
337
        }
1✔
338
        return schemaRef.Value
1✔
339
}
340

341
// createSchema is used to create a new schema and add it to the OpenAPI spec.
342
// Relies on the openapi3gen package to generate the schema, and adds custom struct tags.
343
func (s *OpenAPI) createSchema(key string, v any) *openapi3.SchemaRef {
1✔
344
        schemaRef, err := s.Generator().NewSchemaRefForValue(v, s.Description().Components.Schemas)
1✔
345
        if err != nil {
1✔
NEW
346
                slog.Error("Error generating schema", "key", key, "error", err)
×
UNCOV
347
        }
×
348
        schemaRef.Value.Description = key + " schema"
1✔
349

1✔
350
        descriptionable, ok := v.(OpenAPIDescriptioner)
1✔
351
        if ok {
1✔
UNCOV
352
                schemaRef.Value.Description = descriptionable.Description()
×
NEW
353
        }
×
354

355
        parseStructTags(reflect.TypeOf(v), schemaRef)
1✔
356

1✔
357
        s.Description().Components.Schemas[key] = schemaRef
1✔
358

1✔
359
        return schemaRef
1✔
360
}
361

362
// parseStructTags parses struct tags and modifies the schema accordingly.
363
// t must be a struct type.
364
// It adds the following struct tags (tag => OpenAPI schema field):
365
// - description => description
366
// - example => example
367
// - json => nullable (if contains omitempty)
368
// - validate:
369
//   - required => required
370
//   - min=1 => min=1 (for integers)
371
//   - min=1 => minLength=1 (for strings)
372
//   - max=100 => max=100 (for integers)
373
//   - max=100 => maxLength=100 (for strings)
374
func parseStructTags(t reflect.Type, schemaRef *openapi3.SchemaRef) {
1✔
375
        if t.Kind() == reflect.Ptr {
2✔
376
                t = t.Elem()
1✔
377
        }
1✔
378

379
        if t.Kind() != reflect.Struct {
2✔
380
                return
1✔
381
        }
1✔
382

383
        for i := range t.NumField() {
2✔
384
                field := t.Field(i)
1✔
385

1✔
386
                if field.Anonymous {
2✔
387
                        fieldType := field.Type
1✔
388
                        parseStructTags(fieldType, schemaRef)
1✔
389
                        continue
1✔
390
                }
391

392
                jsonFieldName := field.Tag.Get("json")
1✔
393
                jsonFieldName = strings.Split(jsonFieldName, ",")[0] // remove omitempty, etc
1✔
394
                if jsonFieldName == "-" {
2✔
395
                        continue
1✔
396
                }
397
                if jsonFieldName == "" {
2✔
398
                        jsonFieldName = field.Name
1✔
399
                }
1✔
400

401
                property := schemaRef.Value.Properties[jsonFieldName]
1✔
402
                if property == nil {
2✔
403
                        slog.Warn("Property not found in schema", "property", jsonFieldName)
1✔
404
                        continue
1✔
405
                }
406
                propertyCopy := *property
1✔
407
                propertyValue := *propertyCopy.Value
1✔
408

1✔
409
                // Example
1✔
410
                example, ok := field.Tag.Lookup("example")
1✔
411
                if ok {
2✔
412
                        propertyValue.Example = example
1✔
413
                        if propertyValue.Type.Is(openapi3.TypeInteger) {
2✔
414
                                exNum, err := strconv.Atoi(example)
1✔
415
                                if err != nil {
1✔
UNCOV
416
                                        slog.Warn("Example might be incorrect (should be integer)", "error", err)
×
UNCOV
417
                                }
×
418
                                propertyValue.Example = exNum
1✔
419
                        }
420
                }
421

422
                // Validation
423
                validateTag, ok := field.Tag.Lookup("validate")
1✔
424
                validateTags := strings.Split(validateTag, ",")
1✔
425
                if ok && slices.Contains(validateTags, "required") {
2✔
426
                        schemaRef.Value.Required = append(schemaRef.Value.Required, jsonFieldName)
1✔
427
                }
1✔
428
                for _, validateTag := range validateTags {
2✔
429
                        if strings.HasPrefix(validateTag, "min=") {
2✔
430
                                min, err := strconv.Atoi(strings.Split(validateTag, "=")[1])
1✔
431
                                if err != nil {
1✔
UNCOV
432
                                        slog.Warn("Min might be incorrect (should be integer)", "error", err)
×
UNCOV
433
                                }
×
434

435
                                if propertyValue.Type.Is(openapi3.TypeInteger) {
2✔
436
                                        minPtr := float64(min)
1✔
437
                                        propertyValue.Min = &minPtr
1✔
438
                                } else if propertyValue.Type.Is(openapi3.TypeString) {
3✔
439
                                        //nolint:gosec // disable G115
1✔
440
                                        propertyValue.MinLength = uint64(min)
1✔
441
                                }
1✔
442
                        }
443
                        if strings.HasPrefix(validateTag, "max=") {
2✔
444
                                max, err := strconv.Atoi(strings.Split(validateTag, "=")[1])
1✔
445
                                if err != nil {
1✔
UNCOV
446
                                        slog.Warn("Max might be incorrect (should be integer)", "error", err)
×
UNCOV
447
                                }
×
448
                                if propertyValue.Type.Is(openapi3.TypeInteger) {
2✔
449
                                        maxPtr := float64(max)
1✔
450
                                        propertyValue.Max = &maxPtr
1✔
451
                                } else if propertyValue.Type.Is(openapi3.TypeString) {
3✔
452
                                        //nolint:gosec // disable G115
1✔
453
                                        maxPtr := uint64(max)
1✔
454
                                        propertyValue.MaxLength = &maxPtr
1✔
455
                                }
1✔
456
                        }
457
                }
458

459
                // Description
460
                description, ok := field.Tag.Lookup("description")
1✔
461
                if ok {
2✔
462
                        propertyValue.Description = description
1✔
463
                }
1✔
464
                jsonTag, ok := field.Tag.Lookup("json")
1✔
465
                if ok {
2✔
466
                        if strings.Contains(jsonTag, ",omitempty") {
2✔
467
                                propertyValue.Nullable = true
1✔
468
                        }
1✔
469
                }
470
                propertyCopy.Value = &propertyValue
1✔
471

1✔
472
                schemaRef.Value.Properties[jsonFieldName] = &propertyCopy
1✔
473
        }
474
}
475

476
type OpenAPIDescriptioner interface {
477
        Description() string
478
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc