diff --git a/.cursor/rules/tests.mdc b/.cursor/rules/tests.mdc new file mode 100644 index 0000000..95c3383 --- /dev/null +++ b/.cursor/rules/tests.mdc @@ -0,0 +1,38 @@ +--- +description: +globs: +alwaysApply: false +--- +Write tests using TDD with the hydronica.trial package following this pattern. +The most important thing is to keep things as simple and understandable as possible. + +``` +func TestFunction(t *testing.T) { + // basic function that takes the table's input and returns the output value and an error. + // All errors cases should be handled as an error response + // if no errors return nil in the second value + // Keep the input and output values as simple as possible, preferable primitive types or the exact values of the function being tested + // do not create inline structs, define the struct in the test function first, if a simple struct is needed + fn := func(i string) (RawJSON, error) { + var v RawJSON + err := json.Unmarshal([]byte(i), &v) + return v, err + } + // Tables (test cases) should be clear and as simple as possible to provide logical coverage. + cases := trial.Cases[string, RawJSON]{ + "test_case": { + Input:{}, + Expected:{}, + } + } + + trial.New(fn, cases).SubTest(t) +} +``` + +When dealing time using the following helper functions +- TimeHour(s string) - uses format "2006-01-02T15" +- TimeDay(s string) - uses format "2006-01-02" +- Time(layout string, value) +- Times(layout string, values ...string) +- TimeP(layout string, s string) returns a *time.Time diff --git a/README.md b/README.md index 7920da8..1f65c85 100644 --- a/README.md +++ b/README.md @@ -1,153 +1,633 @@ # GoOpenAPI -A Go Lang SDK to help create OpenApi 3.0.3 Spec -[OpenAPI spec](https://swagger.io/specification/) +A Go SDK for building OpenAPI 3.0.3 specifications programmatically. Generate complete OpenAPI documentation from your Go code with type-safe schema generation and comprehensive security support. [![codecov](https://codecov.io/gh/hydronica/go-openapi/graph/badge.svg?token=E3I51BL34W)](https://codecov.io/gh/hydronica/go-openapi) +## Features -## OpenAPI Document Breakdown - +- **Complete OpenAPI 3.0.3 Support**: Generate valid OpenAPI specifications +- **Type-Safe Schema Generation**: Automatic schema creation from Go structs, maps, and JSON +- **Comprehensive Security**: Support for API Key, Bearer, Basic, OAuth2, and OpenID Connect +- **Parameter Management**: Path, query, header, and cookie parameters with examples +- **Request/Response Bodies**: Multiple content types and examples +- **Route Management**: Fluent API for building routes and operations +- **Schema Compilation**: Automatic schema consolidation and reference generation +## Installation -## Getting Started +```bash +go get github.com/hydronica/go-openapi +``` + +## Quick Start + +### Basic Usage + +```go +package main -``` go import ( - _ "embed" - + "fmt" "github.com/hydronica/go-openapi" ) -// go:embed base.json -var base string func main() { + // Create a new OpenAPI document + doc := openapi.New("My API", "1.0.0", "A sample API") - // create doc from base template - doc, err := openapi.NewFromJson(base) - if err != nil { - log.Fatal(err) - } - - // create doc from scratch - doc = openapi.New("title", "v1.0.0", "all about this API") - - doc.AddRoute( - openapi.NewRoute("/path/v1", "get"). - AddResponse( - openapi.Resp{Code: 200, Desc:"valid response"}.WithJSONString('{"status":"ok"}' - ). - AddRequest( - openapi.Req{MType: "application/json", Desc:"pull data"}. - WithParams(myStruct) - ) - ) - - // print generated json document - fmt.Println(string(doc.JSON())) + // Add a route + route := doc.GetRoute("/users/{id}", "GET") + route.AddResponse(openapi.Response{ + Status: 200, + Desc: "User retrieved successfully", + }.WithExample(map[string]any{ + "id": 123, + "name": "John Doe", + "email": "john@example.com", + })) + + // Generate JSON + fmt.Println(doc.JSON()) } ``` -### Adding Schema +### From Existing JSON -#### JSONExample - - Easiest to implement - - go-openapi converts to object - - hard to read unique hash16 name of object based on keys - - No way to add descriptions to fields +```go +//go:embed openapi.json +var baseSpec string -``` go - openapi.NewRoute("path/v1/test","post"). - AddRequest(openapi.RequestBody{}.WithJSONString(`{"name":"bob","age":99,"country":"United States"}`) +doc, err := openapi.NewFromJson(baseSpec) +if err != nil { + log.Fatal(err) +} ``` -``` json -{ - "title": "fd4b3d4f5cce2e6d", - "type": "object", - "properties": { - "age": { - "type": "number" - }, - "country": { - "type": "string" +## Schema Generation + +### 1. Go Structs (Recommended) + +Use Go structs with `json` and `desc` tags for the cleanest schema generation: + +```go +type User struct { + ID int `json:"id" desc:"Unique user identifier"` + Name string `json:"name" desc:"User's full name"` + Email string `json:"email" desc:"User's email address"` + Active bool `json:"active" desc:"Whether the user is active"` + Created time.Time `json:"created" desc:"Account creation timestamp"` +} + +route.AddRequest(openapi.RequestBody{}.WithExample(User{})) +``` + +**Benefits:** +- Clear, readable schema names (`User` instead of hash) +- Field descriptions from `desc` tags +- Type-safe and refactor-friendly + +### 2. JSON Strings + +Convert JSON strings directly to schemas: + +```go +route.AddRequest(openapi.RequestBody{}.WithJSONString(`{ + "name": "John Doe", + "age": 30, + "email": "john@example.com" +}`)) +``` + +**Benefits:** +- Quick prototyping +- Easy for simple schemas + +**Limitations:** +- Auto-generated hash names +- No field descriptions + +### 3. Example Maps + +Use `map[string]Example` for detailed field descriptions: + +```go +route.AddRequest(openapi.RequestBody{}.WithExample(map[string]openapi.Example{ + "name": {Value: "John Doe", Desc: "User's full name"}, + "age": {Value: 30, Desc: "User's age in years"}, + "email": {Value: "john@example.com", Desc: "User's email address"}, +})) +``` + +**Benefits:** +- Field-level descriptions +- Flexible value types + +**Limitations:** +- Auto-generated hash names +- More verbose syntax + +## Route Management + +### Creating Routes + +```go +// Get or create a route +route := doc.GetRoute("/users/{id}", "GET") + +// Add tags for grouping +route.Tags("users", "management") + +// Add summary +route.Summary = "Get user by ID" +``` + +### Path Parameters + +Path parameters are automatically detected from the path: + +```go +route := doc.GetRoute("/users/{id}/posts/{postId}", "GET") +// Automatically creates path parameters for 'id' and 'postId' + +// Add examples for path parameters +route.PathParam("id", 123, "User ID") +route.PathParam("postId", "abc-123", "Post ID") +``` + +### Query Parameters + +```go +// Single parameter +route.QueryParam("limit", 10, "Maximum number of results") +route.QueryParam("offset", 0, "Number of results to skip") + +// Multiple parameters from struct +type QueryParams struct { + Limit int `json:"limit" desc:"Maximum results"` + Offset int `json:"offset" desc:"Results to skip"` + Search string `json:"search" desc:"Search term"` +} +route.QueryParams(QueryParams{Limit: 10, Offset: 0, Search: "example"}) + +// Multiple parameters from map +route.QueryParams(map[string]any{ + "limit": 10, + "offset": 0, + "search": "example", +}) +``` + +### Header Parameters + +```go +route.HeaderParam("X-API-Version", "v1", "API version") +route.HeaderParam("X-Request-ID", "abc-123", "Request identifier") +``` + +### Cookie Parameters + +```go +route.CookieParam("session", "abc123", "Session identifier") +``` + +### Multiple Parameter Examples + +```go +// Multiple examples for a single parameter +route.QueryParam("status", []string{"active", "inactive", "pending"}, "User status") + +// Using Example struct for custom names +route.QueryParam("priority", []openapi.Example{ + {Summary: "low", Value: 1}, + {Summary: "medium", Value: 5}, + {Summary: "high", Value: 10}, +}, "Priority level") +``` + +## Request and Response Bodies + +### Request Bodies + +```go +// From struct +type CreateUserRequest struct { + Name string `json:"name" desc:"User's name"` + Email string `json:"email" desc:"User's email"` +} + +route.AddRequest(openapi.RequestBody{ + Desc: "User creation data", + Required: true, +}.WithExample(CreateUserRequest{})) + +// Multiple examples +route.AddRequest(openapi.RequestBody{}. + WithNamedExample("admin", CreateUserRequest{Name: "Admin", Email: "admin@example.com"}). + WithNamedExample("user", CreateUserRequest{Name: "User", Email: "user@example.com"})) +``` + +### Response Bodies + +```go +// Success response +route.AddResponse(openapi.Response{ + Status: 200, + Desc: "User created successfully", +}.WithExample(User{})) + +// Error response +route.AddResponse(openapi.Response{ + Status: 400, + Desc: "Invalid request", +}.WithJSONString(`{"error": "validation failed", "details": "name is required"}`)) + +// Multiple status codes +route.AddResponse(openapi.Response{Status: 200, Desc: "Success"}.WithExample(User{})) +route.AddResponse(openapi.Response{Status: 404, Desc: "Not found"}.WithJSONString(`{"error": "user not found"}`)) +route.AddResponse(openapi.Response{Status: 500, Desc: "Server error"}.WithJSONString(`{"error": "internal server error"}`)) +``` + +## Security + +### API Key Authentication + +```go +// Header-based API key +doc.AddAPIKeyAuth("YourUniqueNameApiKeyAuth", "X-API-Key", openapi.APIKeyInHeader, "API key for authentication") + +// Query parameter API key +doc.AddAPIKeyAuth("ApiKeyQuery", "api_key", openapi.APIKeyInQuery, "API key as query parameter") + +// Cookie-based API key +doc.AddAPIKeyAuth("ApiKeyCookie", "auth_token", openapi.APIKeyInCookie, "API key in cookie") +``` + +### Bearer Token Authentication + +```go +doc.AddBearerAuth("BearerAuth", openapi.BearerFormatJWT, "Bearer token authentication") +``` + +### Basic Authentication + +```go +doc.AddBasicAuth("BasicAuth", "HTTP Basic authentication") +``` + +### OAuth2 Authentication + +```go +flows := &openapi.Flows{ + AuthorizationCode: &openapi.Flow{ + AuthorizationURL: "https://example.com/oauth/authorize", + TokenURL: "https://example.com/oauth/token", + Scopes: map[string]string{ + "read": "Read access", + "write": "Write access", + "admin": "Admin access", + }, }, - "name": { - "type": "string" - } - } } +doc.AddOAuth2Auth("OAuth2", flows, "OAuth2 authentication") ``` -#### map[string]openapi.Example - - hard to read unique hash16 name of object based on keys - - Ability to add Description to fields +### OpenID Connect +```go +doc.AddOpenIDConnectAuth("OpenIDConnect", "https://example.com/.well-known/openid_configuration", "OpenID Connect authentication") +``` + +### Security Requirements + +#### Global Security Requirements + +```go +// Single security requirement for all endpoints +doc.AddSecurityRequirement("ApiKeyAuth", []string{}) + +// OAuth2 with scopes for all endpoints +doc.AddSecurityRequirement("OAuth2", []string{"read", "write"}) + +// Multiple security schemes (AND logic) for all endpoints +doc.AddMultipleSecurityRequirement(map[string][]string{ + "ApiKeyAuth": {}, + "OAuth2": {"read"}, +}) +``` + +#### Route-Specific Security Requirements + +```go +// Add security to individual routes +route := doc.GetRoute("/admin/users", "POST") + +// Single security requirement for this route only +route.AddSecurity("OAuth2Auth", "admin", "write") + +// Multiple security schemes for this route (AND logic) +route.AddMultipleSecurity(map[string][]string{ + "BearerAuth": {}, + "ApiKeyAuth": {}, +}) + +// Different routes can have different security requirements +publicRoute := doc.GetRoute("/public", "GET") +// No security requirements needed for public route + +protectedRoute := doc.GetRoute("/protected", "GET") +protectedRoute.AddSecurity("BearerAuth") + +adminRoute := doc.GetRoute("/admin", "DELETE") +adminRoute.AddSecurity("OAuth2Auth", "admin") + +// Clear security from a route if needed +route.ClearSecurity() +``` + +### Available Constants + +The library provides constants for common security values: + +```go +// Security scheme types +openapi.SecurityTypeAPIKey // "apiKey" +openapi.SecurityTypeHTTP // "http" +openapi.SecurityTypeOAuth2 // "oauth2" +openapi.SecurityTypeOpenID // "openIdConnect" + +// HTTP authentication schemes +openapi.HTTPSchemeBearer // "bearer" +openapi.HTTPSchemeBasic // "basic" + +// API key locations +openapi.APIKeyInQuery // "query" +openapi.APIKeyInHeader // "header" +openapi.APIKeyInCookie // "cookie" -``` go -openapi.NewRoute("path/v1/test","post"). - AddRequest(RequestBody{}.WithExample(map[string]Example{ - "age": {Value: 12, Desc: "age in earth years"}, - "country": {Value: "USA", Desc: "3 character ISO Code"}, - "name": {Value: "bob", Desc: "individual name"}, - }) +// Common bearer token formats +openapi.BearerFormatJWT // "JWT" ``` -``` json -{ - "title": "fd4b3d4f5cce2e6d", - "type": "object", - "properties": { - "age": { - "type": "integer", - "description": "age in earth years" +## Advanced Features + +### Tags + +```go +doc.AddTags( + openapi.Tag{ + Name: "users", + Desc: "User management operations", }, - "country": { - "type": "string", - "description": "3 character ISO Code" + openapi.Tag{ + Name: "posts", + Desc: "Post management operations", + }, +) +``` + +### Servers + +```go +doc.Servers = []openapi.Server{ + { + URL: "https://api.example.com/v1", + Desc: "Production server", + }, + { + URL: "https://staging-api.example.com/v1", + Desc: "Staging server", }, - "name": { - "type": "string", - "description": "individual name" - } - } } ``` -#### struct - - description pulled from the `desc` struct tag - - clear title based on the struct name - - +### Path Conversion + +Convert Go-style paths to OpenAPI format: + +```go +// Convert ":id" to "{id}" +path := openapi.CleanPath("/users/:id/posts/:postId") +// Result: "/users/{id}/posts/{postId}" +``` + +### Custom Time Formatting + +```go +type Event struct { + Name string `json:"name"` + Date openapi.Time `json:"date"` +} -``` go -type myStruct struct { - Name string `json:"name" desc:"individual name"` - Age int `json:"age" desc:"age in earth years"` - Country string `json:"country" desc:"3 character ISO Code"` +event := Event{ + Name: "Meeting", + Date: openapi.Time{Time: time.Now(), Format: "2006-01-02"}, } +``` + +### Schema Compilation +Compile the document to consolidate schemas and validate: -openapi.NewRoute("path/v1/test","post"). - AddRequest(RequestBody{}.WithExample(myStruct{}) +```go +if err := doc.Compile(); err != nil { + log.Printf("Validation errors: %v", err) +} ``` -``` json -{ - "title": "openapi.myStruct", - "type": "object", - "properties": { - "age": { - "type": "integer", - "description": "age in earth years" - }, - "country": { - "type": "string", - "description": "3 character ISO Code" - }, - "name": { - "type": "string", - "description": "individual name" +### Custom Schema Names + +```go +// Set custom name for JSON schema +jsonData := openapi.JSONString(`{"name": "value"}`).SetName("CustomSchema") +route.AddRequest(openapi.RequestBody{}.WithExample(jsonData)) +``` + +## Complete Example + +```go +package main + +import ( + "fmt" + "time" + "github.com/hydronica/go-openapi" +) + +type User struct { + ID int `json:"id" desc:"Unique user identifier"` + Name string `json:"name" desc:"User's full name"` + Email string `json:"email" desc:"User's email address"` + Active bool `json:"active" desc:"Whether the user is active"` + Created time.Time `json:"created" desc:"Account creation timestamp"` +} + +type CreateUserRequest struct { + Name string `json:"name" desc:"User's name"` + Email string `json:"email" desc:"User's email"` +} + +type ErrorResponse struct { + Error string `json:"error" desc:"Error message"` + Details string `json:"details" desc:"Error details"` +} + +func main() { + // Create document + doc := openapi.New("User API", "1.0.0", "A simple user management API") + + // Add security schemes + doc.AddBearerAuth("BearerAuth", openapi.BearerFormatJWT, "Bearer token authentication") + + // Add OAuth2 for admin endpoints + flows := &openapi.Flows{ + AuthorizationCode: &openapi.Flow{ + AuthorizationURL: "https://example.com/oauth/authorize", + TokenURL: "https://example.com/oauth/token", + Scopes: map[string]string{ + "read": "Read access to user data", + "write": "Write access to user data", + "admin": "Administrative access", + }, + }, + } + doc.AddOAuth2Auth("OAuth2Auth", flows, "OAuth2 authentication for admin operations") + + // Add tags + doc.AddTags(openapi.Tag{ + Name: "users", + Desc: "User management operations", + }) + + // GET /users + listRoute := doc.GetRoute("/users", "GET") + listRoute.Tags("users") + listRoute.Summary = "List users" + listRoute.QueryParam("limit", 10, "Maximum number of users to return") + listRoute.QueryParam("offset", 0, "Number of users to skip") + listRoute.AddResponse(openapi.Response{ + Status: 200, + Desc: "List of users", + }.WithExample([]User{{ID: 1, Name: "John Doe", Email: "john@example.com", Active: true}})) + + // POST /users + createRoute := doc.GetRoute("/users", "POST") + createRoute.Tags("users") + createRoute.Summary = "Create user" + createRoute.AddRequest(openapi.RequestBody{ + Desc: "User creation data", + Required: true, + }.WithExample(CreateUserRequest{Name: "Jane Doe", Email: "jane@example.com"})) + createRoute.AddResponse(openapi.Response{ + Status: 201, + Desc: "User created successfully", + }.WithExample(User{ID: 2, Name: "Jane Doe", Email: "jane@example.com", Active: true})) + createRoute.AddResponse(openapi.Response{ + Status: 400, + Desc: "Invalid request", + }.WithExample(ErrorResponse{Error: "validation failed", Details: "name is required"})) + + // GET /users/{id} - requires bearer auth + getRoute := doc.GetRoute("/users/{id}", "GET") + getRoute.Tags("users") + getRoute.Summary = "Get user by ID" + getRoute.PathParam("id", 123, "User ID") + getRoute.AddSecurity("BearerAuth") // Route-specific security + getRoute.AddResponse(openapi.Response{ + Status: 200, + Desc: "User details", + }.WithExample(User{ID: 1, Name: "John Doe", Email: "john@example.com", Active: true})) + getRoute.AddResponse(openapi.Response{ + Status: 404, + Desc: "User not found", + }.WithExample(ErrorResponse{Error: "not found", Details: "user with id 123 not found"})) + + // DELETE /users/{id} - requires admin OAuth2 scope + deleteRoute := doc.GetRoute("/users/{id}", "DELETE") + deleteRoute.Tags("users") + deleteRoute.Summary = "Delete user" + deleteRoute.PathParam("id", 123, "User ID") + deleteRoute.AddSecurity("OAuth2Auth", "admin") // Admin-only endpoint + deleteRoute.AddResponse(openapi.Response{ + Status: 204, + Desc: "User deleted successfully", + }) + deleteRoute.AddResponse(openapi.Response{ + Status: 403, + Desc: "Insufficient permissions", + }.WithExample(ErrorResponse{Error: "forbidden", Details: "admin scope required"})) + + // GET /public/info - no security required + publicRoute := doc.GetRoute("/public/info", "GET") + publicRoute.Tags("public") + publicRoute.Summary = "Get public information" + publicRoute.AddResponse(openapi.Response{ + Status: 200, + Desc: "Public information", + }.WithJSONString(`{"version": "1.0.0", "status": "operational"}`)) + // No security requirements for public endpoints + + // Compile and validate + if err := doc.Compile(); err != nil { + fmt.Printf("Validation errors: %v\n", err) } - } + + // Output JSON + fmt.Println(doc.JSON()) } -``` \ No newline at end of file +``` + +## API Reference + +### Core Types + +- `OpenAPI`: Main document structure +- `Route`: Individual API endpoint +- `RequestBody`: Request body definition +- `Response`: Response definition +- `Param`: Parameter definition +- `Schema`: Data type definition +- `Example`: Example value with description + +### Methods + +#### Document Methods +- `New(title, version, description)`: Create new document +- `NewFromJson(spec)`: Create from existing JSON +- `GetRoute(path, method)`: Get or create route +- `JSON()`: Generate JSON output +- `Compile()`: Validate and consolidate schemas + +#### Global Security Methods +- `AddAPIKeyAuth(name, keyName, location, description)`: Add API key authentication +- `AddBearerAuth(name, bearerFormat, description)`: Add bearer token authentication +- `AddBasicAuth(name, description)`: Add basic authentication +- `AddOAuth2Auth(name, flows, description)`: Add OAuth2 authentication +- `AddOpenIDConnectAuth(name, url, description)`: Add OpenID Connect authentication +- `AddSecurityRequirement(schemeName, scopes...)`: Add global security requirement +- `AddMultipleSecurityRequirement(schemes)`: Add multiple global security schemes + +#### Route Methods +- `AddResponse(response)`: Add response to route +- `AddRequest(request)`: Add request body to route +- `AddSecurity(schemeName, scopes...)`: Add security requirement to specific route +- `AddMultipleSecurity(schemes)`: Add multiple security schemes to specific route +- `ClearSecurity()`: Remove all security requirements from route +- `PathParam(name, value, desc)`: Add path parameter +- `QueryParam(name, value, desc)`: Add query parameter +- `HeaderParam(name, value, desc)`: Add header parameter +- `CookieParam(name, value, desc)`: Add cookie parameter +- `Tags(tags...)`: Add tags to route + +#### Request/Response Methods +- `WithExample(value)`: Add example to request/response +- `WithJSONString(json)`: Add JSON string example +- `WithNamedExample(name, value)`: Add named example + +## Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. + +## License + +This project is licensed under the MIT License - see the LICENSE file for details. \ No newline at end of file diff --git a/build.go b/build.go index 75360ec..8ea30c3 100644 --- a/build.go +++ b/build.go @@ -14,6 +14,8 @@ import ( "time" ) +// NewFromJson creates a new OpenAPI object from an existing json string +// This allows the user to create a spec with a base structure and then add to it. func NewFromJson(spec string) (api *OpenAPI, err error) { api = &OpenAPI{ Paths: make(Router), @@ -238,11 +240,11 @@ func (o *OpenAPI) Compile() error { o.Components.Schemas = make(map[string]Schema) } var errs error - for _, r := range o.Paths { - if r.Requests != nil { - for k, c := range r.Requests.Content { + for _, path := range o.Paths { + if path.Requests != nil { + for k, c := range path.Requests.Content { if k == "invalid/json" { - errs = errors.Join(errs, fmt.Errorf("invalid json %v request at %v: %q", r.method, r.path, c.Examples["invalid"].Value)) + errs = errors.Join(errs, fmt.Errorf("invalid json %v request at %v: %q", path.method, path.path, c.Examples["invalid"].Value)) continue } if c.Schema.Type != Object { @@ -252,13 +254,13 @@ func (o *OpenAPI) Compile() error { o.Components.Schemas[c.Schema.Title] = c.Schema } c.Schema = Schema{Ref: "#/components/schemas/" + c.Schema.Title} - r.Requests.Content[k] = c + path.Requests.Content[k] = c } } - for _, resp := range r.Responses { + for _, resp := range path.Responses { for k, c := range resp.Content { if k == "invalid/json" { - errs = errors.Join(errs, fmt.Errorf("invalid json %v response at %v: %q", r.method, r.path, c.Examples["invalid"].Value)) + errs = errors.Join(errs, fmt.Errorf("invalid json %v response at %v: %q", path.method, path.path, c.Examples["invalid"].Value)) continue } if c.Schema.Type != Object { @@ -272,7 +274,7 @@ func (o *OpenAPI) Compile() error { } } - for _, p := range r.Params { + for _, p := range path.Params { if strings.Contains(p.Desc, "err:") { errs = errors.Join(errs, fmt.Errorf("%v param %v| %v", p.In, p.Name, p.Desc)) } diff --git a/cmd/gherkin/main.go b/cmd/gherkin/main.go index 512eb64..498a362 100644 --- a/cmd/gherkin/main.go +++ b/cmd/gherkin/main.go @@ -147,7 +147,7 @@ func main() { // generate the output swagger doc f, err := os.Create(c.Out) if err != nil { - log.Fatalf("issue with writing %q: %w", c.Out, err) + log.Fatalf("issue with writing %q: %+v", c.Out, err) } f.Write([]byte(doc.JSON())) } diff --git a/docs/chart.drawio.svg b/docs/chart.drawio.svg index 080ed69..4dc4b43 100644 --- a/docs/chart.drawio.svg +++ b/docs/chart.drawio.svg @@ -1,11 +1,11 @@ - + - + -
+
OpenAPI @@ -19,20 +19,20 @@
- + OpenAPI... - - - - - + + + + + -
+
Route @@ -40,16 +40,16 @@
- + Route - + -
+

@@ -72,16 +72,16 @@

- + Tags: []string... - + -
+
path|method @@ -89,63 +89,20 @@
- + path|method - - - - - + + + + + -
-
-
- Media -
-
-
-
- - Media - -
-
- - - - -
-
-
- Response - -
- Desc: string -
- Status Code -
-
-
-
-
-
- - Response... - -
-
- - - - - - -
+
Code @@ -153,35 +110,16 @@
- + Code - - - + -
-
-
- MIMEType -
-
-
-
- - MIMEType - -
-
- - - - -
+
Info @@ -189,7 +127,7 @@
- + Info @@ -197,7 +135,7 @@ -
+

@@ -235,18 +173,20 @@

- + Title: string... - - - + + + + + -
+
Paths: Router @@ -254,18 +194,18 @@
- + Paths: Router - - - + + + -
+
Tags: []Tag @@ -273,18 +213,18 @@
- + Tags: []Tag - - - + + + -
+
Servers:[]Server @@ -292,16 +232,16 @@
- + Servers:[]Server - + -
+
Params: @@ -312,16 +252,16 @@
- + Params: []Param - + -
+
RequestBody @@ -329,18 +269,18 @@
- + RequestBody - - - + + + -
+
Responses @@ -348,16 +288,16 @@
- + Responses - + -
+
ExternalDocs: @@ -365,117 +305,185 @@
- + ExternalDocs: - - - + -
+
- Content + Example
- - Content + + Example - + -
+
- Schema -
- -
-
+ Tag
- - Schema + + Tag - -
+
+
+
+ + + Name: string +
+ Desc: string +
+
+
+
+
+
+ + + Name: string... + + + + + + + +
+
+
+ ExternalDocs: +
+
+
+
+ + ExternalDocs: + +
+
+ + + + +
- Request Body + Server
- - Request Body + + Server
-
-
+
+
-

- + + + URL: string +
Desc: string +
-

-

- - Required: bool - -

+
- - Desc: string... + + URL: string... + + + + + + + +
+
+
+ ExternalDocs: +
+
+
+
+ + ExternalDocs:
- + + + -
+
+
+
+ Components +
+
+
+ + + Components + + + + + + + +
- Content + Component
- - Content + + Component
- - - + + + -
+
Schema @@ -483,236 +491,280 @@
- + Schema - - - + + + -
+
-
- Examples +
+ id
- - Examples + + id -
-
+
+
- - - - Title: string -
-
-
- - Desc: string -
- Type: string -
-
-
-
+ @schema
- - Title: string... + + @schema + + + + + + + +
+
+
+ Security +
+
+
+
+ + Security
- - - + + + -
+
- Items + Media
- - Items + + Media - - - + -
+
- Properties + Response + +
+ Desc: string +
+ Status Code +
+
- - Properties + + Response... - - - + + + -
+
- Name + MIMEType
- - Name + + MIMEType - + + + -
+
- Param + Content
- - Param + + Content + -
-
+
+
+
+ Schema +
+ +
+
+
+
+
+ + + Schema + + + + + + + +
+
+
+ Request Body +
+
+
+
+ + Request Body + +
+
+ + + +
+

- - Name: string - -

-

- + Desc: string

- In: string + Required: bool -
-

-

-

- - Name: string... + + Desc: string...
- - - + -
+
- Schema + Content
- - Schema + + Content - - - + + + -
+
- Examples + Schema
- - Examples + + Schema - + + + -
+
- Example + Examples
- - Example + + Examples -
+
+ + + Title: string +
+
+
- Summary: string -
- Desc: String -
- Value: any + Desc: string +
+ Type: string +

@@ -720,231 +772,253 @@
- - Summary: string... + + Title: string... - - - + + + -
+
-
- Name +
+ Items
- - Name + + Items - + + + -
+
- Tag + Properties
- - Tag + + Properties + + + -
-
-
- - - Name: string -
- Desc: string -
-
-
+
+
+
+ Name
- - Name: string... + + Name - + -
-
-
- ExternalDocs: +
+
+
+ Param
- - ExternalDocs: + + Param - -
-
-
- Server +
+
+
+

+ + Name: string + +

+

+ + Desc: string + +

+

+ + In: string + +
+

+

+
+

- - Server + + Name: string... + + + -
-
-
- - - URL: string -
- Desc: string -
-
-
+
+
+
+ Schema
- - URL: string... + + Schema - + + + -
-
-
- ExternalDocs: +
+
+
+ Examples
- - ExternalDocs: + + Examples - - - -
-
-
- Components +
+
+
+ + + Summary: string +
+ Desc: String +
+ Value: any +
+
+
- - Components + + Summary: string... - + + + -
+
-
- Component +
+ Name
- - Component + + Name - - - + -
-
-
- Schema +
+
+
+ Global +
+ Security
- - Schema + + Global... - - - + + + -
-
-
- id +
+
+
+ JWT
- - id + + JWT + -
+
-
- @schema +
+ API Key
- - @schema + + API Key diff --git a/openapi.go b/openapi.go index c01b910..252b128 100644 --- a/openapi.go +++ b/openapi.go @@ -4,15 +4,19 @@ import ( "strconv" ) +type SecurityRequirement map[string][]string + // OpenAPI represents the definition of the openapi specification 3.0.3 +// https://swagger.io/specification/v3/#openapi-object type OpenAPI struct { - Version string `json:"openapi"` // the semantic version number of the OpenAPI Specification version - Servers []Server `json:"servers,omitempty"` // Array of Server Objects, which provide connectivity information to a target server. - Info Info `json:"info"` // REQUIRED. Provides metadata about the API. The metadata MAY be used by tooling as required. - Tags []Tag `json:"tags,omitempty"` // A list of tags used by the specification with additional metadata - Paths Router `json:"paths"` // key= path|method - Components Components `json:"components,omitempty"` // reuseable components - ExternalDocs *ExternalDocs `json:"externalDocs,omitempty"` //Additional external documentation. + Version string `json:"openapi"` // the semantic version number of the OpenAPI Specification version + Servers []Server `json:"servers,omitempty"` // Array of Server Objects, which provide connectivity information to a target server. + Info Info `json:"info"` // https://swagger.io/specification/v3/#info-object - REQUIRED. Provides metadata about the API. The metadata MAY be used by tooling as required. + Tags []Tag `json:"tags,omitempty"` // A list of tags used by the specification with additional metadata + Paths Router `json:"paths"` // https://swagger.io/specification/v3/#paths-object - key= path|method + Components Components `json:"components,omitempty"` // https://swagger.io/specification/v3/#components-object - reuseable components + ExternalDocs *ExternalDocs `json:"externalDocs,omitempty"` // https://swagger.io/specification/v3/#external-documentation-object - Additional external documentation. + Security []SecurityRequirement `json:"security,omitempty"` // https://swagger.io/specification/v3/#security-requirement-object - Lists the required security schemes to execute this operation. The name used for each property MUST correspond to a security scheme declared in the Security Schemes under the Components Object. } type Server struct { @@ -89,19 +93,16 @@ type Media struct { Examples map[string]Example `json:"examples,omitempty"` // NOT Supported: - //Example of the media type. The example object SHOULD be in the correct format as specified by the media type. The example field is mutually exclusive of the examples field. Furthermore, if referencing a schema which contains an example, the example value SHALL override the example provided by the schema. - //Example any `json:"example,omitempty"` -> uses examples even for one example - //A map between a property name and its encoding information. The key, being the property name, MUST exist in the schema as a property. //Encoding map[string]Encoding `json:"encoding,omitempty"` } type Components struct { - Schemas map[string]Schema `json:"schemas,omitempty"` + Schemas map[string]Schema `json:"schemas,omitempty"` + SecuritySchemes map[string]SecurityScheme `json:"securitySchemes,omitempty"` //NOT implemented /* Parameters []Params - SecuritySchemes struct{} RequestBodies []RequestBody Responses Responses Headers []Params @@ -110,6 +111,31 @@ type Components struct { Callbacks struct{} */ } +type SecurityScheme struct { + Type string `json:"type"` // REQUIRED. The type of the security scheme. Valid values are "apiKey", "http", "oauth2", "openIdConnect". + In string `json:"in"` // REQUIRED. The location of the API key. Valid values are "query", "header", or "cookie". + Name string `json:"name"` // REQUIRED. The name of the header, query or cookie parameter to be used. + Description string `json:"description,omitempty"` // A description for security scheme. CommonMark syntax MAY be used for rich text representation. + Scheme string `json:"scheme,omitempty"` // REQUIRED. The name of the HTTP Authentication scheme to be used + BearerFormat string `json:"bearerFormat,omitempty"` // A hint to the client to identify how the bearer token is formatted. Bearer tokens are usually generated by an authorization server, so this information is primarily for documentation purposes. + Flows *Flows `json:"flows,omitempty"` // REQUIRED. The type of flow used by the OAuth2 security scheme. + OpenIDConnectURL string `json:"openIdConnectUrl,omitempty"` // REQUIRED. OpenId Connect URL to discover OAuth2 configuration values. This MUST be in the form of a URL. The OAuth2 standard requires the use of TLS. +} + +type Flows struct { + Implicit *Flow `json:"implicit,omitempty"` // Configuration for the OAuth Implicit flow + Password *Flow `json:"password,omitempty"` // Configuration for the OAuth Resource Owner Password flow + ClientCredentials *Flow `json:"clientCredentials,omitempty"` // Configuration for the OAuth Client Credentials flow. Previously called application in OpenAPI 2.0. + AuthorizationCode *Flow `json:"authorizationCode,omitempty"` // Configuration for the OAuth Authorization Code flow. Previously called accessCode in OpenAPI 2.0. +} + +type Flow struct { + AuthorizationURL string `json:"authorizationUrl,omitempty"` // REQUIRED. The authorization URL to be used for this flow. This MUST be in the form of a URL. The OAuth2 standard requires the use of TLS. + TokenURL string `json:"tokenUrl,omitempty"` // REQUIRED. The token URL to be used for this flow. This MUST be in the form of a URL. The OAuth2 standard requires the use of TLS. + RefreshURL string `json:"refreshUrl,omitempty"` // The URL to be used for obtaining refresh tokens. This MUST be in the form of a URL. The OAuth2 standard requires the use of TLS. + Scopes map[string]string `json:"scopes,omitempty"` // REQUIRED. The available scopes for the OAuth2 security scheme. A map between the scope name and a short description for it. The map MAY be empty. +} + type Encoding struct { ContentType string `json:"contentType,omitempty"` // The Content-Type for encoding a specific property. // headers map[string]headerObject : not implemented needed if media is multipart diff --git a/paths.go b/paths.go index c291c8a..6b2f6a1 100644 --- a/paths.go +++ b/paths.go @@ -14,16 +14,18 @@ import ( type Router map[string]*Route // Route is a simplified definition for managing routes in code +// this object represents the operation object in the OpenAPI specification type Route struct { // internal reference path string method string - Tag []string `json:"tags,omitempty"` - Summary string `json:"summary,omitempty"` - Responses map[Code]Response `json:"responses,omitempty"` // [status_code]Response - Params Params `json:"parameters,omitempty"` // key reference for params. key is name of Param - Requests *RequestBody `json:"requestBody,omitempty"` // key reference for requests + Tag []string `json:"tags,omitempty"` + Summary string `json:"summary,omitempty"` + Responses map[Code]Response `json:"responses,omitempty"` // [status_code]Response + Params Params `json:"parameters,omitempty"` // key reference for params. key is name of Param + Security []SecurityRequirement `json:"security,omitempty"` // https://swagger.io/specification/v3/#security-requirement-object - Lists the required security schemes to execute this operation. The name used for each property MUST correspond to a security scheme declared in the Security Schemes under the Components Object. + Requests *RequestBody `json:"requestBody,omitempty"` // key reference for requests /* NOT CURRENTLY SUPPORT VALUES // operationId is an optional unique string used to identify an operation @@ -535,3 +537,36 @@ func (p *Params) UnmarshalJSON(b []byte) error { } return nil } + +// AddSecurity adds a security requirement to this specific route +// For non-OAuth2 schemes, pass no scopes or empty slice +// For OAuth2 schemes, pass the required scopes +func (r *Route) AddSecurity(schemeName string, scopes ...string) *Route { + if r.Security == nil { + r.Security = make([]SecurityRequirement, 0) + } + + requirement := SecurityRequirement{ + schemeName: scopes, + } + + r.Security = append(r.Security, requirement) + return r +} + +// AddMultipleSecurity adds a security requirement with multiple schemes to this specific route (AND logic) +// All schemes in the map must be satisfied for this route +func (r *Route) AddMultipleSecurity(schemes map[string][]string) *Route { + if r.Security == nil { + r.Security = make([]SecurityRequirement, 0) + } + + r.Security = append(r.Security, SecurityRequirement(schemes)) + return r +} + +// ClearSecurity removes all security requirements from this specific route +func (r *Route) ClearSecurity() *Route { + r.Security = nil + return r +} diff --git a/paths_test.go b/paths_test.go index 1f9a069..b9f9d63 100644 --- a/paths_test.go +++ b/paths_test.go @@ -335,3 +335,342 @@ func TestMarshalRoute(t *testing.T) { trial.New(fn, cases).SubTest(t) } + +func TestRouteAddSecurityRequirement(t *testing.T) { + type input struct { + schemeName string + scopes []string + } + + fn := func(i input) ([]SecurityRequirement, error) { + doc := New("Test API", "1.0.0", "Test") + route := doc.GetRoute("/users", "GET") + route.AddSecurity(i.schemeName, i.scopes...) + return route.Security, nil + } + + cases := trial.Cases[input, []SecurityRequirement]{ + "api_key_no_scopes": { + Input: input{ + schemeName: "ApiKeyAuth", + scopes: []string{}, + }, + Expected: []SecurityRequirement{ + { + "ApiKeyAuth": {}, + }, + }, + }, + "bearer_no_scopes": { + Input: input{ + schemeName: "BearerAuth", + scopes: nil, + }, + Expected: []SecurityRequirement{ + { + "BearerAuth": {}, + }, + }, + }, + "oauth2_with_scopes": { + Input: input{ + schemeName: "OAuth2Auth", + scopes: []string{"read", "write"}, + }, + Expected: []SecurityRequirement{ + { + "OAuth2Auth": {"read", "write"}, + }, + }, + }, + "single_scope": { + Input: input{ + schemeName: "OAuth2Auth", + scopes: []string{"admin"}, + }, + Expected: []SecurityRequirement{ + { + "OAuth2Auth": {"admin"}, + }, + }, + }, + } + + trial.New(fn, cases).SubTest(t) +} + +func TestRouteAddMultipleSecurityRequirement(t *testing.T) { + type input struct { + schemes map[string][]string + } + + fn := func(i input) ([]SecurityRequirement, error) { + doc := New("Test API", "1.0.0", "Test") + route := doc.GetRoute("/admin", "POST") + route.AddMultipleSecurity(i.schemes) + return route.Security, nil + } + + cases := trial.Cases[input, []SecurityRequirement]{ + "api_key_and_oauth2": { + Input: input{ + schemes: map[string][]string{ + "ApiKeyAuth": {}, + "OAuth2Auth": {"admin"}, + }, + }, + Expected: []SecurityRequirement{ + { + "ApiKeyAuth": {}, + "OAuth2Auth": {"admin"}, + }, + }, + }, + "multiple_schemes_various_scopes": { + Input: input{ + schemes: map[string][]string{ + "BearerAuth": {}, + "OAuth2Auth": {"read", "write"}, + "ApiKeyAuth": {}, + }, + }, + Expected: []SecurityRequirement{ + { + "BearerAuth": {}, + "OAuth2Auth": {"read", "write"}, + "ApiKeyAuth": {}, + }, + }, + }, + } + + trial.New(fn, cases).SubTest(t) +} + +func TestRouteMultipleSecurityRequirements(t *testing.T) { + type input struct { + requirements []map[string][]string + } + + fn := func(i input) ([]SecurityRequirement, error) { + doc := New("Test API", "1.0.0", "Test") + route := doc.GetRoute("/flexible", "GET") + + // Add multiple different security requirement options (OR logic) + for _, req := range i.requirements { + route.AddMultipleSecurity(req) + } + + return route.Security, nil + } + + cases := trial.Cases[input, []SecurityRequirement]{ + "multiple_alternatives": { + Input: input{ + requirements: []map[string][]string{ + {"ApiKeyAuth": {}}, // Option 1: API Key only + {"OAuth2Auth": {"read"}}, // Option 2: OAuth2 with read scope + {"BearerAuth": {}, "ApiKeyAuth": {}}, // Option 3: Both Bearer and API Key + }, + }, + Expected: []SecurityRequirement{ + {"ApiKeyAuth": {}}, + {"OAuth2Auth": {"read"}}, + {"BearerAuth": {}, "ApiKeyAuth": {}}, + }, + }, + } + + trial.New(fn, cases).SubTest(t) +} + +func TestRouteClearSecurity(t *testing.T) { + fn := func(hasInitialSecurity bool) ([]SecurityRequirement, error) { + doc := New("Test API", "1.0.0", "Test") + route := doc.GetRoute("/test", "DELETE") + + if hasInitialSecurity { + route.AddSecurity("ApiKeyAuth") + route.AddSecurity("OAuth2Auth", "admin") + } + + route.ClearSecurity() + return route.Security, nil + } + + cases := trial.Cases[bool, []SecurityRequirement]{ + "clear_existing_security": { + Input: true, + Expected: nil, + }, + "clear_no_security": { + Input: false, + Expected: nil, + }, + } + + trial.New(fn, cases).SubTest(t) +} + +func TestRouteSecurityWithFullIntegration(t *testing.T) { + type routeConfig struct { + path string + method string + securityScheme string + scopes []string + addGlobalAuth bool + } + + fn := func(config routeConfig) (map[string]any, error) { + doc := New("Test API", "1.0.0", "Test") + + // Add security schemes to the document + if config.addGlobalAuth { + doc.AddBearerAuth("BearerAuth", BearerFormatJWT, "JWT Bearer token") + doc.AddAPIKeyAuth("ApiKeyAuth", "X-API-Key", APIKeyInHeader, "API key") + doc.AddOAuth2Auth("OAuth2Auth", &Flows{ + AuthorizationCode: &Flow{ + AuthorizationURL: "https://example.com/auth", + TokenURL: "https://example.com/token", + Scopes: map[string]string{ + "read": "Read access", + "write": "Write access", + "admin": "Admin access", + }, + }, + }, "OAuth2 authentication") + } + + // Create route with security + route := doc.GetRoute(config.path, config.method) + route.AddSecurity(config.securityScheme, config.scopes...) + route.AddResponse(Response{ + Status: 200, + Desc: "Success", + }) + + // Return relevant data for validation + return map[string]any{ + "hasSecuritySchemes": len(doc.Components.SecuritySchemes) > 0, + "routeSecurity": route.Security, + "routeExists": route != nil, + }, nil + } + + cases := trial.Cases[routeConfig, map[string]any]{ + "bearer_auth_integration": { + Input: routeConfig{ + path: "/protected", + method: "GET", + securityScheme: "BearerAuth", + scopes: []string{}, + addGlobalAuth: true, + }, + Expected: map[string]any{ + "hasSecuritySchemes": true, + "routeSecurity": []SecurityRequirement{ + {"BearerAuth": {}}, + }, + "routeExists": true, + }, + }, + "oauth2_with_scopes": { + Input: routeConfig{ + path: "/admin/users", + method: "POST", + securityScheme: "OAuth2Auth", + scopes: []string{"admin", "write"}, + addGlobalAuth: true, + }, + Expected: map[string]any{ + "hasSecuritySchemes": true, + "routeSecurity": []SecurityRequirement{ + {"OAuth2Auth": {"admin", "write"}}, + }, + "routeExists": true, + }, + }, + } + + trial.New(fn, cases).SubTest(t) +} + +func TestRouteSecurityInheritance(t *testing.T) { + type testData struct { + addGlobalSecurity bool + addRouteSecurity bool + } + + fn := func(data testData) (map[string]any, error) { + doc := New("Test API", "1.0.0", "Test") + + // Add security schemes + doc.AddAPIKeyAuth("ApiKeyAuth", "X-API-Key", APIKeyInHeader, "API key") + doc.AddBearerAuth("BearerAuth", BearerFormatJWT, "Bearer token") + + // Add global security if requested + if data.addGlobalSecurity { + doc.AddSecurityRequirement("ApiKeyAuth") + } + + // Create route + route := doc.GetRoute("/test", "GET") + + // Add route-specific security if requested + if data.addRouteSecurity { + route.AddSecurity("BearerAuth") + } + + return map[string]any{ + "globalSecurity": doc.Security, + "routeSecurity": route.Security, + "bothExist": doc.Security != nil && route.Security != nil, + }, nil + } + + cases := trial.Cases[testData, map[string]any]{ + "global_only": { + Input: testData{ + addGlobalSecurity: true, + addRouteSecurity: false, + }, + Expected: map[string]any{ + "globalSecurity": []SecurityRequirement{ + {"ApiKeyAuth": {}}, + }, + "routeSecurity": []SecurityRequirement(nil), + "bothExist": false, + }, + }, + "route_only": { + Input: testData{ + addGlobalSecurity: false, + addRouteSecurity: true, + }, + Expected: map[string]any{ + "globalSecurity": []SecurityRequirement(nil), + "routeSecurity": []SecurityRequirement{ + {"BearerAuth": {}}, + }, + "bothExist": false, + }, + }, + "both_global_and_route": { + Input: testData{ + addGlobalSecurity: true, + addRouteSecurity: true, + }, + Expected: map[string]any{ + "globalSecurity": []SecurityRequirement{ + {"ApiKeyAuth": {}}, + }, + "routeSecurity": []SecurityRequirement{ + {"BearerAuth": {}}, + }, + "bothExist": true, + }, + }, + } + + trial.New(fn, cases).SubTest(t) +} diff --git a/security.go b/security.go new file mode 100644 index 0000000..e183a26 --- /dev/null +++ b/security.go @@ -0,0 +1,134 @@ +package openapi + +// Security scheme types +const ( + SecurityTypeAPIKey = "apiKey" + SecurityTypeHTTP = "http" + SecurityTypeOAuth2 = "oauth2" + SecurityTypeOpenID = "openIdConnect" +) + +// HTTP authentication schemes +const ( + HTTPSchemeBearer = "bearer" + HTTPSchemeBasic = "basic" +) + +// API key locations +const ( + APIKeyInQuery = "query" + APIKeyInHeader = "header" + APIKeyInCookie = "cookie" +) + +// Common bearer token formats +const ( + BearerFormatJWT = "JWT" +) + +func (o *OpenAPI) AddSecurity(security string) { + if o.Security == nil { + o.Security = make([]SecurityRequirement, 0) + } + + // Create a security requirement map with empty scopes for the given security scheme + requirement := SecurityRequirement{ + security: {}, + } + + o.Security = append(o.Security, requirement) +} + +// AddSecurityScheme adds a security scheme to the OpenAPI specification +func (o *OpenAPI) AddSecurityScheme(name string, scheme SecurityScheme) { + // if the security schemes are not defined, create them + if o.Components.SecuritySchemes == nil { + o.Components.SecuritySchemes = make(map[string]SecurityScheme) + } + o.Components.SecuritySchemes[name] = scheme +} + +// AddAPIKeyAuth adds an API key authentication scheme +// name is your unique identifier for the security scheme +// keyName is the name of the API key +// location is the location of the API key i.e., APIKeyInQuery, APIKeyInHeader, or APIKeyInCookie +// description is the description of the security scheme +func (o *OpenAPI) AddAPIKeyAuth(name, keyName, location, description string) { + scheme := SecurityScheme{ + Type: SecurityTypeAPIKey, + Name: keyName, + In: location, // APIKeyInQuery, APIKeyInHeader, or APIKeyInCookie + Description: description, + } + o.AddSecurityScheme(name, scheme) +} + +// AddBearerAuth adds a bearer token authentication scheme +// name is your unique identifier for the security scheme +// bearerFormat is the format of the bearer token i.e., BearerFormatJWT +// description is the description of the security scheme +func (o *OpenAPI) AddBearerAuth(name, bearerFormat, description string) { + scheme := SecurityScheme{ + Type: SecurityTypeHTTP, + Scheme: HTTPSchemeBearer, + BearerFormat: bearerFormat, + In: APIKeyInHeader, + Description: description, + } + o.AddSecurityScheme(name, scheme) +} + +// AddBasicAuth adds a basic authentication scheme +func (o *OpenAPI) AddBasicAuth(name string, description string) { + scheme := SecurityScheme{ + Type: SecurityTypeHTTP, + Scheme: HTTPSchemeBasic, + Description: description, + } + o.AddSecurityScheme(name, scheme) +} + +// AddOAuth2Auth adds an OAuth2 authentication scheme +func (o *OpenAPI) AddOAuth2Auth(name string, flows *Flows, description string) { + scheme := SecurityScheme{ + Type: SecurityTypeOAuth2, + Flows: flows, + Description: description, + } + o.AddSecurityScheme(name, scheme) +} + +// AddOpenIDConnectAuth adds an OpenID Connect authentication scheme +func (o *OpenAPI) AddOpenIDConnectAuth(name, openIDConnectURL, description string) { + scheme := SecurityScheme{ + Type: SecurityTypeOpenID, + OpenIDConnectURL: openIDConnectURL, + Description: description, + } + o.AddSecurityScheme(name, scheme) +} + +// AddSecurityRequirement adds a security requirement to the OpenAPI specification +// For non-OAuth2 schemes, pass an empty string for scopes +// For OAuth2 schemes, pass each scope as a separate argument +func (o *OpenAPI) AddSecurityRequirement(schemeName string, scopes ...string) { + if o.Security == nil { + o.Security = make([]SecurityRequirement, 0) + } + + requirement := SecurityRequirement{ + schemeName: scopes, + } + + o.Security = append(o.Security, requirement) +} + +// AddMultipleSecurityRequirement adds a security requirement with multiple schemes (AND logic) +// All schemes in the map must be satisfied +func (o *OpenAPI) AddMultipleSecurityRequirement(schemes map[string][]string) { + if o.Security == nil { + o.Security = make([]SecurityRequirement, 0) + } + + o.Security = append(o.Security, SecurityRequirement(schemes)) +} diff --git a/security_test.go b/security_test.go new file mode 100644 index 0000000..e83bdbb --- /dev/null +++ b/security_test.go @@ -0,0 +1,379 @@ +package openapi + +import ( + "testing" + + "github.com/hydronica/trial" +) + +func TestAddAPIKeyAuth(t *testing.T) { + type input struct { + name string + keyName string + location string + description string + } + + fn := func(i input) (SecurityScheme, error) { + doc := New("Test API", "1.0.0", "Test") + doc.AddAPIKeyAuth(i.name, i.keyName, i.location, i.description) + + scheme, exists := doc.Components.SecuritySchemes[i.name] + if !exists { + return SecurityScheme{}, nil + } + return scheme, nil + } + + cases := trial.Cases[input, SecurityScheme]{ + "header_auth": { + Input: input{ + name: "ApiKeyAuth", + keyName: "X-API-Key", + location: APIKeyInHeader, + description: "API key authentication", + }, + Expected: SecurityScheme{ + Type: SecurityTypeAPIKey, + Name: "X-API-Key", + In: APIKeyInHeader, + Description: "API key authentication", + }, + }, + "query_auth": { + Input: input{ + name: "QueryAuth", + keyName: "api_key", + location: APIKeyInQuery, + description: "Query parameter API key", + }, + Expected: SecurityScheme{ + Type: SecurityTypeAPIKey, + Name: "api_key", + In: APIKeyInQuery, + Description: "Query parameter API key", + }, + }, + "cookie_auth": { + Input: input{ + name: "CookieAuth", + keyName: "auth_token", + location: APIKeyInCookie, + description: "Cookie-based authentication", + }, + Expected: SecurityScheme{ + Type: SecurityTypeAPIKey, + Name: "auth_token", + In: APIKeyInCookie, + Description: "Cookie-based authentication", + }, + }, + } + + trial.New(fn, cases).SubTest(t) +} + +func TestAddBearerAuth(t *testing.T) { + type input struct { + name string + bearerFormat string + description string + } + + fn := func(i input) (SecurityScheme, error) { + doc := New("Test API", "1.0.0", "Test") + doc.AddBearerAuth(i.name, i.bearerFormat, i.description) + + scheme, exists := doc.Components.SecuritySchemes[i.name] + if !exists { + return SecurityScheme{}, nil + } + return scheme, nil + } + + cases := trial.Cases[input, SecurityScheme]{ + "jwt_bearer": { + Input: input{ + name: "BearerAuth", + bearerFormat: BearerFormatJWT, + description: "JWT Bearer token", + }, + Expected: SecurityScheme{ + Type: SecurityTypeHTTP, + Scheme: HTTPSchemeBearer, + BearerFormat: BearerFormatJWT, + In: APIKeyInHeader, + Description: "JWT Bearer token", + }, + }, + "custom_bearer": { + Input: input{ + name: "CustomBearer", + bearerFormat: "Custom", + description: "Custom bearer token", + }, + Expected: SecurityScheme{ + Type: SecurityTypeHTTP, + Scheme: HTTPSchemeBearer, + BearerFormat: "Custom", + In: APIKeyInHeader, + Description: "Custom bearer token", + }, + }, + } + + trial.New(fn, cases).SubTest(t) +} + +func TestAddBasicAuth(t *testing.T) { + type input struct { + name string + description string + } + + fn := func(i input) (SecurityScheme, error) { + doc := New("Test API", "1.0.0", "Test") + doc.AddBasicAuth(i.name, i.description) + + scheme, exists := doc.Components.SecuritySchemes[i.name] + if !exists { + return SecurityScheme{}, nil + } + return scheme, nil + } + + cases := trial.Cases[input, SecurityScheme]{ + "basic_auth": { + Input: input{ + name: "BasicAuth", + description: "HTTP Basic authentication", + }, + Expected: SecurityScheme{ + Type: SecurityTypeHTTP, + Scheme: HTTPSchemeBasic, + Description: "HTTP Basic authentication", + }, + }, + } + + trial.New(fn, cases).SubTest(t) +} + +func TestAddOAuth2Auth(t *testing.T) { + type input struct { + name string + flows *Flows + description string + } + + fn := func(i input) (SecurityScheme, error) { + doc := New("Test API", "1.0.0", "Test") + doc.AddOAuth2Auth(i.name, i.flows, i.description) + + scheme, exists := doc.Components.SecuritySchemes[i.name] + if !exists { + return SecurityScheme{}, nil + } + return scheme, nil + } + + testFlows := &Flows{ + AuthorizationCode: &Flow{ + AuthorizationURL: "https://example.com/auth", + TokenURL: "https://example.com/token", + Scopes: map[string]string{ + "read": "Read access", + "write": "Write access", + }, + }, + } + + cases := trial.Cases[input, SecurityScheme]{ + "oauth2_auth": { + Input: input{ + name: "OAuth2Auth", + flows: testFlows, + description: "OAuth2 authentication", + }, + Expected: SecurityScheme{ + Type: SecurityTypeOAuth2, + Flows: testFlows, + Description: "OAuth2 authentication", + }, + }, + } + + trial.New(fn, cases).SubTest(t) +} + +func TestAddOpenIDConnectAuth(t *testing.T) { + type input struct { + name string + openIDConnectURL string + description string + } + + fn := func(i input) (SecurityScheme, error) { + doc := New("Test API", "1.0.0", "Test") + doc.AddOpenIDConnectAuth(i.name, i.openIDConnectURL, i.description) + + scheme, exists := doc.Components.SecuritySchemes[i.name] + if !exists { + return SecurityScheme{}, nil + } + return scheme, nil + } + + cases := trial.Cases[input, SecurityScheme]{ + "openid_connect": { + Input: input{ + name: "OpenIDConnect", + openIDConnectURL: "https://example.com/.well-known/openid_configuration", + description: "OpenID Connect authentication", + }, + Expected: SecurityScheme{ + Type: SecurityTypeOpenID, + OpenIDConnectURL: "https://example.com/.well-known/openid_configuration", + Description: "OpenID Connect authentication", + }, + }, + } + + trial.New(fn, cases).SubTest(t) +} + +func TestAddSecurityRequirement(t *testing.T) { + type input struct { + schemeName string + scopes []string + } + + fn := func(i input) ([]SecurityRequirement, error) { + doc := New("Test API", "1.0.0", "Test") + doc.AddSecurityRequirement(i.schemeName, i.scopes...) + return doc.Security, nil + } + + cases := trial.Cases[input, []SecurityRequirement]{ + "api_key_requirement": { + Input: input{ + schemeName: "ApiKeyAuth", + scopes: []string{}, + }, + Expected: []SecurityRequirement{ + { + "ApiKeyAuth": {}, + }, + }, + }, + "oauth2_with_scopes": { + Input: input{ + schemeName: "OAuth2Auth", + scopes: []string{"read", "write"}, + }, + Expected: []SecurityRequirement{ + { + "OAuth2Auth": {"read", "write"}, + }, + }, + }, + } + + trial.New(fn, cases).SubTest(t) +} + +func TestAddMultipleSecurityRequirement(t *testing.T) { + type input struct { + schemes map[string][]string + } + + fn := func(i input) ([]SecurityRequirement, error) { + doc := New("Test API", "1.0.0", "Test") + doc.AddMultipleSecurityRequirement(i.schemes) + return doc.Security, nil + } + + cases := trial.Cases[input, []SecurityRequirement]{ + "multiple_schemes": { + Input: input{ + schemes: map[string][]string{ + "ApiKeyAuth": {}, + "OAuth2Auth": {"read"}, + }, + }, + Expected: []SecurityRequirement{ + { + "ApiKeyAuth": {}, + "OAuth2Auth": {"read"}, + }, + }, + }, + "single_scheme_in_map": { + Input: input{ + schemes: map[string][]string{ + "BearerAuth": {}, + }, + }, + Expected: []SecurityRequirement{ + { + "BearerAuth": {}, + }, + }, + }, + } + + trial.New(fn, cases).SubTest(t) +} + +func TestSecurityConstants(t *testing.T) { + // Test that constants have the correct values + fn := func(constant string) (string, error) { + return constant, nil + } + + cases := trial.Cases[string, string]{ + "SecurityTypeAPIKey": {Input: SecurityTypeAPIKey, Expected: "apiKey"}, + "SecurityTypeHTTP": {Input: SecurityTypeHTTP, Expected: "http"}, + "SecurityTypeOAuth2": {Input: SecurityTypeOAuth2, Expected: "oauth2"}, + "SecurityTypeOpenID": {Input: SecurityTypeOpenID, Expected: "openIdConnect"}, + "HTTPSchemeBearer": {Input: HTTPSchemeBearer, Expected: "bearer"}, + "HTTPSchemeBasic": {Input: HTTPSchemeBasic, Expected: "basic"}, + "APIKeyInQuery": {Input: APIKeyInQuery, Expected: "query"}, + "APIKeyInHeader": {Input: APIKeyInHeader, Expected: "header"}, + "APIKeyInCookie": {Input: APIKeyInCookie, Expected: "cookie"}, + "BearerFormatJWT": {Input: BearerFormatJWT, Expected: "JWT"}, + } + + trial.New(fn, cases).SubTest(t) +} + +func TestSecuritySchemeInitialization(t *testing.T) { + fn := func(authType string) (bool, error) { + doc := New("Test API", "1.0.0", "Test") + + // Initially, Components.SecuritySchemes should be nil + if doc.Components.SecuritySchemes != nil { + return false, nil + } + + // After adding any auth method, it should be initialized + switch authType { + case "apikey": + doc.AddAPIKeyAuth("test", "key", APIKeyInHeader, "desc") + case "bearer": + doc.AddBearerAuth("test", BearerFormatJWT, "desc") + case "basic": + doc.AddBasicAuth("test", "desc") + } + + return doc.Components.SecuritySchemes != nil, nil + } + + cases := trial.Cases[string, bool]{ + "apikey_initializes": {Input: "apikey", Expected: true}, + "bearer_initializes": {Input: "bearer", Expected: true}, + "basic_initializes": {Input: "basic", Expected: true}, + } + + trial.New(fn, cases).SubTest(t) +}