Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
ed61350
test: assert raw openapi spec matches fixture
gaby Aug 21, 2025
0cded2b
refactor: split route media types
gaby Aug 22, 2025
886ed13
feat: add route tags and deprecation
gaby Aug 22, 2025
60d4596
test: expand openapi middleware coverage
gaby Sep 4, 2025
149c053
test: cover openapi helpers
gaby Sep 4, 2025
ddbaa0f
feat(openapi): add operation helpers
gaby Sep 19, 2025
4ab58fd
refactor: use maps.Copy for metadata cloning
gaby Sep 21, 2025
9b1a022
docs: clarify openapi type reference
gaby Sep 21, 2025
2b4fa75
test: normalize openapi json assertions
gaby Oct 24, 2025
2bab831
chore: address openapi review feedback
gaby Oct 25, 2025
3c33f84
fix: scope openapi handler to its resolved path
gaby Nov 3, 2025
7da513d
Adjust OpenAPI default responses
gaby Dec 27, 2025
abe75b9
Extend OpenAPI helpers with schema refs and examples
gaby Jan 3, 2026
87d1f44
Merge branch 'main' into 2025-08-21-14-48-18
gaby Jan 9, 2026
868cf03
Merge branch 'main' into 2025-08-21-14-48-18
gaby Jan 16, 2026
ac3bc5c
Merge branch 'main' into 2025-08-21-14-48-18
gaby Jan 19, 2026
615d8e9
Merge branch 'main' into 2025-08-21-14-48-18
gaby Jan 21, 2026
abcc817
Merge branch 'main' into 2025-08-21-14-48-18
gaby Jan 23, 2026
603b4ab
Merge branch 'main' into 2025-08-21-14-48-18
gaby Jan 26, 2026
d092785
πŸ› bug: fix merge conflicts and auto-generated HEAD routes in OpenAPI …
Claude Mar 27, 2026
0c16991
πŸ”₯ feat: merge parallel benchmarks from main into router_test.go
Claude Mar 27, 2026
272d61e
🧹 chore: fix lint issues and optimize struct alignment
Claude Mar 27, 2026
1ecf5d1
Merge branch 'main' into 2025-08-21-14-48-18
gaby Mar 27, 2026
b76e04d
🧹 chore: address review comments - clone Tags, filter middleware rout…
Copilot Mar 27, 2026
9158f56
🧹 chore: fix deprecated utils.ToLower lint warning
Copilot Mar 27, 2026
d342624
βœ… test: add auto-HEAD exclusion test for OpenAPI middleware
Copilot Mar 27, 2026
4e33e7d
Merge branch 'main' into 2025-08-21-14-48-18
gaby Mar 29, 2026
49ee7a2
fix: add OpenAPI methods to domainRouter, defensive-copy Tags slice, …
Copilot Mar 29, 2026
38a32b9
refactor: simplify Tags() defensive copy
Copilot Mar 29, 2026
9946f64
πŸ”’ security: harden OpenAPI middleware - fix path generation, add nil …
Claude Mar 30, 2026
8dcfd7c
πŸ§ͺ test: improve openapi middleware coverage to 93.1%
Claude Mar 30, 2026
e3918d1
Merge branch 'main' into 2025-08-21-14-48-18
gaby Apr 1, 2026
057e9eb
Merge branch 'main' into 2025-08-21-14-48-18
gaby Apr 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
257 changes: 255 additions & 2 deletions app.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"errors"
"fmt"
"io"
"mime"
"net"
"net/http"
"net/http/httputil"
Expand Down Expand Up @@ -805,6 +806,257 @@ func (app *App) Name(name string) Router {
return app
}

// Summary assigns a short summary to the most recently added route.
func (app *App) Summary(sum string) Router {
app.mutex.Lock()
app.latestRoute.Summary = sum
app.mutex.Unlock()
return app
}

// Description assigns a description to the most recently added route.
func (app *App) Description(desc string) Router {
app.mutex.Lock()
app.latestRoute.Description = desc
app.mutex.Unlock()
return app
}

// Consumes assigns a request media type to the most recently added route.
func (app *App) Consumes(typ string) Router {
if typ != "" {
typ = strings.TrimSpace(typ)
if _, _, err := mime.ParseMediaType(typ); err != nil || !strings.Contains(typ, "/") {
panic("invalid media type: " + typ)
}
}
app.mutex.Lock()
app.latestRoute.Consumes = typ
app.mutex.Unlock()
return app
}

// Produces assigns a response media type to the most recently added route.
func (app *App) Produces(typ string) Router {
if typ != "" {
typ = strings.TrimSpace(typ)
if _, _, err := mime.ParseMediaType(typ); err != nil || !strings.Contains(typ, "/") {
panic("invalid media type: " + typ)
}
}
app.mutex.Lock()
Comment on lines +825 to +847
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consumes() / Produces() validate typ without trimming whitespace, while RequestBody/Response media type handling trims and de-dupes. This makes Consumes(" application/json") (or trailing spaces) panic unexpectedly. Consider normalizing via strings.TrimSpace (or reusing the same sanitization helper used elsewhere) before validating/storing.

Copilot uses AI. Check for mistakes.
app.latestRoute.Produces = typ
app.mutex.Unlock()
return app
}

// RequestBody documents the request payload for the most recently added route.
func (app *App) RequestBody(description string, required bool, mediaTypes ...string) Router {
return app.RequestBodyWithExample(description, required, nil, "", nil, nil, mediaTypes...)
}

// RequestBodyWithExample documents the request payload with schema references and examples.
func (app *App) RequestBodyWithExample(description string, required bool, schema map[string]any, schemaRef string, example any, examples map[string]any, mediaTypes ...string) Router {
sanitized := sanitizeRequiredMediaTypes(mediaTypes)

body := &RouteRequestBody{
Description: description,
Required: required,
MediaTypes: append([]string(nil), sanitized...),
SchemaRef: schemaRef,
Example: example,
Examples: copyAnyMap(examples),
}
if schemaRef != "" {
body.Schema = map[string]any{"$ref": schemaRef}
} else if len(schema) > 0 {
body.Schema = copyAnyMap(schema)
}

app.mutex.Lock()
app.latestRoute.RequestBody = body
if len(sanitized) > 0 {
app.latestRoute.Consumes = sanitized[0]
}
app.mutex.Unlock()

return app
}

// Parameter documents an input parameter for the most recently added route.
func (app *App) Parameter(name, in string, required bool, schema map[string]any, description string) Router {
return app.addParameter(name, in, required, schema, "", description, nil, nil)
}

// ParameterWithExample documents an input parameter, including schema references and examples.
func (app *App) ParameterWithExample(name, in string, required bool, schema map[string]any, schemaRef, description string, example any, examples map[string]any) Router {
return app.addParameter(name, in, required, schema, schemaRef, description, example, examples)
}

func (app *App) addParameter(name, in string, required bool, schema map[string]any, schemaRef, description string, example any, examples map[string]any) Router {
if strings.TrimSpace(name) == "" {
panic("parameter name is required")
}

location := strings.ToLower(strings.TrimSpace(in))
switch location {
case "path", "query", "header", "cookie":
default:
panic("invalid parameter location: " + in)
}

if schemaRef != "" {
schema = map[string]any{"$ref": schemaRef}
} else if schema == nil {
schema = map[string]any{"type": "string"}
}

schemaCopy := copyAnyMap(schema)
if schemaCopy == nil {
schemaCopy = map[string]any{"type": "string"}
}
if schemaRef == "" {
if _, ok := schemaCopy["type"]; !ok {
schemaCopy["type"] = "string"
}
}

if location == "path" {
required = true
}

param := RouteParameter{
Name: name,
In: location,
Required: required,
Description: description,
Schema: schemaCopy,
SchemaRef: schemaRef,
Example: example,
Examples: copyAnyMap(examples),
}

app.mutex.Lock()
app.latestRoute.Parameters = append(app.latestRoute.Parameters, param)
app.mutex.Unlock()

return app
}

// Response documents an HTTP response for the most recently added route.
func (app *App) Response(status int, description string, mediaTypes ...string) Router {
return app.addResponse(status, description, nil, "", nil, nil, mediaTypes...)
}

// ResponseWithExample documents an HTTP response with schema references and examples.
func (app *App) ResponseWithExample(status int, description string, schema map[string]any, schemaRef string, example any, examples map[string]any, mediaTypes ...string) Router {
return app.addResponse(status, description, schema, schemaRef, example, examples, mediaTypes...)
}

func (app *App) addResponse(status int, description string, schema map[string]any, schemaRef string, example any, examples map[string]any, mediaTypes ...string) Router {
if status != 0 && (status < 100 || status > 599) {
panic("invalid status code")
}

sanitized := sanitizeMediaTypes(mediaTypes)

if description == "" {
if status == 0 {
description = "Default response"
} else if text := http.StatusText(status); text != "" {
description = text
} else {
description = "Status " + strconv.Itoa(status)
}
}

key := "default"
if status > 0 {
key = strconv.Itoa(status)
}

resp := RouteResponse{Description: description}
if len(sanitized) > 0 {
resp.MediaTypes = append([]string(nil), sanitized...)
}
if schemaRef != "" {
resp.SchemaRef = schemaRef
resp.Schema = map[string]any{"$ref": schemaRef}
} else if len(schema) > 0 {
resp.Schema = copyAnyMap(schema)
}
resp.Example = example
resp.Examples = copyAnyMap(examples)

app.mutex.Lock()
if app.latestRoute.Responses == nil {
app.latestRoute.Responses = make(map[string]RouteResponse)
}
app.latestRoute.Responses[key] = resp
if status == StatusOK && len(resp.MediaTypes) > 0 {
app.latestRoute.Produces = resp.MediaTypes[0]
}
app.mutex.Unlock()

return app
}

func sanitizeMediaTypes(mediaTypes []string) []string {
if len(mediaTypes) == 0 {
return nil
}

seen := make(map[string]struct{}, len(mediaTypes))
sanitized := make([]string, 0, len(mediaTypes))
for _, typ := range mediaTypes {
trimmed := strings.TrimSpace(typ)
if trimmed == "" {
continue
}
if _, _, err := mime.ParseMediaType(trimmed); err != nil || !strings.Contains(trimmed, "/") {
panic("invalid media type: " + trimmed)
}
if _, ok := seen[trimmed]; ok {
continue
}
seen[trimmed] = struct{}{}
sanitized = append(sanitized, trimmed)
}
if len(sanitized) == 0 {
return nil
}
return sanitized
}

func sanitizeRequiredMediaTypes(mediaTypes []string) []string {
sanitized := sanitizeMediaTypes(mediaTypes)
if len(sanitized) == 0 {
panic("at least one media type must be provided")
}
return sanitized
}

// Tags assigns tags to the most recently added route.
func (app *App) Tags(tags ...string) Router {
app.mutex.Lock()
var copied []string
if len(tags) > 0 {
copied = make([]string, len(tags))
copy(copied, tags)
}
app.latestRoute.Tags = copied
app.mutex.Unlock()
return app
}

// Deprecated marks the most recently added route as deprecated.
func (app *App) Deprecated() Router {
app.mutex.Lock()
app.latestRoute.Deprecated = true
app.mutex.Unlock()
return app
}

// GetRoute Get route by name
func (app *App) GetRoute(name string) Route {
for _, routes := range app.stack {
Expand Down Expand Up @@ -861,7 +1113,7 @@ func (app *App) Use(args ...any) Router {
var prefix string
var subApp *App
var prefixes []string
var handlers []Handler
var handlers []any

for i := range args {
switch arg := args[i].(type) {
Expand Down Expand Up @@ -889,7 +1141,8 @@ func (app *App) Use(args ...any) Router {
return app.mount(prefix, subApp)
}

app.register([]string{methodUse}, prefix, nil, handlers...)
converted := collectHandlers("use", handlers...)
app.register([]string{methodUse}, prefix, nil, converted...)
}

return app
Expand Down
Loading
Loading