diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 12dffed515..7fdbbc8a2c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,8 +19,8 @@ jobs: fail-fast: true matrix: version: - - "1.26" - "1.25" + - "1.26" steps: - name: Check out source code uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 diff --git a/cmd/oapi-codegen/oapi-codegen.go b/cmd/oapi-codegen/oapi-codegen.go index 989b4d13fd..23ef1967eb 100644 --- a/cmd/oapi-codegen/oapi-codegen.go +++ b/cmd/oapi-codegen/oapi-codegen.go @@ -323,10 +323,6 @@ func main() { errExit("error loading swagger spec in %s\n: %s\n", flag.Arg(0), err) } - if strings.HasPrefix(swagger.OpenAPI, "3.1.") { - fmt.Fprintln(os.Stderr, "WARNING: You are using an OpenAPI 3.1.x specification, which is not yet supported by oapi-codegen (https://github.com/oapi-codegen/oapi-codegen/issues/373) and so some functionality may not be available. Until oapi-codegen supports OpenAPI 3.1, it is recommended to downgrade your spec to 3.0.x") - } - if len(noVCSVersionOverride) > 0 { opts.NoVCSVersionOverride = &noVCSVersionOverride } diff --git a/configuration-schema.json b/configuration-schema.json index e7960731b1..1608987f5e 100644 --- a/configuration-schema.json +++ b/configuration-schema.json @@ -147,6 +147,10 @@ "type": "boolean", "description": "Whether to skip generation of the Valid() method on enum types" }, + "skip-enum-via-oneof": { + "type": "boolean", + "description": "Disables detection of the OpenAPI 3.1 enum-via-oneOf idiom: a schema with `type: string|integer` and `oneOf:` members that each carry `const` + `title` will normally be emitted as a Go enum with named constants. Set this to true to fall through to the standard union generator instead." + }, "include-tags": { "type": "array", "description": "Only include operations that have one of these tags. Ignored when empty.", diff --git a/examples/callback/client/main.go b/examples/callback/client/main.go new file mode 100644 index 0000000000..ec15c59c5a --- /dev/null +++ b/examples/callback/client/main.go @@ -0,0 +1,144 @@ +package main + +import ( + "bytes" + "encoding/json" + "flag" + "fmt" + "log" + "net" + "net/http" + "sync" + "sync/atomic" + + treefarm "github.com/oapi-codegen/oapi-codegen/v2/examples/callback" +) + +// trees and cities for our planting requests +var treeKinds = []string{ + "oak", "maple", "pine", "birch", "willow", + "cedar", "elm", "ash", "cherry", "walnut", +} + +var cities = []string{ + "Providence", "Austin", "Denver", "Seattle", "Chicago", + "Boston", "Miami", "Nashville", "Savannah", "Mountain View", +} + +// CallbackReceiver implements treefarm.CallbackReceiverInterface. +type CallbackReceiver struct { + received atomic.Int32 + total int + done chan struct{} + once sync.Once + + mu sync.Mutex + ordinals map[string]int // UUID string -> 1-based planting order +} + +var _ treefarm.CallbackReceiverInterface = (*CallbackReceiver)(nil) + +func NewCallbackReceiver(total int) *CallbackReceiver { + return &CallbackReceiver{ + total: total, + done: make(chan struct{}), + ordinals: make(map[string]int), + } +} + +func (cr *CallbackReceiver) Register(id string, ordinal int) { + cr.mu.Lock() + cr.ordinals[id] = ordinal + cr.mu.Unlock() +} + +func (cr *CallbackReceiver) HandleTreePlantedCallback(w http.ResponseWriter, r *http.Request) { + var result treefarm.TreePlantingResult + if err := json.NewDecoder(r.Body).Decode(&result); err != nil { + log.Printf("Error decoding callback: %v", err) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + cr.mu.Lock() + ordinal := cr.ordinals[result.ID.String()] + cr.mu.Unlock() + + n := cr.received.Add(1) + log.Printf("Callback %d/%d received: tree #%d success=%v", n, cr.total, ordinal, result.Success) + + w.WriteHeader(http.StatusOK) + + if int(n) >= cr.total { + cr.once.Do(func() { close(cr.done) }) + } +} + +func main() { + serverAddr := flag.String("server", "http://localhost:8080", "Tree farm server address") + flag.Parse() + + const numTrees = 10 + + // Start callback receiver on an ephemeral port. + receiver := NewCallbackReceiver(numTrees) + + mux := http.NewServeMux() + mux.Handle("/tree_callback", treefarm.TreePlantedCallbackHandler(receiver, nil)) + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + log.Fatalf("Failed to listen: %v", err) + } + callbackPort := listener.Addr().(*net.TCPAddr).Port + callbackURL := fmt.Sprintf("http://localhost:%d/tree_callback", callbackPort) + log.Printf("Callback receiver listening on port %d", callbackPort) + + go func() { + if err := http.Serve(listener, mux); err != nil { + log.Printf("Callback server stopped: %v", err) + } + }() + + // Send 10 tree planting requests. + client := &http.Client{} + for i := range numTrees { + req := treefarm.TreePlantingRequest{ + Kind: treeKinds[i], + Location: cities[i], + CallbackURL: callbackURL, + } + + body, err := json.Marshal(req) + if err != nil { + log.Fatalf("Failed to marshal request: %v", err) + } + + resp, err := client.Post( + *serverAddr+"/api/plant_tree", + "application/json", + bytes.NewReader(body), + ) + if err != nil { + log.Fatalf("Failed to plant tree %d: %v", i+1, err) + } + + var accepted treefarm.TreeWithID + if err := json.NewDecoder(resp.Body).Decode(&accepted); err != nil { + _ = resp.Body.Close() + log.Fatalf("Failed to decode response: %v", err) + } + _ = resp.Body.Close() + + receiver.Register(accepted.ID.String(), i+1) + log.Printf("Planted tree %d/%d: id=%s kind=%q location=%q", + i+1, numTrees, accepted.ID, accepted.Kind, accepted.Location) + } + + log.Printf("All %d trees planted, waiting for callbacks...", numTrees) + + // Wait for all callbacks. + <-receiver.done + + log.Printf("All %d callbacks received, done!", numTrees) +} diff --git a/examples/callback/config.yaml b/examples/callback/config.yaml new file mode 100644 index 0000000000..204f845cf9 --- /dev/null +++ b/examples/callback/config.yaml @@ -0,0 +1,13 @@ +# yaml-language-server: $schema=../../configuration-schema.json +package: treefarm +generate: + models: true + client: true + std-http-server: true +output-options: + skip-prune: true + # Use ToCamelCaseWithInitialisms so spec fields like `id` and + # `callbackUrl` generate as `ID` and `CallbackURL` (matching the + # example's Go code). + name-normalizer: ToCamelCaseWithInitialisms +output: treefarm.gen.go diff --git a/examples/callback/doc.go b/examples/callback/doc.go new file mode 100644 index 0000000000..8f859b2a08 --- /dev/null +++ b/examples/callback/doc.go @@ -0,0 +1,14 @@ +//go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen --config=config.yaml tree-farm.yaml + +// Package treefarm provides an example of how to handle OpenAPI callbacks. +// We create a server which plants trees. The client asks the server to plant +// a tree and requests a callback when the planting is done. +// +// The server program will wait 1-5 seconds before notifying the client that a +// tree has been planted. +// +// You can run the example by running these two commands in parallel: +// +// go run ./server --port 8080 +// go run ./client --server http://localhost:8080 +package treefarm diff --git a/examples/callback/server/main.go b/examples/callback/server/main.go new file mode 100644 index 0000000000..cb8a24a322 --- /dev/null +++ b/examples/callback/server/main.go @@ -0,0 +1,116 @@ +package main + +import ( + "context" + "encoding/json" + "flag" + "log" + "math/rand/v2" + "net" + "net/http" + "time" + + "github.com/google/uuid" + + treefarm "github.com/oapi-codegen/oapi-codegen/v2/examples/callback" +) + +// TreeFarm implements treefarm.ServerInterface. +type TreeFarm struct { + initiator *treefarm.CallbackInitiator +} + +var _ treefarm.ServerInterface = (*TreeFarm)(nil) + +func NewTreeFarm() *TreeFarm { + initiator, err := treefarm.NewCallbackInitiator() + if err != nil { + log.Fatalf("Failed to create callback initiator: %v", err) + } + return &TreeFarm{initiator: initiator} +} + +func sendError(w http.ResponseWriter, code int, message string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(treefarm.Error{ + Code: int32(code), + Message: message, + }) +} + +func (tf *TreeFarm) PlantTree(w http.ResponseWriter, r *http.Request) { + log.Printf("Received PlantTree request from %s", r.RemoteAddr) + + var req treefarm.TreePlantingRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + sendError(w, http.StatusBadRequest, "Invalid request body: "+err.Error()) + return + } + + if req.CallbackURL == "" { + sendError(w, http.StatusBadRequest, "callbackUrl is required") + return + } + + id := uuid.New() + + log.Printf("Accepted tree planting: id=%s kind=%q location=%q callbackUrl=%q", + id, req.Kind, req.Location, req.CallbackURL) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + _ = json.NewEncoder(w).Encode(treefarm.TreeWithID{ + Location: req.Location, + Kind: req.Kind, + ID: id, + }) + + go tf.plantAndNotify(id, req) +} + +func (tf *TreeFarm) plantAndNotify(id uuid.UUID, req treefarm.TreePlantingRequest) { + delay := time.Duration(1+rand.IntN(5)) * time.Second + log.Printf("Planting tree %s (kind=%q, location=%q) — will complete in %s", + id, req.Kind, req.Location, delay) + + time.Sleep(delay) + + result := treefarm.TreePlantedJSONRequestBody{ + ID: id, + Kind: req.Kind, + Location: req.Location, + Success: true, + } + + log.Printf("Tree %s planted, invoking callback at %s", id, req.CallbackURL) + + resp, err := tf.initiator.TreePlanted(context.Background(), req.CallbackURL, result) + if err != nil { + log.Printf("Callback to %s failed: %v", req.CallbackURL, err) + return + } + defer func() { _ = resp.Body.Close() }() + + log.Printf("Callback to %s returned status %d", req.CallbackURL, resp.StatusCode) +} + +func main() { + port := flag.String("port", "8080", "Port for HTTP server") + flag.Parse() + + farm := NewTreeFarm() + + mux := http.NewServeMux() + treefarm.HandlerFromMux(farm, mux) + + // Wrap with request logging. + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + log.Printf("%s %s from %s", r.Method, r.URL.Path, r.RemoteAddr) + mux.ServeHTTP(w, r) + }) + + addr := net.JoinHostPort("0.0.0.0", *port) + log.Printf("Tree Farm server listening on %s", addr) + log.Fatal(http.ListenAndServe(addr, handler)) +} diff --git a/examples/callback/tree-farm.yaml b/examples/callback/tree-farm.yaml new file mode 100644 index 0000000000..4cad525c08 --- /dev/null +++ b/examples/callback/tree-farm.yaml @@ -0,0 +1,138 @@ +openapi: "3.1.0" +info: + version: 1.0.0 + title: Tree Farm + description: | + A tree planting service that demonstrates OpenAPI callbacks. + The client submits a tree planting request along with a callback URL. + When the tree has been planted, the server sends a POST to the callback URL + to notify the client of the result. + license: + name: Apache 2.0 + url: https://www.apache.org/licenses/LICENSE-2.0.html +paths: + /api/plant_tree: + post: + summary: Request a tree planting + description: | + Submits a request to plant a tree at the specified location. + This is a long-running operation — the server accepts the request + and later notifies the caller via the provided callback URL. + operationId: PlantTree + requestBody: + $ref: '#/components/requestBodies/TreePlanting' + responses: + '202': + description: Tree planting request accepted + content: + application/json: + schema: + $ref: '#/components/schemas/TreeWithId' + default: + description: unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + callbacks: + treePlanted: + '{$request.body#/callbackUrl}': + post: + summary: Tree planting result notification + description: | + Sent by the server to the callback URL when the tree planting + operation completes (successfully or not). + operationId: TreePlanted + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TreePlantingResult' + responses: + '200': + description: Callback received successfully + default: + description: unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' +components: + schemas: + Tree: + type: object + description: A tree to be planted + required: + - location + - kind + properties: + location: + type: string + description: Where to plant the tree (e.g. "north meadow") + kind: + type: string + description: What kind of tree to plant (e.g. "oak") + + TreePlantingRequest: + description: | + A tree planting request, combining the tree details with a callback URL + for completion notification. + allOf: + - $ref: '#/components/schemas/Tree' + - type: object + required: + - callbackUrl + properties: + callbackUrl: + type: string + format: uri + description: URL to receive the planting result callback + + TreeWithId: + description: A tree with a server-assigned identifier + allOf: + - $ref: '#/components/schemas/Tree' + - type: object + required: + - id + properties: + id: + type: string + format: uuid + description: Unique identifier for this planting request + + TreePlantingResult: + description: The result of a tree planting operation, sent via callback + allOf: + - $ref: '#/components/schemas/TreeWithId' + - type: object + required: + - success + properties: + success: + type: boolean + description: Whether the tree was successfully planted + + Error: + type: object + required: + - code + - message + properties: + code: + type: integer + format: int32 + description: Error code + message: + type: string + description: Error message + + requestBodies: + TreePlanting: + description: Tree planting request + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TreePlantingRequest' diff --git a/examples/callback/treefarm.gen.go b/examples/callback/treefarm.gen.go new file mode 100644 index 0000000000..0cef33d647 --- /dev/null +++ b/examples/callback/treefarm.gen.go @@ -0,0 +1,766 @@ +//go:build go1.22 + +// Package treefarm provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.0.0-00010101000000-000000000000 DO NOT EDIT. +package treefarm + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + openapi_types "github.com/oapi-codegen/runtime/types" +) + +// Error defines model for Error. +type Error struct { + // Code Error code + Code int32 `json:"code"` + + // Message Error message + Message string `json:"message"` +} + +// Tree A tree to be planted +type Tree struct { + // Kind What kind of tree to plant (e.g. "oak") + Kind string `json:"kind"` + + // Location Where to plant the tree (e.g. "north meadow") + Location string `json:"location"` +} + +// TreePlantingRequest A tree planting request, combining the tree details with a callback URL +// for completion notification. +type TreePlantingRequest struct { + // CallbackURL URL to receive the planting result callback + CallbackURL string `json:"callbackUrl"` + + // Kind What kind of tree to plant (e.g. "oak") + Kind string `json:"kind"` + + // Location Where to plant the tree (e.g. "north meadow") + Location string `json:"location"` +} + +// TreePlantingResult The result of a tree planting operation, sent via callback +type TreePlantingResult struct { + // ID Unique identifier for this planting request + ID openapi_types.UUID `json:"id"` + + // Kind What kind of tree to plant (e.g. "oak") + Kind string `json:"kind"` + + // Location Where to plant the tree (e.g. "north meadow") + Location string `json:"location"` + + // Success Whether the tree was successfully planted + Success bool `json:"success"` +} + +// TreeWithID A tree with a server-assigned identifier +type TreeWithID struct { + // ID Unique identifier for this planting request + ID openapi_types.UUID `json:"id"` + + // Kind What kind of tree to plant (e.g. "oak") + Kind string `json:"kind"` + + // Location Where to plant the tree (e.g. "north meadow") + Location string `json:"location"` +} + +// TreePlanting A tree planting request, combining the tree details with a callback URL +// for completion notification. +type TreePlanting = TreePlantingRequest + +// PlantTreeJSONRequestBody defines body for PlantTree for application/json ContentType. +type PlantTreeJSONRequestBody = TreePlantingRequest + +// TreePlantedJSONRequestBody defines body for TreePlanted for application/json ContentType. +type TreePlantedJSONRequestBody = TreePlantingResult + +// RequestEditorFn is the function signature for the RequestEditor callback function +type RequestEditorFn func(ctx context.Context, req *http.Request) error + +// Doer performs HTTP requests. +// +// The standard http.Client implements this interface. +type HttpRequestDoer interface { + Do(req *http.Request) (*http.Response, error) +} + +// Client which conforms to the OpenAPI3 specification for this service. +type Client struct { + // The endpoint of the server conforming to this interface, with scheme, + // https://api.deepmap.com for example. This can contain a path relative + // to the server, such as https://api.deepmap.com/dev-test, and all the + // paths in the swagger spec will be appended to the server. + Server string + + // Doer for performing requests, typically a *http.Client with any + // customized settings, such as certificate chains. + Client HttpRequestDoer + + // A list of callbacks for modifying requests which are generated before sending over + // the network. + RequestEditors []RequestEditorFn +} + +// ClientOption allows setting custom parameters during construction +type ClientOption func(*Client) error + +// Creates a new Client, with reasonable defaults +func NewClient(server string, opts ...ClientOption) (*Client, error) { + // create a client with sane default values + client := Client{ + Server: server, + } + // mutate client and add all optional params + for _, o := range opts { + if err := o(&client); err != nil { + return nil, err + } + } + // ensure the server URL always has a trailing slash + if !strings.HasSuffix(client.Server, "/") { + client.Server += "/" + } + // create httpClient, if not already present + if client.Client == nil { + client.Client = &http.Client{} + } + return &client, nil +} + +// WithHTTPClient allows overriding the default Doer, which is +// automatically created using http.Client. This is useful for tests. +func WithHTTPClient(doer HttpRequestDoer) ClientOption { + return func(c *Client) error { + c.Client = doer + return nil + } +} + +// WithRequestEditorFn allows setting up a callback function, which will be +// called right before sending the request. This can be used to mutate the request. +func WithRequestEditorFn(fn RequestEditorFn) ClientOption { + return func(c *Client) error { + c.RequestEditors = append(c.RequestEditors, fn) + return nil + } +} + +// The interface specification for the client above. +type ClientInterface interface { + + // PlantTreeWithBody Request a tree planting + // + // Submits a request to plant a tree at the specified location. + // This is a long-running operation — the server accepts the request + // and later notifies the caller via the provided callback URL. + // + // Takes any type of body and a specified content type. + // + // Corresponds with POST /api/plant_tree (the `PlantTree` operationId). + PlantTreeWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + // PlantTree Request a tree planting + // + // Submits a request to plant a tree at the specified location. + // This is a long-running operation — the server accepts the request + // and later notifies the caller via the provided callback URL. + // + // Takes a body of the `application/json` content type. + // + // Corresponds with POST /api/plant_tree (the `PlantTree` operationId). + PlantTree(ctx context.Context, body PlantTreeJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) +} + +// PlantTreeWithBody Request a tree planting +// +// Submits a request to plant a tree at the specified location. +// This is a long-running operation — the server accepts the request +// and later notifies the caller via the provided callback URL. +// +// Takes any type of body and a specified content type. +// +// Corresponds with POST /api/plant_tree (the `PlantTree` operationId). +func (c *Client) PlantTreeWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPlantTreeRequestWithBody(c.Server, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +// PlantTree Request a tree planting +// +// Submits a request to plant a tree at the specified location. +// This is a long-running operation — the server accepts the request +// and later notifies the caller via the provided callback URL. +// +// Takes a body of the `application/json` content type. +// +// Corresponds with POST /api/plant_tree (the `PlantTree` operationId). +func (c *Client) PlantTree(ctx context.Context, body PlantTreeJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPlantTreeRequest(c.Server, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +// NewPlantTreeRequest calls the generic PlantTree builder with application/json body +func NewPlantTreeRequest(server string, body PlantTreeJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewPlantTreeRequestWithBody(server, "application/json", bodyReader) +} + +// NewPlantTreeRequestWithBody constructs an http.Request for the PlantTree method, with any body, and a specified content type +func NewPlantTreeRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/plant_tree") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +func (c *Client) applyEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error { + for _, r := range c.RequestEditors { + if err := r(ctx, req); err != nil { + return err + } + } + for _, r := range additionalEditors { + if err := r(ctx, req); err != nil { + return err + } + } + return nil +} + +// ClientWithResponses builds on ClientInterface to offer response payloads +type ClientWithResponses struct { + ClientInterface +} + +// NewClientWithResponses creates a new ClientWithResponses, which wraps +// Client with return type handling +func NewClientWithResponses(server string, opts ...ClientOption) (*ClientWithResponses, error) { + client, err := NewClient(server, opts...) + if err != nil { + return nil, err + } + return &ClientWithResponses{client}, nil +} + +// WithBaseURL overrides the baseURL. +func WithBaseURL(baseURL string) ClientOption { + return func(c *Client) error { + newBaseURL, err := url.Parse(baseURL) + if err != nil { + return err + } + c.Server = newBaseURL.String() + return nil + } +} + +// ClientWithResponsesInterface is the interface specification for the client with responses above. +type ClientWithResponsesInterface interface { + + // PlantTreeWithBodyWithResponse Request a tree planting + // + // Submits a request to plant a tree at the specified location. + // This is a long-running operation — the server accepts the request + // and later notifies the caller via the provided callback URL. + // + // Takes any type of body and a specified content type, and returns a wrapper object for the known response body format(s). + // + // Corresponds with POST /api/plant_tree (the `PlantTree` operationId). + PlantTreeWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PlantTreeResponse, error) + + // PlantTreeWithResponse Request a tree planting + // + // Submits a request to plant a tree at the specified location. + // This is a long-running operation — the server accepts the request + // and later notifies the caller via the provided callback URL. + // + // Takes a body of the `application/json` content type, and returns a wrapper object for the known response body format(s). + // + // Corresponds with POST /api/plant_tree (the `PlantTree` operationId). + PlantTreeWithResponse(ctx context.Context, body PlantTreeJSONRequestBody, reqEditors ...RequestEditorFn) (*PlantTreeResponse, error) +} + +type PlantTreeResponse struct { + Body []byte + HTTPResponse *http.Response + // JSON202 the response for an HTTP 202 `application/json` response + JSON202 *TreeWithID + // JSONDefault the response for an HTTP default `application/json` response + JSONDefault *Error +} + +// GetJSON202 returns the response for an HTTP 202 `application/json` response +func (r PlantTreeResponse) GetJSON202() *TreeWithID { + return r.JSON202 +} + +// GetJSONDefault returns the response for an HTTP default `application/json` response +func (r PlantTreeResponse) GetJSONDefault() *Error { + return r.JSONDefault +} + +// GetBody returns the raw response body bytes +func (r PlantTreeResponse) GetBody() []byte { + return r.Body +} + +// Status returns HTTPResponse.Status +func (r PlantTreeResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r PlantTreeResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r PlantTreeResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +// PlantTreeWithBodyWithResponse Request a tree planting +// +// Submits a request to plant a tree at the specified location. +// This is a long-running operation — the server accepts the request +// and later notifies the caller via the provided callback URL. +// +// Takes any type of body and a specified content type, and returns a wrapper object for the known response body format(s). +// +// Corresponds with POST /api/plant_tree (the `PlantTree` operationId). +func (c *ClientWithResponses) PlantTreeWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PlantTreeResponse, error) { + rsp, err := c.PlantTreeWithBody(ctx, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParsePlantTreeResponse(rsp) +} + +// PlantTreeWithResponse Request a tree planting +// +// Submits a request to plant a tree at the specified location. +// This is a long-running operation — the server accepts the request +// and later notifies the caller via the provided callback URL. +// +// Takes a body of the `application/json` content type, and returns a wrapper object for the known response body format(s). +// +// Corresponds with POST /api/plant_tree (the `PlantTree` operationId). +func (c *ClientWithResponses) PlantTreeWithResponse(ctx context.Context, body PlantTreeJSONRequestBody, reqEditors ...RequestEditorFn) (*PlantTreeResponse, error) { + rsp, err := c.PlantTree(ctx, body, reqEditors...) + if err != nil { + return nil, err + } + return ParsePlantTreeResponse(rsp) +} + +// ParsePlantTreeResponse parses an HTTP response from a PlantTreeWithResponse call +func ParsePlantTreeResponse(rsp *http.Response) (*PlantTreeResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &PlantTreeResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 202: + var dest TreeWithID + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON202 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSONDefault = &dest + + } + + return response, nil +} + +// CallbackInitiator sends OpenAPI callback requests to target URLs. +// Modeled on the generated Client, but with no stored Server -- the full +// target URL is provided per-call by the caller (typically discovered +// from a callback expression on the parent operation's request body, +// e.g. `{$request.body#/callbackUrl}`). +type CallbackInitiator struct { + // Doer for performing requests, typically a *http.Client with any + // customized settings, such as certificate chains. + Client HttpRequestDoer + + // A list of callbacks for modifying requests which are generated before sending over + // the network. + RequestEditors []RequestEditorFn +} + +// CallbackInitiatorOption allows setting custom parameters during construction. +type CallbackInitiatorOption func(*CallbackInitiator) error + +// NewCallbackInitiator creates a new CallbackInitiator with reasonable defaults. +func NewCallbackInitiator(opts ...CallbackInitiatorOption) (*CallbackInitiator, error) { + initiator := CallbackInitiator{} + for _, o := range opts { + if err := o(&initiator); err != nil { + return nil, err + } + } + if initiator.Client == nil { + initiator.Client = &http.Client{} + } + return &initiator, nil +} + +// WithCallbackHTTPClient allows overriding the default Doer, which is +// automatically created using http.Client. This is useful for tests. +func WithCallbackHTTPClient(doer HttpRequestDoer) CallbackInitiatorOption { + return func(p *CallbackInitiator) error { + p.Client = doer + return nil + } +} + +// WithCallbackRequestEditorFn allows setting up a callback function, which +// will be called right before sending the callback request. This can be +// used to mutate the request, e.g. to add signature headers. +func WithCallbackRequestEditorFn(fn RequestEditorFn) CallbackInitiatorOption { + return func(p *CallbackInitiator) error { + p.RequestEditors = append(p.RequestEditors, fn) + return nil + } +} + +func (p *CallbackInitiator) applyCallbackEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error { + for _, r := range p.RequestEditors { + if err := r(ctx, req); err != nil { + return err + } + } + for _, r := range additionalEditors { + if err := r(ctx, req); err != nil { + return err + } + } + return nil +} + +// CallbackInitiatorInterface is the interface specification for the callback initiator. +type CallbackInitiatorInterface interface { + // TreePlantedWithBody fires the treePlanted callback with any body + TreePlantedWithBody(ctx context.Context, targetURL string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + TreePlanted(ctx context.Context, targetURL string, body TreePlantedJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) +} + +func (p *CallbackInitiator) TreePlantedWithBody(ctx context.Context, targetURL string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewTreePlantedCallbackRequestWithBody(targetURL, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := p.applyCallbackEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return p.Client.Do(req) +} + +func (p *CallbackInitiator) TreePlanted(ctx context.Context, targetURL string, body TreePlantedJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewTreePlantedCallbackRequest(targetURL, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := p.applyCallbackEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return p.Client.Do(req) +} + +// NewTreePlantedCallbackRequest builds a application/json POST request for the treePlanted callback +func NewTreePlantedCallbackRequest(targetURL string, body TreePlantedJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewTreePlantedCallbackRequestWithBody(targetURL, "application/json", bodyReader) +} + +// NewTreePlantedCallbackRequestWithBody builds a POST request for the treePlanted callback with any body +func NewTreePlantedCallbackRequestWithBody(targetURL string, contentType string, body io.Reader) (*http.Request, error) { + var err error + _ = err + + reqURL, err := url.Parse(targetURL) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, reqURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// ServerInterface represents all server handlers. +type ServerInterface interface { + // PlantTree Request a tree planting + // (POST /api/plant_tree) + PlantTree(w http.ResponseWriter, r *http.Request) +} + +// ServerInterfaceWrapper converts contexts to parameters. +type ServerInterfaceWrapper struct { + Handler ServerInterface + HandlerMiddlewares []MiddlewareFunc + ErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error) +} + +type MiddlewareFunc func(http.Handler) http.Handler + +// PlantTree operation middleware +func (siw *ServerInterfaceWrapper) PlantTree(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.PlantTree(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +type UnescapedCookieParamError struct { + ParamName string + Err error +} + +func (e *UnescapedCookieParamError) Error() string { + return fmt.Sprintf("error unescaping cookie parameter '%s'", e.ParamName) +} + +func (e *UnescapedCookieParamError) Unwrap() error { + return e.Err +} + +type UnmarshalingParamError struct { + ParamName string + Err error +} + +func (e *UnmarshalingParamError) Error() string { + return fmt.Sprintf("Error unmarshaling parameter %s as JSON: %s", e.ParamName, e.Err.Error()) +} + +func (e *UnmarshalingParamError) Unwrap() error { + return e.Err +} + +type RequiredParamError struct { + ParamName string +} + +func (e *RequiredParamError) Error() string { + return fmt.Sprintf("Query argument %s is required, but not found", e.ParamName) +} + +type RequiredHeaderError struct { + ParamName string + Err error +} + +func (e *RequiredHeaderError) Error() string { + return fmt.Sprintf("Header parameter %s is required, but not found", e.ParamName) +} + +func (e *RequiredHeaderError) Unwrap() error { + return e.Err +} + +type InvalidParamFormatError struct { + ParamName string + Err error +} + +func (e *InvalidParamFormatError) Error() string { + return fmt.Sprintf("Invalid format for parameter %s: %s", e.ParamName, e.Err.Error()) +} + +func (e *InvalidParamFormatError) Unwrap() error { + return e.Err +} + +type TooManyValuesForParamError struct { + ParamName string + Count int +} + +func (e *TooManyValuesForParamError) Error() string { + return fmt.Sprintf("Expected one value for %s, got %d", e.ParamName, e.Count) +} + +// Handler creates http.Handler with routing matching OpenAPI spec. +func Handler(si ServerInterface) http.Handler { + return HandlerWithOptions(si, StdHTTPServerOptions{}) +} + +// ServeMux is an abstraction of [http.ServeMux]. +type ServeMux interface { + HandleFunc(pattern string, handler func(http.ResponseWriter, *http.Request)) + http.Handler +} + +type StdHTTPServerOptions struct { + BaseURL string + BaseRouter ServeMux + Middlewares []MiddlewareFunc + ErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error) +} + +// HandlerFromMux creates http.Handler with routing matching OpenAPI spec based on the provided mux. +func HandlerFromMux(si ServerInterface, m ServeMux) http.Handler { + return HandlerWithOptions(si, StdHTTPServerOptions{ + BaseRouter: m, + }) +} + +func HandlerFromMuxWithBaseURL(si ServerInterface, m ServeMux, baseURL string) http.Handler { + return HandlerWithOptions(si, StdHTTPServerOptions{ + BaseURL: baseURL, + BaseRouter: m, + }) +} + +// HandlerWithOptions creates http.Handler with additional options +func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.Handler { + m := options.BaseRouter + + if m == nil { + m = http.NewServeMux() + } + if options.ErrorHandlerFunc == nil { + options.ErrorHandlerFunc = func(w http.ResponseWriter, r *http.Request, err error) { + http.Error(w, err.Error(), http.StatusBadRequest) + } + } + + wrapper := ServerInterfaceWrapper{ + Handler: si, + HandlerMiddlewares: options.Middlewares, + ErrorHandlerFunc: options.ErrorHandlerFunc, + } + + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api/plant_tree", wrapper.PlantTree) + + return m +} + +// CallbackReceiverInterface represents handlers for receiving inbound +// callback requests. Each callback becomes a Handle*Callback +// method that the implementation fills in. The caller mounts the per- +// callback http.Handler returned by {Op}CallbackHandler at +// whatever URL path they advertise to senders. +type CallbackReceiverInterface interface { + // Tree planting result notification + // HandleTreePlantedCallback handles the POST callback for treePlanted. + HandleTreePlantedCallback(w http.ResponseWriter, r *http.Request) +} + +// CallbackReceiverMiddlewareFunc wraps an http.Handler with cross- +// cutting behavior (signature verification, logging, rate limiting, ...). +type CallbackReceiverMiddlewareFunc func(http.Handler) http.Handler + +// TreePlantedCallbackHandler returns the http.Handler for the treePlanted callback. +// Mount this at the URL path advertised to callback senders. errHandler +// may be nil; if so, parameter-binding errors return 400 with the error +// message. Middlewares are applied in the order provided -- the last +// argument becomes the outermost wrapper. +func TreePlantedCallbackHandler(si CallbackReceiverInterface, errHandler func(w http.ResponseWriter, r *http.Request, err error), middlewares ...CallbackReceiverMiddlewareFunc) http.Handler { + if errHandler == nil { + errHandler = func(w http.ResponseWriter, r *http.Request, err error) { + http.Error(w, err.Error(), http.StatusBadRequest) + } + } + var h http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + si.HandleTreePlantedCallback(w, r) + }) + for _, mw := range middlewares { + h = mw(h) + } + return h +} diff --git a/examples/minimal-server/chi/api/ping.gen.go b/examples/minimal-server/chi/api/ping.gen.go index a2f368005a..f54fdd3358 100644 --- a/examples/minimal-server/chi/api/ping.gen.go +++ b/examples/minimal-server/chi/api/ping.gen.go @@ -12,6 +12,7 @@ import ( // Pong defines model for Pong. type Pong struct { + // Ping Example: pong Ping string `json:"ping"` } diff --git a/examples/minimal-server/echo/api/ping.gen.go b/examples/minimal-server/echo/api/ping.gen.go index e376e807d2..ca960bf2b0 100644 --- a/examples/minimal-server/echo/api/ping.gen.go +++ b/examples/minimal-server/echo/api/ping.gen.go @@ -9,6 +9,7 @@ import ( // Pong defines model for Pong. type Pong struct { + // Ping Example: pong Ping string `json:"ping"` } diff --git a/examples/minimal-server/fiber/api/ping.gen.go b/examples/minimal-server/fiber/api/ping.gen.go index b926f841bb..e48e5d1794 100644 --- a/examples/minimal-server/fiber/api/ping.gen.go +++ b/examples/minimal-server/fiber/api/ping.gen.go @@ -9,6 +9,7 @@ import ( // Pong defines model for Pong. type Pong struct { + // Ping Example: pong Ping string `json:"ping"` } diff --git a/examples/minimal-server/gin/api/ping.gen.go b/examples/minimal-server/gin/api/ping.gen.go index 8e018d4e89..e7148a7a52 100644 --- a/examples/minimal-server/gin/api/ping.gen.go +++ b/examples/minimal-server/gin/api/ping.gen.go @@ -9,6 +9,7 @@ import ( // Pong defines model for Pong. type Pong struct { + // Ping Example: pong Ping string `json:"ping"` } diff --git a/examples/minimal-server/gorillamux/api/ping.gen.go b/examples/minimal-server/gorillamux/api/ping.gen.go index 82ec6ebb5d..ee1a35215a 100644 --- a/examples/minimal-server/gorillamux/api/ping.gen.go +++ b/examples/minimal-server/gorillamux/api/ping.gen.go @@ -12,6 +12,7 @@ import ( // Pong defines model for Pong. type Pong struct { + // Ping Example: pong Ping string `json:"ping"` } diff --git a/examples/minimal-server/iris/api/ping.gen.go b/examples/minimal-server/iris/api/ping.gen.go index 144166eba2..3ef88437f7 100644 --- a/examples/minimal-server/iris/api/ping.gen.go +++ b/examples/minimal-server/iris/api/ping.gen.go @@ -9,6 +9,7 @@ import ( // Pong defines model for Pong. type Pong struct { + // Ping Example: pong Ping string `json:"ping"` } diff --git a/examples/minimal-server/stdhttp-go-tool/api/ping.gen.go b/examples/minimal-server/stdhttp-go-tool/api/ping.gen.go index 0d4ab7751a..93f74af35a 100644 --- a/examples/minimal-server/stdhttp-go-tool/api/ping.gen.go +++ b/examples/minimal-server/stdhttp-go-tool/api/ping.gen.go @@ -12,6 +12,7 @@ import ( // Pong defines model for Pong. type Pong struct { + // Ping Example: pong Ping string `json:"ping"` } diff --git a/examples/minimal-server/stdhttp/api/ping.gen.go b/examples/minimal-server/stdhttp/api/ping.gen.go index 0d4ab7751a..93f74af35a 100644 --- a/examples/minimal-server/stdhttp/api/ping.gen.go +++ b/examples/minimal-server/stdhttp/api/ping.gen.go @@ -12,6 +12,7 @@ import ( // Pong defines model for Pong. type Pong struct { + // Ping Example: pong Ping string `json:"ping"` } diff --git a/examples/overlay/api/ping.gen.go b/examples/overlay/api/ping.gen.go index 0d6794fc38..8cfd5525ab 100644 --- a/examples/overlay/api/ping.gen.go +++ b/examples/overlay/api/ping.gen.go @@ -19,6 +19,7 @@ import ( // OverriddenPong defines model for Pong. type OverriddenPong struct { + // Ping Example: pong Ping string `json:"ping"` } diff --git a/examples/webhook/client/main.go b/examples/webhook/client/main.go new file mode 100644 index 0000000000..8bd9d8d34e --- /dev/null +++ b/examples/webhook/client/main.go @@ -0,0 +1,150 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "flag" + "fmt" + "log" + "net" + "net/http" + "time" + + "github.com/google/uuid" + + doorbadge "github.com/oapi-codegen/oapi-codegen/v2/examples/webhook" +) + +// WebhookReceiver implements doorbadge.WebhookReceiverInterface. +type WebhookReceiver struct{} + +var _ doorbadge.WebhookReceiverInterface = (*WebhookReceiver)(nil) + +func (wr *WebhookReceiver) HandleEnterEventWebhook(w http.ResponseWriter, r *http.Request) { + var person doorbadge.Person + if err := json.NewDecoder(r.Body).Decode(&person); err != nil { + log.Printf("Error decoding enter event: %v", err) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + log.Printf("ENTER: %s", person.Name) + w.WriteHeader(http.StatusOK) +} + +func (wr *WebhookReceiver) HandleExitEventWebhook(w http.ResponseWriter, r *http.Request) { + var person doorbadge.Person + if err := json.NewDecoder(r.Body).Decode(&person); err != nil { + log.Printf("Error decoding exit event: %v", err) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + log.Printf("EXIT: %s", person.Name) + w.WriteHeader(http.StatusOK) +} + +func registerWebhook(client *http.Client, serverAddr, kind, url string) (uuid.UUID, error) { + body, err := json.Marshal(doorbadge.WebhookRegistration{URL: url}) + if err != nil { + return uuid.UUID{}, err + } + + resp, err := client.Post( + serverAddr+"/api/webhook/"+kind, + "application/json", + bytes.NewReader(body), + ) + if err != nil { + return uuid.UUID{}, err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusCreated { + return uuid.UUID{}, fmt.Errorf("unexpected status %d", resp.StatusCode) + } + + var regResp doorbadge.WebhookRegistrationResponse + if err := json.NewDecoder(resp.Body).Decode(®Resp); err != nil { + return uuid.UUID{}, err + } + return regResp.ID, nil +} + +func deregisterWebhook(client *http.Client, serverAddr string, id uuid.UUID) error { + req, err := http.NewRequest(http.MethodDelete, serverAddr+"/api/webhook/"+id.String(), nil) + if err != nil { + return err + } + resp, err := client.Do(req) + if err != nil { + return err + } + _ = resp.Body.Close() + return nil +} + +func main() { + serverAddr := flag.String("server", "http://localhost:8080", "Badge reader server address") + duration := flag.Duration("duration", 30*time.Second, "How long to listen for events") + flag.Parse() + + // Start the webhook receiver on an ephemeral port. + receiver := &WebhookReceiver{} + + mux := http.NewServeMux() + mux.Handle("POST /enter", doorbadge.EnterEventWebhookHandler(receiver, nil)) + mux.Handle("POST /exit", doorbadge.ExitEventWebhookHandler(receiver, nil)) + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + log.Fatalf("Failed to listen: %v", err) + } + callbackPort := listener.Addr().(*net.TCPAddr).Port + baseURL := fmt.Sprintf("http://localhost:%d", callbackPort) + log.Printf("Webhook receiver listening on port %d", callbackPort) + + srv := &http.Server{Handler: mux} + go func() { + if err := srv.Serve(listener); err != nil && err != http.ErrServerClosed { + log.Printf("Webhook server stopped: %v", err) + } + }() + + // Register webhooks for both event kinds. + client := &http.Client{} + + kinds := [2]string{"enterEvent", "exitEvent"} + urls := [2]string{baseURL + "/enter", baseURL + "/exit"} + var registrationIDs [2]uuid.UUID + + for i, kind := range kinds { + id, err := registerWebhook(client, *serverAddr, kind, urls[i]) + if err != nil { + log.Fatalf("Failed to register %s webhook: %v", kind, err) + } + registrationIDs[i] = id + log.Printf("Registered %s webhook: id=%s url=%s", kind, id, urls[i]) + } + + log.Printf("Listening for events for %s...", *duration) + + // Wait for the specified duration. + time.Sleep(*duration) + + // Deregister webhooks cleanly. + log.Printf("Duration elapsed, deregistering webhooks...") + for i, id := range registrationIDs { + if err := deregisterWebhook(client, *serverAddr, id); err != nil { + log.Printf("Failed to deregister %s webhook: %v", kinds[i], err) + continue + } + log.Printf("Deregistered %s webhook: id=%s", kinds[i], id) + } + + // Shut down the local webhook server. + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = srv.Shutdown(ctx) + + log.Printf("Done!") +} diff --git a/examples/webhook/config.yaml b/examples/webhook/config.yaml new file mode 100644 index 0000000000..0f14853154 --- /dev/null +++ b/examples/webhook/config.yaml @@ -0,0 +1,12 @@ +# yaml-language-server: $schema=../../configuration-schema.json +package: doorbadge +generate: + models: true + client: true + std-http-server: true +output-options: + skip-prune: true + # Use ToCamelCaseWithInitialisms so spec fields like `url` and `id` + # generate as `URL` and `ID` (matching the example's Go code). + name-normalizer: ToCamelCaseWithInitialisms +output: doorbadge.gen.go diff --git a/examples/webhook/doc.go b/examples/webhook/doc.go new file mode 100644 index 0000000000..86e08c4af4 --- /dev/null +++ b/examples/webhook/doc.go @@ -0,0 +1,13 @@ +//go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen --config=config.yaml door-badge-reader.yaml + +// Package doorbadge provides an example of OpenAPI 3.1 webhooks. +// A door badge reader server generates random enter/exit events and +// notifies registered webhook listeners. +// +// You can run the example by running these two commands in parallel: +// +// go run ./server --port 8080 +// go run ./client --server http://localhost:8080 +// +// You can run multiple clients and they will all get the notifications. +package doorbadge diff --git a/examples/webhook/door-badge-reader.yaml b/examples/webhook/door-badge-reader.yaml new file mode 100644 index 0000000000..ac37ce40dd --- /dev/null +++ b/examples/webhook/door-badge-reader.yaml @@ -0,0 +1,139 @@ +openapi: "3.1.0" +info: + version: 1.0.0 + title: Door Badge Reader + description: | + A door badge reader service that demonstrates OpenAPI 3.1 webhooks. + Clients register for enterEvent and exitEvent webhooks. The server + randomly generates badge events and notifies all registered listeners. + license: + name: Apache 2.0 + url: https://www.apache.org/licenses/LICENSE-2.0.html +paths: + /api/webhook/{kind}: + post: + summary: Register a webhook + description: | + Registers a webhook for the given event kind. The server will POST + to the provided URL whenever an event of that kind occurs. + operationId: RegisterWebhook + parameters: + - name: kind + in: path + required: true + schema: + type: string + enum: + - enterEvent + - exitEvent + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/WebhookRegistration' + responses: + '201': + description: Webhook registered + content: + application/json: + schema: + $ref: '#/components/schemas/WebhookRegistrationResponse' + default: + description: unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /api/webhook/{id}: + delete: + summary: Deregister a webhook + description: Removes a previously registered webhook by its ID. + operationId: DeregisterWebhook + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + '204': + description: Webhook deregistered + default: + description: unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' +webhooks: + enterEvent: + post: + summary: Person entered the building + operationId: EnterEvent + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Person' + responses: + '200': + description: Event received + exitEvent: + post: + summary: Person exited the building + operationId: ExitEvent + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Person' + responses: + '200': + description: Event received +components: + schemas: + WebhookRegistration: + type: object + required: + - url + properties: + url: + type: string + format: uri + description: URL to receive webhook events + + WebhookRegistrationResponse: + type: object + required: + - id + properties: + id: + type: string + format: uuid + description: Unique identifier for this webhook registration + + Person: + type: object + required: + - name + properties: + name: + type: string + description: Name of the person who badged in or out + + Error: + type: object + required: + - code + - message + properties: + code: + type: integer + format: int32 + description: Error code + message: + type: string + description: Error message diff --git a/examples/webhook/doorbadge.gen.go b/examples/webhook/doorbadge.gen.go new file mode 100644 index 0000000000..432aab6867 --- /dev/null +++ b/examples/webhook/doorbadge.gen.go @@ -0,0 +1,1028 @@ +//go:build go1.22 + +// Package doorbadge provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.0.0-00010101000000-000000000000 DO NOT EDIT. +package doorbadge + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/oapi-codegen/runtime" + openapi_types "github.com/oapi-codegen/runtime/types" +) + +// Defines values for RegisterWebhookParamsKind. +const ( + EnterEvent RegisterWebhookParamsKind = "enterEvent" + ExitEvent RegisterWebhookParamsKind = "exitEvent" +) + +// Valid indicates whether the value is a known member of the RegisterWebhookParamsKind enum. +func (e RegisterWebhookParamsKind) Valid() bool { + switch e { + case EnterEvent: + return true + case ExitEvent: + return true + default: + return false + } +} + +// Error defines model for Error. +type Error struct { + // Code Error code + Code int32 `json:"code"` + + // Message Error message + Message string `json:"message"` +} + +// Person defines model for Person. +type Person struct { + // Name Name of the person who badged in or out + Name string `json:"name"` +} + +// WebhookRegistration defines model for WebhookRegistration. +type WebhookRegistration struct { + // URL URL to receive webhook events + URL string `json:"url"` +} + +// WebhookRegistrationResponse defines model for WebhookRegistrationResponse. +type WebhookRegistrationResponse struct { + // ID Unique identifier for this webhook registration + ID openapi_types.UUID `json:"id"` +} + +// RegisterWebhookParamsKind defines parameters for RegisterWebhook. +type RegisterWebhookParamsKind string + +// RegisterWebhookJSONRequestBody defines body for RegisterWebhook for application/json ContentType. +type RegisterWebhookJSONRequestBody = WebhookRegistration + +// EnterEventJSONRequestBody defines body for EnterEvent for application/json ContentType. +type EnterEventJSONRequestBody = Person + +// ExitEventJSONRequestBody defines body for ExitEvent for application/json ContentType. +type ExitEventJSONRequestBody = Person + +// RequestEditorFn is the function signature for the RequestEditor callback function +type RequestEditorFn func(ctx context.Context, req *http.Request) error + +// Doer performs HTTP requests. +// +// The standard http.Client implements this interface. +type HttpRequestDoer interface { + Do(req *http.Request) (*http.Response, error) +} + +// Client which conforms to the OpenAPI3 specification for this service. +type Client struct { + // The endpoint of the server conforming to this interface, with scheme, + // https://api.deepmap.com for example. This can contain a path relative + // to the server, such as https://api.deepmap.com/dev-test, and all the + // paths in the swagger spec will be appended to the server. + Server string + + // Doer for performing requests, typically a *http.Client with any + // customized settings, such as certificate chains. + Client HttpRequestDoer + + // A list of callbacks for modifying requests which are generated before sending over + // the network. + RequestEditors []RequestEditorFn +} + +// ClientOption allows setting custom parameters during construction +type ClientOption func(*Client) error + +// Creates a new Client, with reasonable defaults +func NewClient(server string, opts ...ClientOption) (*Client, error) { + // create a client with sane default values + client := Client{ + Server: server, + } + // mutate client and add all optional params + for _, o := range opts { + if err := o(&client); err != nil { + return nil, err + } + } + // ensure the server URL always has a trailing slash + if !strings.HasSuffix(client.Server, "/") { + client.Server += "/" + } + // create httpClient, if not already present + if client.Client == nil { + client.Client = &http.Client{} + } + return &client, nil +} + +// WithHTTPClient allows overriding the default Doer, which is +// automatically created using http.Client. This is useful for tests. +func WithHTTPClient(doer HttpRequestDoer) ClientOption { + return func(c *Client) error { + c.Client = doer + return nil + } +} + +// WithRequestEditorFn allows setting up a callback function, which will be +// called right before sending the request. This can be used to mutate the request. +func WithRequestEditorFn(fn RequestEditorFn) ClientOption { + return func(c *Client) error { + c.RequestEditors = append(c.RequestEditors, fn) + return nil + } +} + +// The interface specification for the client above. +type ClientInterface interface { + + // DeregisterWebhook Deregister a webhook + // + // Removes a previously registered webhook by its ID. + // + // Corresponds with DELETE /api/webhook/{id} (the `DeregisterWebhook` operationId). + DeregisterWebhook(ctx context.Context, id openapi_types.UUID, reqEditors ...RequestEditorFn) (*http.Response, error) + + // RegisterWebhookWithBody Register a webhook + // + // Registers a webhook for the given event kind. The server will POST + // to the provided URL whenever an event of that kind occurs. + // + // Takes any type of body and a specified content type. + // + // Corresponds with POST /api/webhook/{kind} (the `RegisterWebhook` operationId). + RegisterWebhookWithBody(ctx context.Context, kind RegisterWebhookParamsKind, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + // RegisterWebhook Register a webhook + // + // Registers a webhook for the given event kind. The server will POST + // to the provided URL whenever an event of that kind occurs. + // + // Takes a body of the `application/json` content type. + // + // Corresponds with POST /api/webhook/{kind} (the `RegisterWebhook` operationId). + RegisterWebhook(ctx context.Context, kind RegisterWebhookParamsKind, body RegisterWebhookJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) +} + +// DeregisterWebhook Deregister a webhook +// +// Removes a previously registered webhook by its ID. +// +// Corresponds with DELETE /api/webhook/{id} (the `DeregisterWebhook` operationId). +func (c *Client) DeregisterWebhook(ctx context.Context, id openapi_types.UUID, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewDeregisterWebhookRequest(c.Server, id) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +// RegisterWebhookWithBody Register a webhook +// +// Registers a webhook for the given event kind. The server will POST +// to the provided URL whenever an event of that kind occurs. +// +// Takes any type of body and a specified content type. +// +// Corresponds with POST /api/webhook/{kind} (the `RegisterWebhook` operationId). +func (c *Client) RegisterWebhookWithBody(ctx context.Context, kind RegisterWebhookParamsKind, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewRegisterWebhookRequestWithBody(c.Server, kind, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +// RegisterWebhook Register a webhook +// +// Registers a webhook for the given event kind. The server will POST +// to the provided URL whenever an event of that kind occurs. +// +// Takes a body of the `application/json` content type. +// +// Corresponds with POST /api/webhook/{kind} (the `RegisterWebhook` operationId). +func (c *Client) RegisterWebhook(ctx context.Context, kind RegisterWebhookParamsKind, body RegisterWebhookJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewRegisterWebhookRequest(c.Server, kind, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +// NewDeregisterWebhookRequest constructs an http.Request for the DeregisterWebhook method +func NewDeregisterWebhookRequest(server string, id openapi_types.UUID) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "id", id, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: "uuid"}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/webhook/%s", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodDelete, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewRegisterWebhookRequest calls the generic RegisterWebhook builder with application/json body +func NewRegisterWebhookRequest(server string, kind RegisterWebhookParamsKind, body RegisterWebhookJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewRegisterWebhookRequestWithBody(server, kind, "application/json", bodyReader) +} + +// NewRegisterWebhookRequestWithBody constructs an http.Request for the RegisterWebhook method, with any body, and a specified content type +func NewRegisterWebhookRequestWithBody(server string, kind RegisterWebhookParamsKind, contentType string, body io.Reader) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "kind", kind, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/webhook/%s", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +func (c *Client) applyEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error { + for _, r := range c.RequestEditors { + if err := r(ctx, req); err != nil { + return err + } + } + for _, r := range additionalEditors { + if err := r(ctx, req); err != nil { + return err + } + } + return nil +} + +// ClientWithResponses builds on ClientInterface to offer response payloads +type ClientWithResponses struct { + ClientInterface +} + +// NewClientWithResponses creates a new ClientWithResponses, which wraps +// Client with return type handling +func NewClientWithResponses(server string, opts ...ClientOption) (*ClientWithResponses, error) { + client, err := NewClient(server, opts...) + if err != nil { + return nil, err + } + return &ClientWithResponses{client}, nil +} + +// WithBaseURL overrides the baseURL. +func WithBaseURL(baseURL string) ClientOption { + return func(c *Client) error { + newBaseURL, err := url.Parse(baseURL) + if err != nil { + return err + } + c.Server = newBaseURL.String() + return nil + } +} + +// ClientWithResponsesInterface is the interface specification for the client with responses above. +type ClientWithResponsesInterface interface { + + // DeregisterWebhookWithResponse Deregister a webhook + // + // Removes a previously registered webhook by its ID. + // + // Returns a wrapper object for the known response body format(s). + // + // Corresponds with DELETE /api/webhook/{id} (the `DeregisterWebhook` operationId). + DeregisterWebhookWithResponse(ctx context.Context, id openapi_types.UUID, reqEditors ...RequestEditorFn) (*DeregisterWebhookResponse, error) + + // RegisterWebhookWithBodyWithResponse Register a webhook + // + // Registers a webhook for the given event kind. The server will POST + // to the provided URL whenever an event of that kind occurs. + // + // Takes any type of body and a specified content type, and returns a wrapper object for the known response body format(s). + // + // Corresponds with POST /api/webhook/{kind} (the `RegisterWebhook` operationId). + RegisterWebhookWithBodyWithResponse(ctx context.Context, kind RegisterWebhookParamsKind, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*RegisterWebhookResponse, error) + + // RegisterWebhookWithResponse Register a webhook + // + // Registers a webhook for the given event kind. The server will POST + // to the provided URL whenever an event of that kind occurs. + // + // Takes a body of the `application/json` content type, and returns a wrapper object for the known response body format(s). + // + // Corresponds with POST /api/webhook/{kind} (the `RegisterWebhook` operationId). + RegisterWebhookWithResponse(ctx context.Context, kind RegisterWebhookParamsKind, body RegisterWebhookJSONRequestBody, reqEditors ...RequestEditorFn) (*RegisterWebhookResponse, error) +} + +type DeregisterWebhookResponse struct { + Body []byte + HTTPResponse *http.Response + // JSONDefault the response for an HTTP default `application/json` response + JSONDefault *Error +} + +// GetJSONDefault returns the response for an HTTP default `application/json` response +func (r DeregisterWebhookResponse) GetJSONDefault() *Error { + return r.JSONDefault +} + +// GetBody returns the raw response body bytes +func (r DeregisterWebhookResponse) GetBody() []byte { + return r.Body +} + +// Status returns HTTPResponse.Status +func (r DeregisterWebhookResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r DeregisterWebhookResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r DeregisterWebhookResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type RegisterWebhookResponse struct { + Body []byte + HTTPResponse *http.Response + // JSON201 the response for an HTTP 201 `application/json` response + JSON201 *WebhookRegistrationResponse + // JSONDefault the response for an HTTP default `application/json` response + JSONDefault *Error +} + +// GetJSON201 returns the response for an HTTP 201 `application/json` response +func (r RegisterWebhookResponse) GetJSON201() *WebhookRegistrationResponse { + return r.JSON201 +} + +// GetJSONDefault returns the response for an HTTP default `application/json` response +func (r RegisterWebhookResponse) GetJSONDefault() *Error { + return r.JSONDefault +} + +// GetBody returns the raw response body bytes +func (r RegisterWebhookResponse) GetBody() []byte { + return r.Body +} + +// Status returns HTTPResponse.Status +func (r RegisterWebhookResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r RegisterWebhookResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r RegisterWebhookResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +// DeregisterWebhookWithResponse Deregister a webhook +// +// Removes a previously registered webhook by its ID. +// +// Returns a wrapper object for the known response body format(s). +// +// Corresponds with DELETE /api/webhook/{id} (the `DeregisterWebhook` operationId). +func (c *ClientWithResponses) DeregisterWebhookWithResponse(ctx context.Context, id openapi_types.UUID, reqEditors ...RequestEditorFn) (*DeregisterWebhookResponse, error) { + rsp, err := c.DeregisterWebhook(ctx, id, reqEditors...) + if err != nil { + return nil, err + } + return ParseDeregisterWebhookResponse(rsp) +} + +// RegisterWebhookWithBodyWithResponse Register a webhook +// +// Registers a webhook for the given event kind. The server will POST +// to the provided URL whenever an event of that kind occurs. +// +// Takes any type of body and a specified content type, and returns a wrapper object for the known response body format(s). +// +// Corresponds with POST /api/webhook/{kind} (the `RegisterWebhook` operationId). +func (c *ClientWithResponses) RegisterWebhookWithBodyWithResponse(ctx context.Context, kind RegisterWebhookParamsKind, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*RegisterWebhookResponse, error) { + rsp, err := c.RegisterWebhookWithBody(ctx, kind, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseRegisterWebhookResponse(rsp) +} + +// RegisterWebhookWithResponse Register a webhook +// +// Registers a webhook for the given event kind. The server will POST +// to the provided URL whenever an event of that kind occurs. +// +// Takes a body of the `application/json` content type, and returns a wrapper object for the known response body format(s). +// +// Corresponds with POST /api/webhook/{kind} (the `RegisterWebhook` operationId). +func (c *ClientWithResponses) RegisterWebhookWithResponse(ctx context.Context, kind RegisterWebhookParamsKind, body RegisterWebhookJSONRequestBody, reqEditors ...RequestEditorFn) (*RegisterWebhookResponse, error) { + rsp, err := c.RegisterWebhook(ctx, kind, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseRegisterWebhookResponse(rsp) +} + +// ParseDeregisterWebhookResponse parses an HTTP response from a DeregisterWebhookWithResponse call +func ParseDeregisterWebhookResponse(rsp *http.Response) (*DeregisterWebhookResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &DeregisterWebhookResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSONDefault = &dest + + } + + return response, nil +} + +// ParseRegisterWebhookResponse parses an HTTP response from a RegisterWebhookWithResponse call +func ParseRegisterWebhookResponse(rsp *http.Response) (*RegisterWebhookResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &RegisterWebhookResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 201: + var dest WebhookRegistrationResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON201 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSONDefault = &dest + + } + + return response, nil +} + +// WebhookInitiator sends OpenAPI 3.1 webhook requests to target URLs. +// Modeled on the generated Client, but with no stored Server -- the full +// target URL is provided per-call by the caller (typically discovered +// from a subscription registration). +type WebhookInitiator struct { + // Doer for performing requests, typically a *http.Client with any + // customized settings, such as certificate chains. + Client HttpRequestDoer + + // A list of callbacks for modifying requests which are generated before sending over + // the network. + RequestEditors []RequestEditorFn +} + +// WebhookInitiatorOption allows setting custom parameters during construction. +type WebhookInitiatorOption func(*WebhookInitiator) error + +// NewWebhookInitiator creates a new WebhookInitiator with reasonable defaults. +func NewWebhookInitiator(opts ...WebhookInitiatorOption) (*WebhookInitiator, error) { + initiator := WebhookInitiator{} + for _, o := range opts { + if err := o(&initiator); err != nil { + return nil, err + } + } + if initiator.Client == nil { + initiator.Client = &http.Client{} + } + return &initiator, nil +} + +// WithWebhookHTTPClient allows overriding the default Doer, which is +// automatically created using http.Client. This is useful for tests. +func WithWebhookHTTPClient(doer HttpRequestDoer) WebhookInitiatorOption { + return func(p *WebhookInitiator) error { + p.Client = doer + return nil + } +} + +// WithWebhookRequestEditorFn allows setting up a callback function, which +// will be called right before sending the webhook request. This can be +// used to mutate the request, e.g. to add signature headers. +func WithWebhookRequestEditorFn(fn RequestEditorFn) WebhookInitiatorOption { + return func(p *WebhookInitiator) error { + p.RequestEditors = append(p.RequestEditors, fn) + return nil + } +} + +func (p *WebhookInitiator) applyWebhookEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error { + for _, r := range p.RequestEditors { + if err := r(ctx, req); err != nil { + return err + } + } + for _, r := range additionalEditors { + if err := r(ctx, req); err != nil { + return err + } + } + return nil +} + +// WebhookInitiatorInterface is the interface specification for the webhook initiator. +type WebhookInitiatorInterface interface { + // EnterEventWithBody fires the enterEvent webhook with any body + EnterEventWithBody(ctx context.Context, targetURL string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + EnterEvent(ctx context.Context, targetURL string, body EnterEventJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + + // ExitEventWithBody fires the exitEvent webhook with any body + ExitEventWithBody(ctx context.Context, targetURL string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + ExitEvent(ctx context.Context, targetURL string, body ExitEventJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) +} + +func (p *WebhookInitiator) EnterEventWithBody(ctx context.Context, targetURL string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewEnterEventWebhookRequestWithBody(targetURL, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := p.applyWebhookEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return p.Client.Do(req) +} + +func (p *WebhookInitiator) EnterEvent(ctx context.Context, targetURL string, body EnterEventJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewEnterEventWebhookRequest(targetURL, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := p.applyWebhookEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return p.Client.Do(req) +} + +func (p *WebhookInitiator) ExitEventWithBody(ctx context.Context, targetURL string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewExitEventWebhookRequestWithBody(targetURL, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := p.applyWebhookEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return p.Client.Do(req) +} + +func (p *WebhookInitiator) ExitEvent(ctx context.Context, targetURL string, body ExitEventJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewExitEventWebhookRequest(targetURL, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := p.applyWebhookEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return p.Client.Do(req) +} + +// NewEnterEventWebhookRequest builds a application/json POST request for the enterEvent webhook +func NewEnterEventWebhookRequest(targetURL string, body EnterEventJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewEnterEventWebhookRequestWithBody(targetURL, "application/json", bodyReader) +} + +// NewEnterEventWebhookRequestWithBody builds a POST request for the enterEvent webhook with any body +func NewEnterEventWebhookRequestWithBody(targetURL string, contentType string, body io.Reader) (*http.Request, error) { + var err error + _ = err + + reqURL, err := url.Parse(targetURL) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, reqURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// NewExitEventWebhookRequest builds a application/json POST request for the exitEvent webhook +func NewExitEventWebhookRequest(targetURL string, body ExitEventJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewExitEventWebhookRequestWithBody(targetURL, "application/json", bodyReader) +} + +// NewExitEventWebhookRequestWithBody builds a POST request for the exitEvent webhook with any body +func NewExitEventWebhookRequestWithBody(targetURL string, contentType string, body io.Reader) (*http.Request, error) { + var err error + _ = err + + reqURL, err := url.Parse(targetURL) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, reqURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// ServerInterface represents all server handlers. +type ServerInterface interface { + // DeregisterWebhook Deregister a webhook + // (DELETE /api/webhook/{id}) + DeregisterWebhook(w http.ResponseWriter, r *http.Request, id openapi_types.UUID) + // RegisterWebhook Register a webhook + // (POST /api/webhook/{kind}) + RegisterWebhook(w http.ResponseWriter, r *http.Request, kind RegisterWebhookParamsKind) +} + +// ServerInterfaceWrapper converts contexts to parameters. +type ServerInterfaceWrapper struct { + Handler ServerInterface + HandlerMiddlewares []MiddlewareFunc + ErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error) +} + +type MiddlewareFunc func(http.Handler) http.Handler + +// DeregisterWebhook operation middleware +func (siw *ServerInterfaceWrapper) DeregisterWebhook(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id openapi_types.UUID + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "uuid"}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.DeregisterWebhook(w, r, id) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// RegisterWebhook operation middleware +func (siw *ServerInterfaceWrapper) RegisterWebhook(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "kind" ------------- + var kind RegisterWebhookParamsKind + + err = runtime.BindStyledParameterWithOptions("simple", "kind", r.PathValue("kind"), &kind, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: ""}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "kind", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.RegisterWebhook(w, r, kind) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +type UnescapedCookieParamError struct { + ParamName string + Err error +} + +func (e *UnescapedCookieParamError) Error() string { + return fmt.Sprintf("error unescaping cookie parameter '%s'", e.ParamName) +} + +func (e *UnescapedCookieParamError) Unwrap() error { + return e.Err +} + +type UnmarshalingParamError struct { + ParamName string + Err error +} + +func (e *UnmarshalingParamError) Error() string { + return fmt.Sprintf("Error unmarshaling parameter %s as JSON: %s", e.ParamName, e.Err.Error()) +} + +func (e *UnmarshalingParamError) Unwrap() error { + return e.Err +} + +type RequiredParamError struct { + ParamName string +} + +func (e *RequiredParamError) Error() string { + return fmt.Sprintf("Query argument %s is required, but not found", e.ParamName) +} + +type RequiredHeaderError struct { + ParamName string + Err error +} + +func (e *RequiredHeaderError) Error() string { + return fmt.Sprintf("Header parameter %s is required, but not found", e.ParamName) +} + +func (e *RequiredHeaderError) Unwrap() error { + return e.Err +} + +type InvalidParamFormatError struct { + ParamName string + Err error +} + +func (e *InvalidParamFormatError) Error() string { + return fmt.Sprintf("Invalid format for parameter %s: %s", e.ParamName, e.Err.Error()) +} + +func (e *InvalidParamFormatError) Unwrap() error { + return e.Err +} + +type TooManyValuesForParamError struct { + ParamName string + Count int +} + +func (e *TooManyValuesForParamError) Error() string { + return fmt.Sprintf("Expected one value for %s, got %d", e.ParamName, e.Count) +} + +// Handler creates http.Handler with routing matching OpenAPI spec. +func Handler(si ServerInterface) http.Handler { + return HandlerWithOptions(si, StdHTTPServerOptions{}) +} + +// ServeMux is an abstraction of [http.ServeMux]. +type ServeMux interface { + HandleFunc(pattern string, handler func(http.ResponseWriter, *http.Request)) + http.Handler +} + +type StdHTTPServerOptions struct { + BaseURL string + BaseRouter ServeMux + Middlewares []MiddlewareFunc + ErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error) +} + +// HandlerFromMux creates http.Handler with routing matching OpenAPI spec based on the provided mux. +func HandlerFromMux(si ServerInterface, m ServeMux) http.Handler { + return HandlerWithOptions(si, StdHTTPServerOptions{ + BaseRouter: m, + }) +} + +func HandlerFromMuxWithBaseURL(si ServerInterface, m ServeMux, baseURL string) http.Handler { + return HandlerWithOptions(si, StdHTTPServerOptions{ + BaseURL: baseURL, + BaseRouter: m, + }) +} + +// HandlerWithOptions creates http.Handler with additional options +func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.Handler { + m := options.BaseRouter + + if m == nil { + m = http.NewServeMux() + } + if options.ErrorHandlerFunc == nil { + options.ErrorHandlerFunc = func(w http.ResponseWriter, r *http.Request, err error) { + http.Error(w, err.Error(), http.StatusBadRequest) + } + } + + wrapper := ServerInterfaceWrapper{ + Handler: si, + HandlerMiddlewares: options.Middlewares, + ErrorHandlerFunc: options.ErrorHandlerFunc, + } + + m.HandleFunc(http.MethodDelete+" "+options.BaseURL+"/api/webhook/{id}", wrapper.DeregisterWebhook) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api/webhook/{kind}", wrapper.RegisterWebhook) + + return m +} + +// WebhookReceiverInterface represents handlers for receiving inbound +// webhook requests. Each webhook becomes a Handle*Webhook +// method that the implementation fills in. The caller mounts the per- +// webhook http.Handler returned by {Op}WebhookHandler at +// whatever URL path they advertise to senders. +type WebhookReceiverInterface interface { + // Person entered the building + // HandleEnterEventWebhook handles the POST webhook for enterEvent. + HandleEnterEventWebhook(w http.ResponseWriter, r *http.Request) + // Person exited the building + // HandleExitEventWebhook handles the POST webhook for exitEvent. + HandleExitEventWebhook(w http.ResponseWriter, r *http.Request) +} + +// WebhookReceiverMiddlewareFunc wraps an http.Handler with cross- +// cutting behavior (signature verification, logging, rate limiting, ...). +type WebhookReceiverMiddlewareFunc func(http.Handler) http.Handler + +// EnterEventWebhookHandler returns the http.Handler for the enterEvent webhook. +// Mount this at the URL path advertised to webhook senders. errHandler +// may be nil; if so, parameter-binding errors return 400 with the error +// message. Middlewares are applied in the order provided -- the last +// argument becomes the outermost wrapper. +func EnterEventWebhookHandler(si WebhookReceiverInterface, errHandler func(w http.ResponseWriter, r *http.Request, err error), middlewares ...WebhookReceiverMiddlewareFunc) http.Handler { + if errHandler == nil { + errHandler = func(w http.ResponseWriter, r *http.Request, err error) { + http.Error(w, err.Error(), http.StatusBadRequest) + } + } + var h http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + si.HandleEnterEventWebhook(w, r) + }) + for _, mw := range middlewares { + h = mw(h) + } + return h +} + +// ExitEventWebhookHandler returns the http.Handler for the exitEvent webhook. +// Mount this at the URL path advertised to webhook senders. errHandler +// may be nil; if so, parameter-binding errors return 400 with the error +// message. Middlewares are applied in the order provided -- the last +// argument becomes the outermost wrapper. +func ExitEventWebhookHandler(si WebhookReceiverInterface, errHandler func(w http.ResponseWriter, r *http.Request, err error), middlewares ...WebhookReceiverMiddlewareFunc) http.Handler { + if errHandler == nil { + errHandler = func(w http.ResponseWriter, r *http.Request, err error) { + http.Error(w, err.Error(), http.StatusBadRequest) + } + } + var h http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + si.HandleExitEventWebhook(w, r) + }) + for _, mw := range middlewares { + h = mw(h) + } + return h +} diff --git a/examples/webhook/server/main.go b/examples/webhook/server/main.go new file mode 100644 index 0000000000..368a932464 --- /dev/null +++ b/examples/webhook/server/main.go @@ -0,0 +1,186 @@ +package main + +import ( + "context" + "encoding/json" + "flag" + "log" + "math/rand/v2" + "net" + "net/http" + "sync" + "time" + + "github.com/google/uuid" + + doorbadge "github.com/oapi-codegen/oapi-codegen/v2/examples/webhook" +) + +var names = []string{ + "Alice", "Bob", "Charlie", "Diana", "Eve", + "Frank", "Grace", "Hank", "Iris", "Jack", +} + +type webhookEntry struct { + id uuid.UUID + url string + kind doorbadge.RegisterWebhookParamsKind +} + +// BadgeReader implements doorbadge.ServerInterface. +type BadgeReader struct { + initiator *doorbadge.WebhookInitiator + + mu sync.Mutex + webhooks map[uuid.UUID]webhookEntry +} + +var _ doorbadge.ServerInterface = (*BadgeReader)(nil) + +func NewBadgeReader() *BadgeReader { + initiator, err := doorbadge.NewWebhookInitiator() + if err != nil { + log.Fatalf("Failed to create webhook initiator: %v", err) + } + return &BadgeReader{ + initiator: initiator, + webhooks: make(map[uuid.UUID]webhookEntry), + } +} + +func sendError(w http.ResponseWriter, code int, message string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(doorbadge.Error{ + Code: int32(code), + Message: message, + }) +} + +func (br *BadgeReader) RegisterWebhook(w http.ResponseWriter, r *http.Request, kind doorbadge.RegisterWebhookParamsKind) { + var req doorbadge.WebhookRegistration + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + sendError(w, http.StatusBadRequest, "Invalid request body: "+err.Error()) + return + } + + if kind != doorbadge.EnterEvent && kind != doorbadge.ExitEvent { + sendError(w, http.StatusBadRequest, "Invalid webhook kind: "+string(kind)) + return + } + + id := uuid.New() + entry := webhookEntry{id: id, url: req.URL, kind: kind} + + br.mu.Lock() + br.webhooks[id] = entry + br.mu.Unlock() + + log.Printf("Registered webhook: id=%s kind=%s url=%s", id, kind, req.URL) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(doorbadge.WebhookRegistrationResponse{ID: id}) +} + +func (br *BadgeReader) DeregisterWebhook(w http.ResponseWriter, r *http.Request, id uuid.UUID) { + br.mu.Lock() + entry, ok := br.webhooks[id] + delete(br.webhooks, id) + br.mu.Unlock() + + if !ok { + sendError(w, http.StatusNotFound, "Webhook not found: "+id.String()) + return + } + + log.Printf("Deregistered webhook: id=%s kind=%s url=%s", id, entry.kind, entry.url) + w.WriteHeader(http.StatusNoContent) +} + +// generateEvents picks a random name and event kind every second and notifies webhooks. +func (br *BadgeReader) generateEvents(ctx context.Context) { + ticker := time.NewTicker(1 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + name := names[rand.IntN(len(names))] + kind := doorbadge.EnterEvent + if rand.IntN(2) == 0 { + kind = doorbadge.ExitEvent + } + + person := doorbadge.Person{Name: name} + + br.mu.Lock() + targets := make([]webhookEntry, 0) + for _, entry := range br.webhooks { + if entry.kind == kind { + targets = append(targets, entry) + } + } + br.mu.Unlock() + + if len(targets) == 0 { + continue + } + + log.Printf("Event: %s %s (%d webhooks)", kind, name, len(targets)) + + for _, target := range targets { + var resp *http.Response + var err error + + switch kind { + case doorbadge.EnterEvent: + resp, err = br.initiator.EnterEvent(ctx, target.url, person) + case doorbadge.ExitEvent: + resp, err = br.initiator.ExitEvent(ctx, target.url, person) + } + + if err != nil { + log.Printf("Webhook %s failed: %v — removing", target.id, err) + br.mu.Lock() + delete(br.webhooks, target.id) + br.mu.Unlock() + continue + } + _ = resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + log.Printf("Webhook %s returned %d — removing", target.id, resp.StatusCode) + br.mu.Lock() + delete(br.webhooks, target.id) + br.mu.Unlock() + } + } + } + } +} + +func main() { + port := flag.String("port", "8080", "Port for HTTP server") + flag.Parse() + + reader := NewBadgeReader() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go reader.generateEvents(ctx) + + mux := http.NewServeMux() + doorbadge.HandlerFromMux(reader, mux) + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + log.Printf("%s %s from %s", r.Method, r.URL.Path, r.RemoteAddr) + mux.ServeHTTP(w, r) + }) + + addr := net.JoinHostPort("0.0.0.0", *port) + log.Printf("Door Badge Reader server listening on %s", addr) + log.Fatal(http.ListenAndServe(addr, handler)) +} diff --git a/internal/test/anonymous_inner_hoisting/global/client.gen.go b/internal/test/anonymous_inner_hoisting/global/client.gen.go index 40908af96d..5dbaaf23ed 100644 --- a/internal/test/anonymous_inner_hoisting/global/client.gen.go +++ b/internal/test/anonymous_inner_hoisting/global/client.gen.go @@ -21,6 +21,15 @@ type Role struct { Name string `json:"name"` } +// Roles defines model for Roles. +type Roles = []Roles_Item + +// Roles_Item defines model for Roles.Item. +type Roles_Item struct { + Id int `json:"id"` + Name string `json:"name"` +} + // SuccessfulResponse defines model for SuccessfulResponse. type SuccessfulResponse struct { // Data If successful, response from api @@ -117,10 +126,26 @@ func WithRequestEditorFn(fn RequestEditorFn) ClientOption { // The interface specification for the client above. type ClientInterface interface { + // GetRoles performs a GET /roles (the `GetRoles` operationId) request. + GetRoles(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) + // GetRolesId performs a GET /roles/{id} (the `GetRolesId` operationId) request. GetRolesId(ctx context.Context, id int, reqEditors ...RequestEditorFn) (*http.Response, error) } +// GetRoles performs a GET /roles (the `GetRoles` operationId) request. +func (c *Client) GetRoles(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetRolesRequest(c.Server) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + // GetRolesId performs a GET /roles/{id} (the `GetRolesId` operationId) request. func (c *Client) GetRolesId(ctx context.Context, id int, reqEditors ...RequestEditorFn) (*http.Response, error) { req, err := NewGetRolesIdRequest(c.Server, id) @@ -134,6 +159,33 @@ func (c *Client) GetRolesId(ctx context.Context, id int, reqEditors ...RequestEd return c.Client.Do(req) } +// NewGetRolesRequest constructs an http.Request for the GetRoles method +func NewGetRolesRequest(server string) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/roles") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + // NewGetRolesIdRequest constructs an http.Request for the GetRolesId method func NewGetRolesIdRequest(server string, id int) (*http.Request, error) { var err error @@ -212,12 +264,58 @@ func WithBaseURL(baseURL string) ClientOption { // ClientWithResponsesInterface is the interface specification for the client with responses above. type ClientWithResponsesInterface interface { + // GetRolesWithResponse performs a GET /roles (the `GetRoles` operationId) request. + // + // Returns a wrapper object for the known response body format(s). + GetRolesWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetRolesResponse, error) + // GetRolesIdWithResponse performs a GET /roles/{id} (the `GetRolesId` operationId) request. // // Returns a wrapper object for the known response body format(s). GetRolesIdWithResponse(ctx context.Context, id int, reqEditors ...RequestEditorFn) (*GetRolesIdResponse, error) } +type GetRolesResponse struct { + Body []byte + HTTPResponse *http.Response + // JSON200 the response for an HTTP 200 `application/json` response + JSON200 *Roles +} + +// GetJSON200 returns the response for an HTTP 200 `application/json` response +func (r GetRolesResponse) GetJSON200() *Roles { + return r.JSON200 +} + +// GetBody returns the raw response body bytes +func (r GetRolesResponse) GetBody() []byte { + return r.Body +} + +// Status returns HTTPResponse.Status +func (r GetRolesResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetRolesResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r GetRolesResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + type GetRolesIdResponse struct { Body []byte HTTPResponse *http.Response @@ -259,6 +357,17 @@ func (r GetRolesIdResponse) ContentType() string { return "" } +// GetRolesWithResponse performs a GET /roles (the `GetRoles` operationId) request. +// +// Returns a wrapper object for the known response body format(s). +func (c *ClientWithResponses) GetRolesWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetRolesResponse, error) { + rsp, err := c.GetRoles(ctx, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetRolesResponse(rsp) +} + // GetRolesIdWithResponse performs a GET /roles/{id} (the `GetRolesId` operationId) request. // // Returns a wrapper object for the known response body format(s). @@ -270,6 +379,32 @@ func (c *ClientWithResponses) GetRolesIdWithResponse(ctx context.Context, id int return ParseGetRolesIdResponse(rsp) } +// ParseGetRolesResponse parses an HTTP response from a GetRolesWithResponse call +func ParseGetRolesResponse(rsp *http.Response) (*GetRolesResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetRolesResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest Roles + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + } + + return response, nil +} + // ParseGetRolesIdResponse parses an HTTP response from a GetRolesIdWithResponse call func ParseGetRolesIdResponse(rsp *http.Response) (*GetRolesIdResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) diff --git a/internal/test/anonymous_inner_hoisting/global/client_test.go b/internal/test/anonymous_inner_hoisting/global/client_test.go index b6cf9fb9af..6e0a5c2678 100644 --- a/internal/test/anonymous_inner_hoisting/global/client_test.go +++ b/internal/test/anonymous_inner_hoisting/global/client_test.go @@ -42,3 +42,19 @@ func TestHoistedTypesRoundTrip(t *testing.T) { require.NoError(t, json.Unmarshal(encoded, &decoded)) assert.Equal(t, body, decoded) } + +// TestArrayOfInlineObjectsHoisted verifies that a top-level array schema +// whose items are an inline object emits a named element type under the +// flag, instead of `type Roles = []struct{...}`. The element type's name +// is Roles_Item (path + "Item" suffix, matching the existing array-item +// hoist convention for unions and additionalProperties). +func TestArrayOfInlineObjectsHoisted(t *testing.T) { + // Compile-time assertion: Roles is a slice whose element type is the + // named Roles_Item type, not an anonymous struct. + roles := Roles{ + Roles_Item{Id: 1, Name: "admin"}, + Roles_Item{Id: 2, Name: "user"}, + } + require.Len(t, roles, 2) + assert.Equal(t, "admin", roles[0].Name) +} diff --git a/internal/test/anonymous_inner_hoisting/global/spec.yaml b/internal/test/anonymous_inner_hoisting/global/spec.yaml index 884f9369f3..31e28651dd 100644 --- a/internal/test/anonymous_inner_hoisting/global/spec.yaml +++ b/internal/test/anonymous_inner_hoisting/global/spec.yaml @@ -31,6 +31,20 @@ paths: role: $ref: '#/components/schemas/Role' required: [ role ] + # Reference the top-level `Roles` array schema from an operation so + # the default prune pass doesn't strip it before codegen runs. + /roles: + get: + operationId: GetRoles + tags: + - role + responses: + '200': + description: All roles + content: + application/json: + schema: + $ref: '#/components/schemas/Roles' components: schemas: @@ -57,3 +71,21 @@ components: required: - id - name + # Regression for: top-level array of inline objects. Under + # generate-types-for-anonymous-schemas, this must emit a named + # element type (Roles_Item) rather than `type Roles = []struct{...}`. + # The auto-hoist inside GenerateGoSchema is gated on len(path)>1, + # which fails for a top-level component path, so the array-item hoist + # in oapiSchemaToGoType has to cover this case. + Roles: + type: array + items: + type: object + properties: + id: + type: integer + name: + type: string + required: + - id + - name diff --git a/internal/test/anonymous_inner_hoisting/implicit/client.gen.go b/internal/test/anonymous_inner_hoisting/implicit/client.gen.go index 09e69c9e73..34dcb66075 100644 --- a/internal/test/anonymous_inner_hoisting/implicit/client.gen.go +++ b/internal/test/anonymous_inner_hoisting/implicit/client.gen.go @@ -79,6 +79,11 @@ type PostBodyRootOneOfJSONBody struct { union json.RawMessage } +// TriggerCallbackJSONBody defines parameters for TriggerCallback. +type TriggerCallbackJSONBody struct { + CallbackUrl string `json:"callbackUrl"` +} + // GetResponseDeepNested200JSONResponseBody_Wrapper_Inner defines parameters for GetResponseDeepNested. type GetResponseDeepNested200JSONResponseBody_Wrapper_Inner struct { union json.RawMessage @@ -99,12 +104,57 @@ type GetResponseRootOneOf200JSONResponseBody struct { union json.RawMessage } +// WebhookBodyPropertyOneOfJSONBody defines parameters for WebhookBodyPropertyOneOf. +type WebhookBodyPropertyOneOfJSONBody struct { + Pet *WebhookBodyPropertyOneOfJSONBody_Pet `json:"pet,omitempty"` +} + +// WebhookBodyPropertyOneOfJSONBody_Pet defines parameters for WebhookBodyPropertyOneOf. +type WebhookBodyPropertyOneOfJSONBody_Pet struct { + union json.RawMessage +} + +// WebhookBodyRootOneOfJSONBody defines parameters for WebhookBodyRootOneOf. +type WebhookBodyRootOneOfJSONBody struct { + union json.RawMessage +} + +// CallbackBodyPropertyOneOfJSONBody defines parameters for CallbackBodyPropertyOneOf. +type CallbackBodyPropertyOneOfJSONBody struct { + Pet *CallbackBodyPropertyOneOfJSONBody_Pet `json:"pet,omitempty"` +} + +// CallbackBodyPropertyOneOfJSONBody_Pet defines parameters for CallbackBodyPropertyOneOf. +type CallbackBodyPropertyOneOfJSONBody_Pet struct { + union json.RawMessage +} + +// CallbackBodyRootOneOfJSONBody defines parameters for CallbackBodyRootOneOf. +type CallbackBodyRootOneOfJSONBody struct { + union json.RawMessage +} + // PostBodyPropertyOneOfJSONRequestBody defines body for PostBodyPropertyOneOf for application/json ContentType. type PostBodyPropertyOneOfJSONRequestBody PostBodyPropertyOneOfJSONBody // PostBodyRootOneOfJSONRequestBody defines body for PostBodyRootOneOf for application/json ContentType. type PostBodyRootOneOfJSONRequestBody PostBodyRootOneOfJSONBody +// TriggerCallbackJSONRequestBody defines body for TriggerCallback for application/json ContentType. +type TriggerCallbackJSONRequestBody TriggerCallbackJSONBody + +// WebhookBodyPropertyOneOfJSONRequestBody defines body for WebhookBodyPropertyOneOf for application/json ContentType. +type WebhookBodyPropertyOneOfJSONRequestBody WebhookBodyPropertyOneOfJSONBody + +// WebhookBodyRootOneOfJSONRequestBody defines body for WebhookBodyRootOneOf for application/json ContentType. +type WebhookBodyRootOneOfJSONRequestBody WebhookBodyRootOneOfJSONBody + +// CallbackBodyPropertyOneOfJSONRequestBody defines body for CallbackBodyPropertyOneOf for application/json ContentType. +type CallbackBodyPropertyOneOfJSONRequestBody CallbackBodyPropertyOneOfJSONBody + +// CallbackBodyRootOneOfJSONRequestBody defines body for CallbackBodyRootOneOf for application/json ContentType. +type CallbackBodyRootOneOfJSONRequestBody CallbackBodyRootOneOfJSONBody + // AsCat returns the union data inside the PostBodyPropertyOneOfJSONBody_Pet as a Cat func (t PostBodyPropertyOneOfJSONBody_Pet) AsCat() (Cat, error) { var body Cat @@ -477,6 +527,254 @@ func (t *GetResponseRootOneOf200JSONResponseBody) UnmarshalJSON(b []byte) error return err } +// AsCat returns the union data inside the WebhookBodyPropertyOneOfJSONBody_Pet as a Cat +func (t WebhookBodyPropertyOneOfJSONBody_Pet) AsCat() (Cat, error) { + var body Cat + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromCat overwrites any union data inside the WebhookBodyPropertyOneOfJSONBody_Pet as the provided Cat +func (t *WebhookBodyPropertyOneOfJSONBody_Pet) FromCat(v Cat) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeCat performs a merge with any union data inside the WebhookBodyPropertyOneOfJSONBody_Pet, using the provided Cat +func (t *WebhookBodyPropertyOneOfJSONBody_Pet) MergeCat(v Cat) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsDog returns the union data inside the WebhookBodyPropertyOneOfJSONBody_Pet as a Dog +func (t WebhookBodyPropertyOneOfJSONBody_Pet) AsDog() (Dog, error) { + var body Dog + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromDog overwrites any union data inside the WebhookBodyPropertyOneOfJSONBody_Pet as the provided Dog +func (t *WebhookBodyPropertyOneOfJSONBody_Pet) FromDog(v Dog) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeDog performs a merge with any union data inside the WebhookBodyPropertyOneOfJSONBody_Pet, using the provided Dog +func (t *WebhookBodyPropertyOneOfJSONBody_Pet) MergeDog(v Dog) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +func (t WebhookBodyPropertyOneOfJSONBody_Pet) MarshalJSON() ([]byte, error) { + b, err := t.union.MarshalJSON() + return b, err +} + +func (t *WebhookBodyPropertyOneOfJSONBody_Pet) UnmarshalJSON(b []byte) error { + err := t.union.UnmarshalJSON(b) + return err +} + +// AsCat returns the union data inside the WebhookBodyRootOneOfJSONBody as a Cat +func (t WebhookBodyRootOneOfJSONBody) AsCat() (Cat, error) { + var body Cat + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromCat overwrites any union data inside the WebhookBodyRootOneOfJSONBody as the provided Cat +func (t *WebhookBodyRootOneOfJSONBody) FromCat(v Cat) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeCat performs a merge with any union data inside the WebhookBodyRootOneOfJSONBody, using the provided Cat +func (t *WebhookBodyRootOneOfJSONBody) MergeCat(v Cat) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsDog returns the union data inside the WebhookBodyRootOneOfJSONBody as a Dog +func (t WebhookBodyRootOneOfJSONBody) AsDog() (Dog, error) { + var body Dog + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromDog overwrites any union data inside the WebhookBodyRootOneOfJSONBody as the provided Dog +func (t *WebhookBodyRootOneOfJSONBody) FromDog(v Dog) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeDog performs a merge with any union data inside the WebhookBodyRootOneOfJSONBody, using the provided Dog +func (t *WebhookBodyRootOneOfJSONBody) MergeDog(v Dog) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +func (t WebhookBodyRootOneOfJSONBody) MarshalJSON() ([]byte, error) { + b, err := t.union.MarshalJSON() + return b, err +} + +func (t *WebhookBodyRootOneOfJSONBody) UnmarshalJSON(b []byte) error { + err := t.union.UnmarshalJSON(b) + return err +} + +// AsCat returns the union data inside the CallbackBodyPropertyOneOfJSONBody_Pet as a Cat +func (t CallbackBodyPropertyOneOfJSONBody_Pet) AsCat() (Cat, error) { + var body Cat + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromCat overwrites any union data inside the CallbackBodyPropertyOneOfJSONBody_Pet as the provided Cat +func (t *CallbackBodyPropertyOneOfJSONBody_Pet) FromCat(v Cat) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeCat performs a merge with any union data inside the CallbackBodyPropertyOneOfJSONBody_Pet, using the provided Cat +func (t *CallbackBodyPropertyOneOfJSONBody_Pet) MergeCat(v Cat) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsDog returns the union data inside the CallbackBodyPropertyOneOfJSONBody_Pet as a Dog +func (t CallbackBodyPropertyOneOfJSONBody_Pet) AsDog() (Dog, error) { + var body Dog + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromDog overwrites any union data inside the CallbackBodyPropertyOneOfJSONBody_Pet as the provided Dog +func (t *CallbackBodyPropertyOneOfJSONBody_Pet) FromDog(v Dog) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeDog performs a merge with any union data inside the CallbackBodyPropertyOneOfJSONBody_Pet, using the provided Dog +func (t *CallbackBodyPropertyOneOfJSONBody_Pet) MergeDog(v Dog) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +func (t CallbackBodyPropertyOneOfJSONBody_Pet) MarshalJSON() ([]byte, error) { + b, err := t.union.MarshalJSON() + return b, err +} + +func (t *CallbackBodyPropertyOneOfJSONBody_Pet) UnmarshalJSON(b []byte) error { + err := t.union.UnmarshalJSON(b) + return err +} + +// AsCat returns the union data inside the CallbackBodyRootOneOfJSONBody as a Cat +func (t CallbackBodyRootOneOfJSONBody) AsCat() (Cat, error) { + var body Cat + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromCat overwrites any union data inside the CallbackBodyRootOneOfJSONBody as the provided Cat +func (t *CallbackBodyRootOneOfJSONBody) FromCat(v Cat) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeCat performs a merge with any union data inside the CallbackBodyRootOneOfJSONBody, using the provided Cat +func (t *CallbackBodyRootOneOfJSONBody) MergeCat(v Cat) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsDog returns the union data inside the CallbackBodyRootOneOfJSONBody as a Dog +func (t CallbackBodyRootOneOfJSONBody) AsDog() (Dog, error) { + var body Dog + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromDog overwrites any union data inside the CallbackBodyRootOneOfJSONBody as the provided Dog +func (t *CallbackBodyRootOneOfJSONBody) FromDog(v Dog) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeDog performs a merge with any union data inside the CallbackBodyRootOneOfJSONBody, using the provided Dog +func (t *CallbackBodyRootOneOfJSONBody) MergeDog(v Dog) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +func (t CallbackBodyRootOneOfJSONBody) MarshalJSON() ([]byte, error) { + b, err := t.union.MarshalJSON() + return b, err +} + +func (t *CallbackBodyRootOneOfJSONBody) UnmarshalJSON(b []byte) error { + err := t.union.UnmarshalJSON(b) + return err +} + // RequestEditorFn is the function signature for the RequestEditor callback function type RequestEditorFn func(ctx context.Context, req *http.Request) error @@ -567,6 +865,14 @@ type ClientInterface interface { // Takes a body of the `application/json` content type. PostBodyRootOneOf(ctx context.Context, body PostBodyRootOneOfJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + // TriggerCallbackWithBody performs a POST /callback-trigger (the `TriggerCallback` operationId) request, + // with any type of body and a specified content type. + TriggerCallbackWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + // TriggerCallback performs a POST /callback-trigger (the `TriggerCallback` operationId) request. + // Takes a body of the `application/json` content type. + TriggerCallback(ctx context.Context, body TriggerCallbackJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + // GetResponseDeepNested performs a GET /response-deep-nested (the `GetResponseDeepNested` operationId) request. GetResponseDeepNested(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -636,6 +942,34 @@ func (c *Client) PostBodyRootOneOf(ctx context.Context, body PostBodyRootOneOfJS return c.Client.Do(req) } +// TriggerCallbackWithBody performs a POST /callback-trigger (the `TriggerCallback` operationId) request, +// with any type of body and a specified content type. +func (c *Client) TriggerCallbackWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewTriggerCallbackRequestWithBody(c.Server, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +// TriggerCallback performs a POST /callback-trigger (the `TriggerCallback` operationId) request. +// Takes a body of the `application/json` content type. +func (c *Client) TriggerCallback(ctx context.Context, body TriggerCallbackJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewTriggerCallbackRequest(c.Server, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + // GetResponseDeepNested performs a GET /response-deep-nested (the `GetResponseDeepNested` operationId) request. func (c *Client) GetResponseDeepNested(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) { req, err := NewGetResponseDeepNestedRequest(c.Server) @@ -768,8 +1102,19 @@ func NewPostBodyRootOneOfRequestWithBody(server string, contentType string, body return req, nil } -// NewGetResponseDeepNestedRequest constructs an http.Request for the GetResponseDeepNested method -func NewGetResponseDeepNestedRequest(server string) (*http.Request, error) { +// NewTriggerCallbackRequest calls the generic TriggerCallback builder with application/json body +func NewTriggerCallbackRequest(server string, body TriggerCallbackJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewTriggerCallbackRequestWithBody(server, "application/json", bodyReader) +} + +// NewTriggerCallbackRequestWithBody constructs an http.Request for the TriggerCallback method, with any body, and a specified content type +func NewTriggerCallbackRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) { var err error serverURL, err := url.Parse(server) @@ -777,7 +1122,7 @@ func NewGetResponseDeepNestedRequest(server string) (*http.Request, error) { return nil, err } - operationPath := fmt.Sprintf("/response-deep-nested") + operationPath := fmt.Sprintf("/callback-trigger") if operationPath[0] == '/' { operationPath = "." + operationPath } @@ -787,7 +1132,36 @@ func NewGetResponseDeepNestedRequest(server string) (*http.Request, error) { return nil, err } - req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// NewGetResponseDeepNestedRequest constructs an http.Request for the GetResponseDeepNested method +func NewGetResponseDeepNestedRequest(server string) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/response-deep-nested") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) if err != nil { return nil, err } @@ -940,6 +1314,16 @@ type ClientWithResponsesInterface interface { // Takes a body of the `application/json` content type, and returns a wrapper object for the known response body format(s). PostBodyRootOneOfWithResponse(ctx context.Context, body PostBodyRootOneOfJSONRequestBody, reqEditors ...RequestEditorFn) (*PostBodyRootOneOfResponse, error) + // TriggerCallbackWithBodyWithResponse performs a POST /callback-trigger (the `TriggerCallback` operationId) request, + // with any type of body and a specified content type. + // + // Returns a wrapper object for the known response body format(s). + TriggerCallbackWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*TriggerCallbackResponse, error) + + // TriggerCallbackWithResponse performs a POST /callback-trigger (the `TriggerCallback` operationId) request. + // Takes a body of the `application/json` content type, and returns a wrapper object for the known response body format(s). + TriggerCallbackWithResponse(ctx context.Context, body TriggerCallbackJSONRequestBody, reqEditors ...RequestEditorFn) (*TriggerCallbackResponse, error) + // GetResponseDeepNestedWithResponse performs a GET /response-deep-nested (the `GetResponseDeepNested` operationId) request. // // Returns a wrapper object for the known response body format(s). @@ -1029,6 +1413,40 @@ func (r PostBodyRootOneOfResponse) ContentType() string { return "" } +type TriggerCallbackResponse struct { + Body []byte + HTTPResponse *http.Response +} + +// GetBody returns the raw response body bytes +func (r TriggerCallbackResponse) GetBody() []byte { + return r.Body +} + +// Status returns HTTPResponse.Status +func (r TriggerCallbackResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r TriggerCallbackResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r TriggerCallbackResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + type GetResponseDeepNestedResponse struct { Body []byte HTTPResponse *http.Response @@ -1249,6 +1667,28 @@ func (c *ClientWithResponses) PostBodyRootOneOfWithResponse(ctx context.Context, return ParsePostBodyRootOneOfResponse(rsp) } +// TriggerCallbackWithBodyWithResponse performs a POST /callback-trigger (the `TriggerCallback` operationId) request, +// with any type of body and a specified content type. +// +// Returns a wrapper object for the known response body format(s). +func (c *ClientWithResponses) TriggerCallbackWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*TriggerCallbackResponse, error) { + rsp, err := c.TriggerCallbackWithBody(ctx, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseTriggerCallbackResponse(rsp) +} + +// TriggerCallbackWithResponse performs a POST /callback-trigger (the `TriggerCallback` operationId) request. +// Takes a body of the `application/json` content type, and returns a wrapper object for the known response body format(s). +func (c *ClientWithResponses) TriggerCallbackWithResponse(ctx context.Context, body TriggerCallbackJSONRequestBody, reqEditors ...RequestEditorFn) (*TriggerCallbackResponse, error) { + rsp, err := c.TriggerCallback(ctx, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseTriggerCallbackResponse(rsp) +} + // GetResponseDeepNestedWithResponse performs a GET /response-deep-nested (the `GetResponseDeepNested` operationId) request. // // Returns a wrapper object for the known response body format(s). @@ -1325,6 +1765,22 @@ func ParsePostBodyRootOneOfResponse(rsp *http.Response) (*PostBodyRootOneOfRespo return response, nil } +// ParseTriggerCallbackResponse parses an HTTP response from a TriggerCallbackWithResponse call +func ParseTriggerCallbackResponse(rsp *http.Response) (*TriggerCallbackResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &TriggerCallbackResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + return response, nil +} + // ParseGetResponseDeepNestedResponse parses an HTTP response from a GetResponseDeepNestedWithResponse call func ParseGetResponseDeepNestedResponse(rsp *http.Response) (*GetResponseDeepNestedResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) @@ -1434,3 +1890,378 @@ func ParseGetResponseRootOneOfResponse(rsp *http.Response) (*GetResponseRootOneO return response, nil } + +// WebhookInitiator sends OpenAPI 3.1 webhook requests to target URLs. +// Modeled on the generated Client, but with no stored Server -- the full +// target URL is provided per-call by the caller (typically discovered +// from a subscription registration). +type WebhookInitiator struct { + // Doer for performing requests, typically a *http.Client with any + // customized settings, such as certificate chains. + Client HttpRequestDoer + + // A list of callbacks for modifying requests which are generated before sending over + // the network. + RequestEditors []RequestEditorFn +} + +// WebhookInitiatorOption allows setting custom parameters during construction. +type WebhookInitiatorOption func(*WebhookInitiator) error + +// NewWebhookInitiator creates a new WebhookInitiator with reasonable defaults. +func NewWebhookInitiator(opts ...WebhookInitiatorOption) (*WebhookInitiator, error) { + initiator := WebhookInitiator{} + for _, o := range opts { + if err := o(&initiator); err != nil { + return nil, err + } + } + if initiator.Client == nil { + initiator.Client = &http.Client{} + } + return &initiator, nil +} + +// WithWebhookHTTPClient allows overriding the default Doer, which is +// automatically created using http.Client. This is useful for tests. +func WithWebhookHTTPClient(doer HttpRequestDoer) WebhookInitiatorOption { + return func(p *WebhookInitiator) error { + p.Client = doer + return nil + } +} + +// WithWebhookRequestEditorFn allows setting up a callback function, which +// will be called right before sending the webhook request. This can be +// used to mutate the request, e.g. to add signature headers. +func WithWebhookRequestEditorFn(fn RequestEditorFn) WebhookInitiatorOption { + return func(p *WebhookInitiator) error { + p.RequestEditors = append(p.RequestEditors, fn) + return nil + } +} + +func (p *WebhookInitiator) applyWebhookEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error { + for _, r := range p.RequestEditors { + if err := r(ctx, req); err != nil { + return err + } + } + for _, r := range additionalEditors { + if err := r(ctx, req); err != nil { + return err + } + } + return nil +} + +// WebhookInitiatorInterface is the interface specification for the webhook initiator. +type WebhookInitiatorInterface interface { + // WebhookBodyPropertyOneOfWithBody fires the webhookBodyPropertyOneOf webhook with any body + WebhookBodyPropertyOneOfWithBody(ctx context.Context, targetURL string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + WebhookBodyPropertyOneOf(ctx context.Context, targetURL string, body WebhookBodyPropertyOneOfJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + + // WebhookBodyRootOneOfWithBody fires the webhookBodyRootOneOf webhook with any body + WebhookBodyRootOneOfWithBody(ctx context.Context, targetURL string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + WebhookBodyRootOneOf(ctx context.Context, targetURL string, body WebhookBodyRootOneOfJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) +} + +func (p *WebhookInitiator) WebhookBodyPropertyOneOfWithBody(ctx context.Context, targetURL string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewWebhookBodyPropertyOneOfWebhookRequestWithBody(targetURL, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := p.applyWebhookEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return p.Client.Do(req) +} + +func (p *WebhookInitiator) WebhookBodyPropertyOneOf(ctx context.Context, targetURL string, body WebhookBodyPropertyOneOfJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewWebhookBodyPropertyOneOfWebhookRequest(targetURL, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := p.applyWebhookEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return p.Client.Do(req) +} + +func (p *WebhookInitiator) WebhookBodyRootOneOfWithBody(ctx context.Context, targetURL string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewWebhookBodyRootOneOfWebhookRequestWithBody(targetURL, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := p.applyWebhookEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return p.Client.Do(req) +} + +func (p *WebhookInitiator) WebhookBodyRootOneOf(ctx context.Context, targetURL string, body WebhookBodyRootOneOfJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewWebhookBodyRootOneOfWebhookRequest(targetURL, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := p.applyWebhookEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return p.Client.Do(req) +} + +// NewWebhookBodyPropertyOneOfWebhookRequest builds a application/json POST request for the webhookBodyPropertyOneOf webhook +func NewWebhookBodyPropertyOneOfWebhookRequest(targetURL string, body WebhookBodyPropertyOneOfJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewWebhookBodyPropertyOneOfWebhookRequestWithBody(targetURL, "application/json", bodyReader) +} + +// NewWebhookBodyPropertyOneOfWebhookRequestWithBody builds a POST request for the webhookBodyPropertyOneOf webhook with any body +func NewWebhookBodyPropertyOneOfWebhookRequestWithBody(targetURL string, contentType string, body io.Reader) (*http.Request, error) { + var err error + _ = err + + reqURL, err := url.Parse(targetURL) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, reqURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// NewWebhookBodyRootOneOfWebhookRequest builds a application/json POST request for the webhookBodyRootOneOf webhook +func NewWebhookBodyRootOneOfWebhookRequest(targetURL string, body WebhookBodyRootOneOfJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewWebhookBodyRootOneOfWebhookRequestWithBody(targetURL, "application/json", bodyReader) +} + +// NewWebhookBodyRootOneOfWebhookRequestWithBody builds a POST request for the webhookBodyRootOneOf webhook with any body +func NewWebhookBodyRootOneOfWebhookRequestWithBody(targetURL string, contentType string, body io.Reader) (*http.Request, error) { + var err error + _ = err + + reqURL, err := url.Parse(targetURL) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, reqURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// CallbackInitiator sends OpenAPI callback requests to target URLs. +// Modeled on the generated Client, but with no stored Server -- the full +// target URL is provided per-call by the caller (typically discovered +// from a callback expression on the parent operation's request body, +// e.g. `{$request.body#/callbackUrl}`). +type CallbackInitiator struct { + // Doer for performing requests, typically a *http.Client with any + // customized settings, such as certificate chains. + Client HttpRequestDoer + + // A list of callbacks for modifying requests which are generated before sending over + // the network. + RequestEditors []RequestEditorFn +} + +// CallbackInitiatorOption allows setting custom parameters during construction. +type CallbackInitiatorOption func(*CallbackInitiator) error + +// NewCallbackInitiator creates a new CallbackInitiator with reasonable defaults. +func NewCallbackInitiator(opts ...CallbackInitiatorOption) (*CallbackInitiator, error) { + initiator := CallbackInitiator{} + for _, o := range opts { + if err := o(&initiator); err != nil { + return nil, err + } + } + if initiator.Client == nil { + initiator.Client = &http.Client{} + } + return &initiator, nil +} + +// WithCallbackHTTPClient allows overriding the default Doer, which is +// automatically created using http.Client. This is useful for tests. +func WithCallbackHTTPClient(doer HttpRequestDoer) CallbackInitiatorOption { + return func(p *CallbackInitiator) error { + p.Client = doer + return nil + } +} + +// WithCallbackRequestEditorFn allows setting up a callback function, which +// will be called right before sending the callback request. This can be +// used to mutate the request, e.g. to add signature headers. +func WithCallbackRequestEditorFn(fn RequestEditorFn) CallbackInitiatorOption { + return func(p *CallbackInitiator) error { + p.RequestEditors = append(p.RequestEditors, fn) + return nil + } +} + +func (p *CallbackInitiator) applyCallbackEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error { + for _, r := range p.RequestEditors { + if err := r(ctx, req); err != nil { + return err + } + } + for _, r := range additionalEditors { + if err := r(ctx, req); err != nil { + return err + } + } + return nil +} + +// CallbackInitiatorInterface is the interface specification for the callback initiator. +type CallbackInitiatorInterface interface { + // CallbackBodyPropertyOneOfWithBody fires the callbackBodyPropertyOneOf callback with any body + CallbackBodyPropertyOneOfWithBody(ctx context.Context, targetURL string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + CallbackBodyPropertyOneOf(ctx context.Context, targetURL string, body CallbackBodyPropertyOneOfJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + + // CallbackBodyRootOneOfWithBody fires the callbackBodyRootOneOf callback with any body + CallbackBodyRootOneOfWithBody(ctx context.Context, targetURL string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + CallbackBodyRootOneOf(ctx context.Context, targetURL string, body CallbackBodyRootOneOfJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) +} + +func (p *CallbackInitiator) CallbackBodyPropertyOneOfWithBody(ctx context.Context, targetURL string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewCallbackBodyPropertyOneOfCallbackRequestWithBody(targetURL, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := p.applyCallbackEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return p.Client.Do(req) +} + +func (p *CallbackInitiator) CallbackBodyPropertyOneOf(ctx context.Context, targetURL string, body CallbackBodyPropertyOneOfJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewCallbackBodyPropertyOneOfCallbackRequest(targetURL, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := p.applyCallbackEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return p.Client.Do(req) +} + +func (p *CallbackInitiator) CallbackBodyRootOneOfWithBody(ctx context.Context, targetURL string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewCallbackBodyRootOneOfCallbackRequestWithBody(targetURL, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := p.applyCallbackEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return p.Client.Do(req) +} + +func (p *CallbackInitiator) CallbackBodyRootOneOf(ctx context.Context, targetURL string, body CallbackBodyRootOneOfJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewCallbackBodyRootOneOfCallbackRequest(targetURL, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := p.applyCallbackEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return p.Client.Do(req) +} + +// NewCallbackBodyPropertyOneOfCallbackRequest builds a application/json POST request for the callbackBodyPropertyOneOf callback +func NewCallbackBodyPropertyOneOfCallbackRequest(targetURL string, body CallbackBodyPropertyOneOfJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewCallbackBodyPropertyOneOfCallbackRequestWithBody(targetURL, "application/json", bodyReader) +} + +// NewCallbackBodyPropertyOneOfCallbackRequestWithBody builds a POST request for the callbackBodyPropertyOneOf callback with any body +func NewCallbackBodyPropertyOneOfCallbackRequestWithBody(targetURL string, contentType string, body io.Reader) (*http.Request, error) { + var err error + _ = err + + reqURL, err := url.Parse(targetURL) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, reqURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// NewCallbackBodyRootOneOfCallbackRequest builds a application/json POST request for the callbackBodyRootOneOf callback +func NewCallbackBodyRootOneOfCallbackRequest(targetURL string, body CallbackBodyRootOneOfJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewCallbackBodyRootOneOfCallbackRequestWithBody(targetURL, "application/json", bodyReader) +} + +// NewCallbackBodyRootOneOfCallbackRequestWithBody builds a POST request for the callbackBodyRootOneOf callback with any body +func NewCallbackBodyRootOneOfCallbackRequestWithBody(targetURL string, contentType string, body io.Reader) (*http.Request, error) { + var err error + _ = err + + reqURL, err := url.Parse(targetURL) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, reqURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} diff --git a/internal/test/anonymous_inner_hoisting/implicit/client_test.go b/internal/test/anonymous_inner_hoisting/implicit/client_test.go index 9090a5880a..52d5eb1ad7 100644 --- a/internal/test/anonymous_inner_hoisting/implicit/client_test.go +++ b/internal/test/anonymous_inner_hoisting/implicit/client_test.go @@ -121,6 +121,74 @@ func TestBodyPropertyOneOf_RoundTripDog(t *testing.T) { require.Equal(t, dog, got) } +func TestWebhookBodyRootOneOf_RoundTripCat(t *testing.T) { + cat := Cat{Kind: CatKindCat, Name: ptr("whiskers")} + + var body WebhookBodyRootOneOfJSONBody + require.NoError(t, body.FromCat(cat)) + + b, err := json.Marshal(body) + require.NoError(t, err) + + var decoded WebhookBodyRootOneOfJSONBody + require.NoError(t, json.Unmarshal(b, &decoded)) + + got, err := decoded.AsCat() + require.NoError(t, err) + require.Equal(t, cat, got) +} + +func TestWebhookBodyPropertyOneOf_RoundTripDog(t *testing.T) { + dog := Dog{Kind: DogKindDog, Name: ptr("rex")} + + var pet WebhookBodyPropertyOneOfJSONBody_Pet + require.NoError(t, pet.FromDog(dog)) + + b, err := json.Marshal(pet) + require.NoError(t, err) + + var decoded WebhookBodyPropertyOneOfJSONBody_Pet + require.NoError(t, json.Unmarshal(b, &decoded)) + + got, err := decoded.AsDog() + require.NoError(t, err) + require.Equal(t, dog, got) +} + +func TestCallbackBodyRootOneOf_RoundTripCat(t *testing.T) { + cat := Cat{Kind: CatKindCat, Name: ptr("whiskers")} + + var body CallbackBodyRootOneOfJSONBody + require.NoError(t, body.FromCat(cat)) + + b, err := json.Marshal(body) + require.NoError(t, err) + + var decoded CallbackBodyRootOneOfJSONBody + require.NoError(t, json.Unmarshal(b, &decoded)) + + got, err := decoded.AsCat() + require.NoError(t, err) + require.Equal(t, cat, got) +} + +func TestCallbackBodyPropertyOneOf_RoundTripDog(t *testing.T) { + dog := Dog{Kind: DogKindDog, Name: ptr("rex")} + + var pet CallbackBodyPropertyOneOfJSONBody_Pet + require.NoError(t, pet.FromDog(dog)) + + b, err := json.Marshal(pet) + require.NoError(t, err) + + var decoded CallbackBodyPropertyOneOfJSONBody_Pet + require.NoError(t, json.Unmarshal(b, &decoded)) + + got, err := decoded.AsDog() + require.NoError(t, err) + require.Equal(t, dog, got) +} + func TestMergeOverwritesPriorBranch(t *testing.T) { cat := Cat{Kind: CatKindCat, Name: ptr("whiskers")} dog := Dog{Kind: DogKindDog, Name: ptr("rex")} diff --git a/internal/test/anonymous_inner_hoisting/implicit/spec.yaml b/internal/test/anonymous_inner_hoisting/implicit/spec.yaml index a6febff103..4590cb21e4 100644 --- a/internal/test/anonymous_inner_hoisting/implicit/spec.yaml +++ b/internal/test/anonymous_inner_hoisting/implicit/spec.yaml @@ -1,4 +1,4 @@ -openapi: 3.0.3 +openapi: 3.1.0 info: version: 1.0.0 title: Anonymous Inner Hoisting @@ -6,6 +6,10 @@ info: Exercises inline oneOf / anyOf schemas at every operation root and at nested positions. Each generated wrapper type should receive As() / From() / Merge() accessor methods. + + Coverage spans regular paths, OpenAPI 3.1 webhooks, and callbacks + so the hoisting pipeline is verified for all three operation + sources. paths: /response-root-oneof: @@ -106,6 +110,93 @@ paths: '200': description: ok + /callback-trigger: + post: + operationId: triggerCallback + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - callbackUrl + properties: + callbackUrl: + type: string + format: uri + responses: + '202': + description: accepted + callbacks: + callbackBodyRootOneOf: + '{$request.body#/callbackUrl}': + post: + operationId: callbackBodyRootOneOf + requestBody: + required: true + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/Cat' + - $ref: '#/components/schemas/Dog' + responses: + '204': + description: ack + callbackBodyPropertyOneOf: + '{$request.body#/callbackUrl}': + post: + operationId: callbackBodyPropertyOneOf + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + pet: + oneOf: + - $ref: '#/components/schemas/Cat' + - $ref: '#/components/schemas/Dog' + responses: + '204': + description: ack + +webhooks: + webhookBodyRootOneOf: + post: + operationId: webhookBodyRootOneOf + requestBody: + required: true + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/Cat' + - $ref: '#/components/schemas/Dog' + responses: + '204': + description: ack + + webhookBodyPropertyOneOf: + post: + operationId: webhookBodyPropertyOneOf + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + pet: + oneOf: + - $ref: '#/components/schemas/Cat' + - $ref: '#/components/schemas/Dog' + responses: + '204': + description: ack + components: schemas: Cat: diff --git a/internal/test/callbacks/callbacks.gen.go b/internal/test/callbacks/callbacks.gen.go new file mode 100644 index 0000000000..7b1c0715a4 --- /dev/null +++ b/internal/test/callbacks/callbacks.gen.go @@ -0,0 +1,651 @@ +//go:build go1.22 + +// Package callbacks provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.0.0-00010101000000-000000000000 DO NOT EDIT. +package callbacks + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" +) + +// TreePlantingRequest defines model for TreePlantingRequest. +type TreePlantingRequest struct { + CallbackUrl string `json:"callbackUrl"` + Kind string `json:"kind"` +} + +// TreePlantingResult defines model for TreePlantingResult. +type TreePlantingResult struct { + Id string `json:"id"` + Success bool `json:"success"` +} + +// PlantTreeJSONRequestBody defines body for PlantTree for application/json ContentType. +type PlantTreeJSONRequestBody = TreePlantingRequest + +// TreePlantedJSONRequestBody defines body for TreePlanted for application/json ContentType. +type TreePlantedJSONRequestBody = TreePlantingResult + +// RequestEditorFn is the function signature for the RequestEditor callback function +type RequestEditorFn func(ctx context.Context, req *http.Request) error + +// Doer performs HTTP requests. +// +// The standard http.Client implements this interface. +type HttpRequestDoer interface { + Do(req *http.Request) (*http.Response, error) +} + +// Client which conforms to the OpenAPI3 specification for this service. +type Client struct { + // The endpoint of the server conforming to this interface, with scheme, + // https://api.deepmap.com for example. This can contain a path relative + // to the server, such as https://api.deepmap.com/dev-test, and all the + // paths in the swagger spec will be appended to the server. + Server string + + // Doer for performing requests, typically a *http.Client with any + // customized settings, such as certificate chains. + Client HttpRequestDoer + + // A list of callbacks for modifying requests which are generated before sending over + // the network. + RequestEditors []RequestEditorFn +} + +// ClientOption allows setting custom parameters during construction +type ClientOption func(*Client) error + +// Creates a new Client, with reasonable defaults +func NewClient(server string, opts ...ClientOption) (*Client, error) { + // create a client with sane default values + client := Client{ + Server: server, + } + // mutate client and add all optional params + for _, o := range opts { + if err := o(&client); err != nil { + return nil, err + } + } + // ensure the server URL always has a trailing slash + if !strings.HasSuffix(client.Server, "/") { + client.Server += "/" + } + // create httpClient, if not already present + if client.Client == nil { + client.Client = &http.Client{} + } + return &client, nil +} + +// WithHTTPClient allows overriding the default Doer, which is +// automatically created using http.Client. This is useful for tests. +func WithHTTPClient(doer HttpRequestDoer) ClientOption { + return func(c *Client) error { + c.Client = doer + return nil + } +} + +// WithRequestEditorFn allows setting up a callback function, which will be +// called right before sending the request. This can be used to mutate the request. +func WithRequestEditorFn(fn RequestEditorFn) ClientOption { + return func(c *Client) error { + c.RequestEditors = append(c.RequestEditors, fn) + return nil + } +} + +// The interface specification for the client above. +type ClientInterface interface { + + // PlantTreeWithBody Request a tree planting (async; result delivered via callback). + // + // Takes any type of body and a specified content type. + // + // Corresponds with POST /api/plant_tree (the `PlantTree` operationId). + PlantTreeWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + // PlantTree Request a tree planting (async; result delivered via callback). + // + // Takes a body of the `application/json` content type. + // + // Corresponds with POST /api/plant_tree (the `PlantTree` operationId). + PlantTree(ctx context.Context, body PlantTreeJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) +} + +// PlantTreeWithBody Request a tree planting (async; result delivered via callback). +// +// Takes any type of body and a specified content type. +// +// Corresponds with POST /api/plant_tree (the `PlantTree` operationId). +func (c *Client) PlantTreeWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPlantTreeRequestWithBody(c.Server, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +// PlantTree Request a tree planting (async; result delivered via callback). +// +// Takes a body of the `application/json` content type. +// +// Corresponds with POST /api/plant_tree (the `PlantTree` operationId). +func (c *Client) PlantTree(ctx context.Context, body PlantTreeJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPlantTreeRequest(c.Server, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +// NewPlantTreeRequest calls the generic PlantTree builder with application/json body +func NewPlantTreeRequest(server string, body PlantTreeJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewPlantTreeRequestWithBody(server, "application/json", bodyReader) +} + +// NewPlantTreeRequestWithBody constructs an http.Request for the PlantTree method, with any body, and a specified content type +func NewPlantTreeRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/plant_tree") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +func (c *Client) applyEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error { + for _, r := range c.RequestEditors { + if err := r(ctx, req); err != nil { + return err + } + } + for _, r := range additionalEditors { + if err := r(ctx, req); err != nil { + return err + } + } + return nil +} + +// ClientWithResponses builds on ClientInterface to offer response payloads +type ClientWithResponses struct { + ClientInterface +} + +// NewClientWithResponses creates a new ClientWithResponses, which wraps +// Client with return type handling +func NewClientWithResponses(server string, opts ...ClientOption) (*ClientWithResponses, error) { + client, err := NewClient(server, opts...) + if err != nil { + return nil, err + } + return &ClientWithResponses{client}, nil +} + +// WithBaseURL overrides the baseURL. +func WithBaseURL(baseURL string) ClientOption { + return func(c *Client) error { + newBaseURL, err := url.Parse(baseURL) + if err != nil { + return err + } + c.Server = newBaseURL.String() + return nil + } +} + +// ClientWithResponsesInterface is the interface specification for the client with responses above. +type ClientWithResponsesInterface interface { + + // PlantTreeWithBodyWithResponse Request a tree planting (async; result delivered via callback). + // + // Takes any type of body and a specified content type, and returns a wrapper object for the known response body format(s). + // + // Corresponds with POST /api/plant_tree (the `PlantTree` operationId). + PlantTreeWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PlantTreeResponse, error) + + // PlantTreeWithResponse Request a tree planting (async; result delivered via callback). + // + // Takes a body of the `application/json` content type, and returns a wrapper object for the known response body format(s). + // + // Corresponds with POST /api/plant_tree (the `PlantTree` operationId). + PlantTreeWithResponse(ctx context.Context, body PlantTreeJSONRequestBody, reqEditors ...RequestEditorFn) (*PlantTreeResponse, error) +} + +type PlantTreeResponse struct { + Body []byte + HTTPResponse *http.Response +} + +// GetBody returns the raw response body bytes +func (r PlantTreeResponse) GetBody() []byte { + return r.Body +} + +// Status returns HTTPResponse.Status +func (r PlantTreeResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r PlantTreeResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r PlantTreeResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +// PlantTreeWithBodyWithResponse Request a tree planting (async; result delivered via callback). +// +// Takes any type of body and a specified content type, and returns a wrapper object for the known response body format(s). +// +// Corresponds with POST /api/plant_tree (the `PlantTree` operationId). +func (c *ClientWithResponses) PlantTreeWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PlantTreeResponse, error) { + rsp, err := c.PlantTreeWithBody(ctx, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParsePlantTreeResponse(rsp) +} + +// PlantTreeWithResponse Request a tree planting (async; result delivered via callback). +// +// Takes a body of the `application/json` content type, and returns a wrapper object for the known response body format(s). +// +// Corresponds with POST /api/plant_tree (the `PlantTree` operationId). +func (c *ClientWithResponses) PlantTreeWithResponse(ctx context.Context, body PlantTreeJSONRequestBody, reqEditors ...RequestEditorFn) (*PlantTreeResponse, error) { + rsp, err := c.PlantTree(ctx, body, reqEditors...) + if err != nil { + return nil, err + } + return ParsePlantTreeResponse(rsp) +} + +// ParsePlantTreeResponse parses an HTTP response from a PlantTreeWithResponse call +func ParsePlantTreeResponse(rsp *http.Response) (*PlantTreeResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &PlantTreeResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + return response, nil +} + +// CallbackInitiator sends OpenAPI callback requests to target URLs. +// Modeled on the generated Client, but with no stored Server -- the full +// target URL is provided per-call by the caller (typically discovered +// from a callback expression on the parent operation's request body, +// e.g. `{$request.body#/callbackUrl}`). +type CallbackInitiator struct { + // Doer for performing requests, typically a *http.Client with any + // customized settings, such as certificate chains. + Client HttpRequestDoer + + // A list of callbacks for modifying requests which are generated before sending over + // the network. + RequestEditors []RequestEditorFn +} + +// CallbackInitiatorOption allows setting custom parameters during construction. +type CallbackInitiatorOption func(*CallbackInitiator) error + +// NewCallbackInitiator creates a new CallbackInitiator with reasonable defaults. +func NewCallbackInitiator(opts ...CallbackInitiatorOption) (*CallbackInitiator, error) { + initiator := CallbackInitiator{} + for _, o := range opts { + if err := o(&initiator); err != nil { + return nil, err + } + } + if initiator.Client == nil { + initiator.Client = &http.Client{} + } + return &initiator, nil +} + +// WithCallbackHTTPClient allows overriding the default Doer, which is +// automatically created using http.Client. This is useful for tests. +func WithCallbackHTTPClient(doer HttpRequestDoer) CallbackInitiatorOption { + return func(p *CallbackInitiator) error { + p.Client = doer + return nil + } +} + +// WithCallbackRequestEditorFn allows setting up a callback function, which +// will be called right before sending the callback request. This can be +// used to mutate the request, e.g. to add signature headers. +func WithCallbackRequestEditorFn(fn RequestEditorFn) CallbackInitiatorOption { + return func(p *CallbackInitiator) error { + p.RequestEditors = append(p.RequestEditors, fn) + return nil + } +} + +func (p *CallbackInitiator) applyCallbackEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error { + for _, r := range p.RequestEditors { + if err := r(ctx, req); err != nil { + return err + } + } + for _, r := range additionalEditors { + if err := r(ctx, req); err != nil { + return err + } + } + return nil +} + +// CallbackInitiatorInterface is the interface specification for the callback initiator. +type CallbackInitiatorInterface interface { + // TreePlantedWithBody fires the treePlanted callback with any body + TreePlantedWithBody(ctx context.Context, targetURL string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + TreePlanted(ctx context.Context, targetURL string, body TreePlantedJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) +} + +func (p *CallbackInitiator) TreePlantedWithBody(ctx context.Context, targetURL string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewTreePlantedCallbackRequestWithBody(targetURL, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := p.applyCallbackEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return p.Client.Do(req) +} + +func (p *CallbackInitiator) TreePlanted(ctx context.Context, targetURL string, body TreePlantedJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewTreePlantedCallbackRequest(targetURL, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := p.applyCallbackEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return p.Client.Do(req) +} + +// NewTreePlantedCallbackRequest builds a application/json POST request for the treePlanted callback +func NewTreePlantedCallbackRequest(targetURL string, body TreePlantedJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewTreePlantedCallbackRequestWithBody(targetURL, "application/json", bodyReader) +} + +// NewTreePlantedCallbackRequestWithBody builds a POST request for the treePlanted callback with any body +func NewTreePlantedCallbackRequestWithBody(targetURL string, contentType string, body io.Reader) (*http.Request, error) { + var err error + _ = err + + reqURL, err := url.Parse(targetURL) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, reqURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// ServerInterface represents all server handlers. +type ServerInterface interface { + // PlantTree Request a tree planting (async; result delivered via callback). + // (POST /api/plant_tree) + PlantTree(w http.ResponseWriter, r *http.Request) +} + +// ServerInterfaceWrapper converts contexts to parameters. +type ServerInterfaceWrapper struct { + Handler ServerInterface + HandlerMiddlewares []MiddlewareFunc + ErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error) +} + +type MiddlewareFunc func(http.Handler) http.Handler + +// PlantTree operation middleware +func (siw *ServerInterfaceWrapper) PlantTree(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.PlantTree(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +type UnescapedCookieParamError struct { + ParamName string + Err error +} + +func (e *UnescapedCookieParamError) Error() string { + return fmt.Sprintf("error unescaping cookie parameter '%s'", e.ParamName) +} + +func (e *UnescapedCookieParamError) Unwrap() error { + return e.Err +} + +type UnmarshalingParamError struct { + ParamName string + Err error +} + +func (e *UnmarshalingParamError) Error() string { + return fmt.Sprintf("Error unmarshaling parameter %s as JSON: %s", e.ParamName, e.Err.Error()) +} + +func (e *UnmarshalingParamError) Unwrap() error { + return e.Err +} + +type RequiredParamError struct { + ParamName string +} + +func (e *RequiredParamError) Error() string { + return fmt.Sprintf("Query argument %s is required, but not found", e.ParamName) +} + +type RequiredHeaderError struct { + ParamName string + Err error +} + +func (e *RequiredHeaderError) Error() string { + return fmt.Sprintf("Header parameter %s is required, but not found", e.ParamName) +} + +func (e *RequiredHeaderError) Unwrap() error { + return e.Err +} + +type InvalidParamFormatError struct { + ParamName string + Err error +} + +func (e *InvalidParamFormatError) Error() string { + return fmt.Sprintf("Invalid format for parameter %s: %s", e.ParamName, e.Err.Error()) +} + +func (e *InvalidParamFormatError) Unwrap() error { + return e.Err +} + +type TooManyValuesForParamError struct { + ParamName string + Count int +} + +func (e *TooManyValuesForParamError) Error() string { + return fmt.Sprintf("Expected one value for %s, got %d", e.ParamName, e.Count) +} + +// Handler creates http.Handler with routing matching OpenAPI spec. +func Handler(si ServerInterface) http.Handler { + return HandlerWithOptions(si, StdHTTPServerOptions{}) +} + +// ServeMux is an abstraction of [http.ServeMux]. +type ServeMux interface { + HandleFunc(pattern string, handler func(http.ResponseWriter, *http.Request)) + http.Handler +} + +type StdHTTPServerOptions struct { + BaseURL string + BaseRouter ServeMux + Middlewares []MiddlewareFunc + ErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error) +} + +// HandlerFromMux creates http.Handler with routing matching OpenAPI spec based on the provided mux. +func HandlerFromMux(si ServerInterface, m ServeMux) http.Handler { + return HandlerWithOptions(si, StdHTTPServerOptions{ + BaseRouter: m, + }) +} + +func HandlerFromMuxWithBaseURL(si ServerInterface, m ServeMux, baseURL string) http.Handler { + return HandlerWithOptions(si, StdHTTPServerOptions{ + BaseURL: baseURL, + BaseRouter: m, + }) +} + +// HandlerWithOptions creates http.Handler with additional options +func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.Handler { + m := options.BaseRouter + + if m == nil { + m = http.NewServeMux() + } + if options.ErrorHandlerFunc == nil { + options.ErrorHandlerFunc = func(w http.ResponseWriter, r *http.Request, err error) { + http.Error(w, err.Error(), http.StatusBadRequest) + } + } + + wrapper := ServerInterfaceWrapper{ + Handler: si, + HandlerMiddlewares: options.Middlewares, + ErrorHandlerFunc: options.ErrorHandlerFunc, + } + + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api/plant_tree", wrapper.PlantTree) + + return m +} + +// CallbackReceiverInterface represents handlers for receiving inbound +// callback requests. Each callback becomes a Handle*Callback +// method that the implementation fills in. The caller mounts the per- +// callback http.Handler returned by {Op}CallbackHandler at +// whatever URL path they advertise to senders. +type CallbackReceiverInterface interface { + // Tree planting result notification. + // HandleTreePlantedCallback handles the POST callback for treePlanted. + HandleTreePlantedCallback(w http.ResponseWriter, r *http.Request) +} + +// CallbackReceiverMiddlewareFunc wraps an http.Handler with cross- +// cutting behavior (signature verification, logging, rate limiting, ...). +type CallbackReceiverMiddlewareFunc func(http.Handler) http.Handler + +// TreePlantedCallbackHandler returns the http.Handler for the treePlanted callback. +// Mount this at the URL path advertised to callback senders. errHandler +// may be nil; if so, parameter-binding errors return 400 with the error +// message. Middlewares are applied in the order provided -- the last +// argument becomes the outermost wrapper. +func TreePlantedCallbackHandler(si CallbackReceiverInterface, errHandler func(w http.ResponseWriter, r *http.Request, err error), middlewares ...CallbackReceiverMiddlewareFunc) http.Handler { + if errHandler == nil { + errHandler = func(w http.ResponseWriter, r *http.Request, err error) { + http.Error(w, err.Error(), http.StatusBadRequest) + } + } + var h http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + si.HandleTreePlantedCallback(w, r) + }) + for _, mw := range middlewares { + h = mw(h) + } + return h +} diff --git a/internal/test/callbacks/callbacks_test.go b/internal/test/callbacks/callbacks_test.go new file mode 100644 index 0000000000..4f3d8fe423 --- /dev/null +++ b/internal/test/callbacks/callbacks_test.go @@ -0,0 +1,106 @@ +// Package callbacks tests OpenAPI callback codegen end-to-end. Spec is +// OpenAPI 3.0 to verify callbacks aren't accidentally gated on 3.1. +package callbacks + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type fakeReceiver struct { + gotMethod string + gotContentType string + gotResult TreePlantingResult + called bool +} + +func (f *fakeReceiver) HandleTreePlantedCallback(w http.ResponseWriter, r *http.Request) { + f.called = true + f.gotMethod = r.Method + f.gotContentType = r.Header.Get("Content-Type") + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + defer func() { _ = r.Body.Close() }() + if err := json.Unmarshal(body, &f.gotResult); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func TestCallbackRoundTrip(t *testing.T) { + receiver := &fakeReceiver{} + + mux := http.NewServeMux() + mux.Handle("POST /tree-planted", TreePlantedCallbackHandler(receiver, nil)) + srv := httptest.NewServer(mux) + defer srv.Close() + + initiator, err := NewCallbackInitiator() + require.NoError(t, err) + + result := TreePlantingResult{ + Id: "tree-42", + Success: true, + } + + resp, err := initiator.TreePlanted(context.Background(), srv.URL+"/tree-planted", result) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + + require.Equal(t, http.StatusNoContent, resp.StatusCode) + require.True(t, receiver.called, "callback handler should have been called") + assert.Equal(t, "POST", receiver.gotMethod) + assert.Equal(t, "application/json", receiver.gotContentType) + assert.Equal(t, result, receiver.gotResult) +} + +// TestCallbackInitiatorRequestEditor verifies WithCallbackRequestEditorFn +// composes onto every outgoing request, mirroring the path Client and +// the WebhookInitiator behavior. +func TestCallbackInitiatorRequestEditor(t *testing.T) { + const sigHeader = "X-Callback-Signature" + const sigValue = "t=1,v1=abc" + + receiver := &capturingReceiver{} + mux := http.NewServeMux() + mux.Handle("POST /cb", TreePlantedCallbackHandler(receiver, nil)) + srv := httptest.NewServer(mux) + defer srv.Close() + + initiator, err := NewCallbackInitiator( + WithCallbackRequestEditorFn(func(ctx context.Context, req *http.Request) error { + req.Header.Set(sigHeader, sigValue) + return nil + }), + ) + require.NoError(t, err) + + resp, err := initiator.TreePlanted(context.Background(), srv.URL+"/cb", TreePlantingResult{ + Id: "x", + Success: false, + }) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + + assert.Equal(t, sigValue, receiver.gotHeaders.Get(sigHeader)) +} + +type capturingReceiver struct { + gotHeaders http.Header +} + +func (c *capturingReceiver) HandleTreePlantedCallback(w http.ResponseWriter, r *http.Request) { + c.gotHeaders = r.Header.Clone() + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/test/callbacks/config.yaml b/internal/test/callbacks/config.yaml new file mode 100644 index 0000000000..83379f94ca --- /dev/null +++ b/internal/test/callbacks/config.yaml @@ -0,0 +1,9 @@ +# yaml-language-server: $schema=../../../configuration-schema.json +package: callbacks +generate: + models: true + client: true + std-http-server: true +output-options: + skip-prune: true +output: callbacks.gen.go diff --git a/internal/test/callbacks/doc.go b/internal/test/callbacks/doc.go new file mode 100644 index 0000000000..c336fdde75 --- /dev/null +++ b/internal/test/callbacks/doc.go @@ -0,0 +1,8 @@ +// Package callbacks verifies OpenAPI callback codegen end-to-end. The +// spec is OpenAPI 3.0 to ensure callback support is NOT gated on 3.1 +// (callbacks have been part of the spec since 3.0). The CallbackInitiator +// fires a callback against an httptest server that registers the +// CallbackReceiverInterface handler. +package callbacks + +//go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen --config=config.yaml spec.yaml diff --git a/internal/test/callbacks/spec.yaml b/internal/test/callbacks/spec.yaml new file mode 100644 index 0000000000..918b2d98fb --- /dev/null +++ b/internal/test/callbacks/spec.yaml @@ -0,0 +1,57 @@ +# Callbacks have been part of the OpenAPI spec since 3.0, so this +# fixture deliberately uses 3.0.3 to verify the codegen does NOT gate +# callback emission on OpenAPI 3.1. +openapi: 3.0.3 +info: + title: Callbacks test + version: 1.0.0 +paths: + /api/plant_tree: + post: + operationId: PlantTree + summary: Request a tree planting (async; result delivered via callback). + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TreePlantingRequest' + responses: + '202': + description: Accepted + callbacks: + treePlanted: + '{$request.body#/callbackUrl}': + post: + operationId: TreePlanted + summary: Tree planting result notification. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TreePlantingResult' + responses: + '204': + description: Acknowledged + +components: + schemas: + TreePlantingRequest: + type: object + required: [kind, callbackUrl] + properties: + kind: + type: string + callbackUrl: + type: string + format: uri + + TreePlantingResult: + type: object + required: [id, success] + properties: + id: + type: string + success: + type: boolean diff --git a/internal/test/enum_via_oneof/config.yaml b/internal/test/enum_via_oneof/config.yaml new file mode 100644 index 0000000000..d2840693ff --- /dev/null +++ b/internal/test/enum_via_oneof/config.yaml @@ -0,0 +1,7 @@ +# yaml-language-server: $schema=../../../configuration-schema.json +package: enum_via_oneof +generate: + models: true +output-options: + skip-prune: true +output: enum_via_oneof.gen.go diff --git a/internal/test/enum_via_oneof/doc.go b/internal/test/enum_via_oneof/doc.go new file mode 100644 index 0000000000..b3f96d9d90 --- /dev/null +++ b/internal/test/enum_via_oneof/doc.go @@ -0,0 +1,7 @@ +// Package enum_via_oneof verifies the OpenAPI 3.1 enum-via-oneOf idiom: +// a scalar schema whose oneOf branches each carry `title` + `const` +// renders as a Go typed enum, while a near-miss schema (one branch +// missing `title`) falls through to the standard union generator. +package enum_via_oneof + +//go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen --config=config.yaml spec.yaml diff --git a/internal/test/enum_via_oneof/enum_via_oneof.gen.go b/internal/test/enum_via_oneof/enum_via_oneof.gen.go new file mode 100644 index 0000000000..836733bf24 --- /dev/null +++ b/internal/test/enum_via_oneof/enum_via_oneof.gen.go @@ -0,0 +1,55 @@ +// Package enum_via_oneof provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.0.0-00010101000000-000000000000 DO NOT EDIT. +package enum_via_oneof + +// Defines values for Color. +const ( + Blue Color = "b" + Green Color = "g" + Red Color = "r" +) + +// Valid indicates whether the value is a known member of the Color enum. +func (e Color) Valid() bool { + switch e { + case Blue: + return true + case Green: + return true + case Red: + return true + default: + return false + } +} + +// Defines values for Severity. +const ( + HIGH Severity = 2 + LOW Severity = 0 + MEDIUM Severity = 1 +) + +// Valid indicates whether the value is a known member of the Severity enum. +func (e Severity) Valid() bool { + switch e { + case HIGH: + return true + case LOW: + return true + case MEDIUM: + return true + default: + return false + } +} + +// Color defines model for Color. +type Color string + +// MixedOneOf defines model for MixedOneOf. +type MixedOneOf = string + +// Severity How urgent a problem is. +type Severity int diff --git a/internal/test/enum_via_oneof/enum_via_oneof_test.go b/internal/test/enum_via_oneof/enum_via_oneof_test.go new file mode 100644 index 0000000000..514bd83d1a --- /dev/null +++ b/internal/test/enum_via_oneof/enum_via_oneof_test.go @@ -0,0 +1,74 @@ +// Package enum_via_oneof tests the OpenAPI 3.1 enum-via-oneOf idiom: a +// scalar schema whose oneOf branches each carry `title` + `const` is +// emitted as a Go typed enum with named constants. +// +// The tests are structural -- they instantiate the generated types and +// assert constant values and JSON round-trips -- rather than string- +// matching the generated source. +package enum_via_oneof + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestSeverityConstants verifies that an integer enum-via-oneOf produces +// `type Severity int` with the right per-branch constant values. +func TestSeverityConstants(t *testing.T) { + assert.Equal(t, 2, int(HIGH)) + assert.Equal(t, 1, int(MEDIUM)) + assert.Equal(t, 0, int(LOW)) +} + +// TestSeverityJSONRoundTrip confirms Severity marshals as its integer +// value, not as a wrapped union or as a string. +func TestSeverityJSONRoundTrip(t *testing.T) { + data, err := json.Marshal(HIGH) + require.NoError(t, err) + assert.JSONEq(t, `2`, string(data)) + + var got Severity + require.NoError(t, json.Unmarshal([]byte(`1`), &got)) + assert.Equal(t, MEDIUM, got) +} + +// TestColorConstants verifies that a string enum-via-oneOf produces +// `type Color string` with the right per-branch constant values. +func TestColorConstants(t *testing.T) { + assert.Equal(t, "r", string(Red)) + assert.Equal(t, "g", string(Green)) + assert.Equal(t, "b", string(Blue)) +} + +// TestColorJSONRoundTrip confirms Color marshals as its string value. +func TestColorJSONRoundTrip(t *testing.T) { + data, err := json.Marshal(Red) + require.NoError(t, err) + assert.JSONEq(t, `"r"`, string(data)) + + var got Color + require.NoError(t, json.Unmarshal([]byte(`"b"`), &got)) + assert.Equal(t, Blue, got) +} + +// TestMixedOneOfFallsThrough verifies the negative path: a oneOf where +// any branch lacks `title` must NOT trigger enum-via-oneOf detection. +// MixedOneOf is emitted by the standard handler as `type MixedOneOf = +// string` (an alias), so a plain string is directly assignable. If +// detection were over-eager, MixedOneOf would become a newtype `type +// MixedOneOf string` and the assignment below would fail to compile +// (a string would not be directly assignable to a newtype value). +// +// The explicit `var m MixedOneOf = s` declaration is intentional: +// staticcheck (ST1023) wants the type omitted because aliasing makes +// it redundant, but that redundancy IS the test -- if we let inference +// give `m` the type `string`, the alias-vs-newtype property isn't +// exercised at compile time. +func TestMixedOneOfFallsThrough(t *testing.T) { + var s = "anything" + var m MixedOneOf = s //nolint:staticcheck // ST1023: explicit type is the compile-time alias check + assert.Equal(t, "anything", string(m)) +} diff --git a/internal/test/enum_via_oneof/spec.yaml b/internal/test/enum_via_oneof/spec.yaml new file mode 100644 index 0000000000..b7a2a1bf48 --- /dev/null +++ b/internal/test/enum_via_oneof/spec.yaml @@ -0,0 +1,44 @@ +# OpenAPI 3.1 enum-via-oneOf idiom: a schema with a scalar `type` plus +# `oneOf` branches that each carry `const` + `title` should be emitted as +# a Go typed enum with named constants (the title becomes the constant +# name, the const becomes the value). +openapi: 3.1.0 +info: + title: Enum via oneOf test + version: 1.0.0 +paths: {} +components: + schemas: + Severity: + description: How urgent a problem is. + type: integer + oneOf: + - title: HIGH + const: 2 + description: An urgent problem + - title: MEDIUM + const: 1 + - title: LOW + const: 0 + description: Can wait forever + + Color: + type: string + oneOf: + - title: Red + const: r + description: Warm, high-energy + - title: Green + const: g + - title: Blue + const: b + description: Cool, calm + + # Negative path: one branch lacks `title`, so the idiom must NOT + # match. The schema falls through to the standard union generator. + MixedOneOf: + type: string + oneOf: + - title: Alpha + const: a + - const: b diff --git a/internal/test/externalref/petstore/externalref.gen.go b/internal/test/externalref/petstore/externalref.gen.go index d70f4bec71..53a4d06187 100644 --- a/internal/test/externalref/petstore/externalref.gen.go +++ b/internal/test/externalref/petstore/externalref.gen.go @@ -87,10 +87,17 @@ func (e FindPetsByStatusParamsStatus) Valid() bool { // Address defines model for Address. type Address struct { - City *string `json:"city,omitempty"` - State *string `json:"state,omitempty"` + // City Example: Palo Alto + City *string `json:"city,omitempty"` + + // State Example: CA + State *string `json:"state,omitempty"` + + // Street Example: 437 Lytton Street *string `json:"street,omitempty"` - Zip *string `json:"zip,omitempty"` + + // Zip Example: 94301 + Zip *string `json:"zip,omitempty"` } // ApiResponse defines model for ApiResponse. @@ -102,38 +109,59 @@ type ApiResponse struct { // Category defines model for Category. type Category struct { - Id *int64 `json:"id,omitempty"` + // Id Example: 1 + Id *int64 `json:"id,omitempty"` + + // Name Example: Dogs Name *string `json:"name,omitempty"` } // Customer defines model for Customer. type Customer struct { - Address *[]Address `json:"address,omitempty"` - Id *int64 `json:"id,omitempty"` - Username *string `json:"username,omitempty"` + Address *[]Address `json:"address,omitempty"` + + // Id Example: 100000 + Id *int64 `json:"id,omitempty"` + + // Username Example: fehguy + Username *string `json:"username,omitempty"` } // Order defines model for Order. type Order struct { - Complete *bool `json:"complete,omitempty"` - Id *int64 `json:"id,omitempty"` - PetId *int64 `json:"petId,omitempty"` + Complete *bool `json:"complete,omitempty"` + + // Id Example: 10 + Id *int64 `json:"id,omitempty"` + + // PetId Example: 198772 + PetId *int64 `json:"petId,omitempty"` + + // Quantity Example: 7 Quantity *int32 `json:"quantity,omitempty"` ShipDate *time.Time `json:"shipDate,omitempty"` // Status Order Status + // + // Example: approved Status *OrderStatus `json:"status,omitempty"` } // OrderStatus Order Status +// +// Example: approved type OrderStatus string // Pet defines model for Pet. type Pet struct { - Category *Category `json:"category,omitempty"` - Id *int64 `json:"id,omitempty"` - Name string `json:"name"` - PhotoUrls []string `json:"photoUrls"` + Category *Category `json:"category,omitempty"` + + // Id Example: 10 + Id *int64 `json:"id,omitempty"` + + // Name Example: doggie + Name string `json:"name"` + PhotoUrls []string `json:"photoUrls"` // Status pet status in the store Status *PetStatus `json:"status,omitempty"` @@ -151,16 +179,31 @@ type Tag struct { // User defines model for User. type User struct { - Email *string `json:"email,omitempty"` + // Email Example: john@email.com + Email *string `json:"email,omitempty"` + + // FirstName Example: John FirstName *string `json:"firstName,omitempty"` - Id *int64 `json:"id,omitempty"` - LastName *string `json:"lastName,omitempty"` - Password *string `json:"password,omitempty"` - Phone *string `json:"phone,omitempty"` + + // Id Example: 10 + Id *int64 `json:"id,omitempty"` + + // LastName Example: James + LastName *string `json:"lastName,omitempty"` + + // Password Example: 12345 + Password *string `json:"password,omitempty"` + + // Phone Example: 12345 + Phone *string `json:"phone,omitempty"` // UserStatus User Status - UserStatus *int32 `json:"userStatus,omitempty"` - Username *string `json:"username,omitempty"` + // + // Example: 1 + UserStatus *int32 `json:"userStatus,omitempty"` + + // Username Example: theUser + Username *string `json:"username,omitempty"` } // UserArray defines model for UserArray. diff --git a/internal/test/issues/issue-1087/deps/deps.gen.go b/internal/test/issues/issue-1087/deps/deps.gen.go index 3661be9d81..5ef898b5ba 100644 --- a/internal/test/issues/issue-1087/deps/deps.gen.go +++ b/internal/test/issues/issue-1087/deps/deps.gen.go @@ -18,33 +18,51 @@ import ( // BaseError defines model for BaseError. type BaseError struct { // Code The underlying http status code + // + // Example: 500 Code int32 `json:"code"` // Domain The domain where the error is originating from as defined by the service + // + // Example: Domain string `json:"domain"` // Message A simple message in english describing the error and can be returned to the consumer + // + // Example: Age cannot be less than 18 Message string `json:"message"` // Metadata Any additional details to be conveyed as determined by the service. If present, will return map of key value pairs + // + // Example: {"propertyName":"propertyName is required"} Metadata *map[string]string `json:"metadata,omitempty"` } // Error defines model for Error. type Error struct { // Code The underlying http status code + // + // Example: 500 Code int32 `json:"code"` // Domain The domain where the error is originating from as defined by the service + // + // Example: Domain string `json:"domain"` // Message A simple message in english describing the error and can be returned to the consumer + // + // Example: Age cannot be less than 18 Message string `json:"message"` // Metadata Any additional details to be conveyed as determined by the service. If present, will return map of key value pairs + // + // Example: {"propertyName":"propertyName is required"} Metadata *map[string]string `json:"metadata,omitempty"` // Reason A reason code specific to the service and can be used to identify the exact issue. Should be unique within a domain + // + // Example: Reason_Code Reason string `json:"reason"` } diff --git a/internal/test/issues/issue-1168/api.gen.go b/internal/test/issues/issue-1168/api.gen.go index d681fbd81c..06bd31b245 100644 --- a/internal/test/issues/issue-1168/api.gen.go +++ b/internal/test/issues/issue-1168/api.gen.go @@ -11,18 +11,24 @@ import ( // ProblemDetails defines model for ProblemDetails. type ProblemDetails struct { // Detail A human readable explanation specific to this occurrence of the problem. + // + // Example: Connection to database timed out Detail *string `json:"detail,omitempty"` // Instance An absolute URI that identifies the specific occurrence of the problem. It may or may not yield further information if dereferenced. Instance *string `json:"instance,omitempty"` // Status The HTTP status code generated by the origin server for this occurrence of the problem. + // + // Example: 503 Status *int32 `json:"status,omitempty"` // Title A short, summary of the problem type. Written in english and readable for engineers (usually not suited for non technical stakeholders and not localized); example: Service Unavailable Title *string `json:"title,omitempty"` // Type An absolute URI that identifies the problem type. When dereferenced, it SHOULD provide human-readable documentation for the problem type (e.g., using HTML). + // + // Example: https://zalando.github.io/problem/constraint-violation Type *string `json:"type,omitempty"` AdditionalProperties map[string]interface{} `json:"-"` } diff --git a/internal/test/issues/issue1561/issue1561.gen.go b/internal/test/issues/issue1561/issue1561.gen.go index f011e318c1..494107b8e5 100644 --- a/internal/test/issues/issue1561/issue1561.gen.go +++ b/internal/test/issues/issue1561/issue1561.gen.go @@ -5,6 +5,7 @@ package issue1561 // Pong defines model for Pong. type Pong struct { + // Ping Example: pong Ping string `json:"ping"` } diff --git a/internal/test/openapi31_content_keywords/config.yaml b/internal/test/openapi31_content_keywords/config.yaml new file mode 100644 index 0000000000..ac933a5526 --- /dev/null +++ b/internal/test/openapi31_content_keywords/config.yaml @@ -0,0 +1,7 @@ +# yaml-language-server: $schema=../../../configuration-schema.json +package: spec +generate: + models: true +output-options: + skip-prune: true +output: spec/types.gen.go diff --git a/internal/test/openapi31_content_keywords/content_keywords_test.go b/internal/test/openapi31_content_keywords/content_keywords_test.go new file mode 100644 index 0000000000..565cf936e3 --- /dev/null +++ b/internal/test/openapi31_content_keywords/content_keywords_test.go @@ -0,0 +1,90 @@ +// Package openapi31_content_keywords exercises the codegen mapping for +// OpenAPI 3.1's `contentEncoding` and `contentMediaType` keywords. The +// assertions are structural -- assigning typed zero values into the +// generated fields only compiles if the field types are what we +// expect, so a regression that drops the synthesis would surface as a +// build failure here before any test run. +package openapi31_content_keywords + +import ( + "testing" + + openapi_types "github.com/oapi-codegen/runtime/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + spec "github.com/oapi-codegen/oapi-codegen/v2/internal/test/openapi31_content_keywords/spec" +) + +func TestContentMediaTypeMapsToFile(t *testing.T) { + // `contentMediaType: application/octet-stream` -- raw binary -- + // must produce openapi_types.File, matching 3.0 `format: binary`. + var f openapi_types.File + fields := spec.FileUploadFields{ + OrderId: 1, + RawFile: f, + } + assert.Equal(t, 1, fields.OrderId) +} + +func TestContentMediaTypeImageMapsToFile(t *testing.T) { + // A non-octet-stream contentMediaType (image/png) still routes to + // openapi_types.File: the keyword signals "this string is binary + // content," regardless of the specific media type. + var f openapi_types.File + fields := spec.FileUploadFields{ + ImageFile: &f, + } + assert.NotNil(t, fields.ImageFile) +} + +func TestContentEncodingBase64MapsToByteSlice(t *testing.T) { + // `contentEncoding: base64` (standard padded base64) is the one + // RFC4648 variant Go's encoding/json handles natively for []byte, + // so it maps to []byte -- matching 3.0 `format: byte`. + fields := spec.FileUploadFields{ + Base64Field: []byte("hello"), + } + assert.Equal(t, "hello", string(fields.Base64Field)) +} + +func TestContentEncodingBase64UrlStaysString(t *testing.T) { + // `contentEncoding: base64url` (URL-safe base64) is intentionally + // NOT mapped to []byte: Go's JSON codec for []byte uses standard + // base64 unconditionally, which would silently corrupt URL-safe + // characters (`-`/`_`) on unmarshal and re-emit them as standard + // base64 on marshal. The field stays as `string` so the declared + // wire encoding is preserved end-to-end and the user can apply + // the correct codec. Compile-time check: `*string` assignment. + v := "abc-def_ghi" + fields := spec.FileUploadFields{ + Base64UrlField: &v, + } + require.NotNil(t, fields.Base64UrlField) + assert.Equal(t, "abc-def_ghi", *fields.Base64UrlField) +} + +func TestContentMediaTypeWinsOverContentEncoding(t *testing.T) { + // When both keywords are set, the file mapping wins -- the field + // is a binary blob of a specific media type, base64-encoded for + // JSON transport. Matches the 3.0 model where `format: binary` + // dominated. + var f openapi_types.File + fields := spec.FileUploadFields{ + BothKeywords: &f, + } + assert.NotNil(t, fields.BothKeywords) +} + +func TestExplicitFormatOverridesContentMediaType(t *testing.T) { + // An explicit `format` on the schema must continue to win over + // the contentMediaType-derived synthesis. Here `format: date` + // keeps its openapi_types.Date Go type even though + // contentMediaType: application/octet-stream would otherwise have + // routed to openapi_types.File. + var d openapi_types.Date + fields := spec.FileUploadFields{ + ExplicitFormatWins: &d, + } + assert.NotNil(t, fields.ExplicitFormatWins) +} diff --git a/internal/test/openapi31_content_keywords/doc.go b/internal/test/openapi31_content_keywords/doc.go new file mode 100644 index 0000000000..9f2237993e --- /dev/null +++ b/internal/test/openapi31_content_keywords/doc.go @@ -0,0 +1,10 @@ +// Package openapi31_content_keywords verifies that the OpenAPI 3.1 +// `contentEncoding` and `contentMediaType` keywords -- the JSON-Schema- +// aligned replacements for the 3.0 `format: binary` / `format: byte` +// idioms -- generate the same Go shapes (`openapi_types.File`, `[]byte`) +// their 3.0 counterparts did. Without coverage, a user following the +// 3.1 upgrade guide's "Update file upload descriptions" step would +// silently lose their file/binary typing. +package openapi31_content_keywords + +//go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen --config=config.yaml spec.yaml diff --git a/internal/test/openapi31_content_keywords/spec.yaml b/internal/test/openapi31_content_keywords/spec.yaml new file mode 100644 index 0000000000..33b599e9dc --- /dev/null +++ b/internal/test/openapi31_content_keywords/spec.yaml @@ -0,0 +1,71 @@ +openapi: 3.1.0 +info: + title: 3.1 content keywords (file uploads) + version: 1.0.0 + description: | + Verifies that the 3.1 file-upload idioms documented at + https://learn.openapis.org/upgrading/v3.0-to-v3.1.html produce + the same Go shapes as their 3.0 `format: binary` / `format: byte` + counterparts. +paths: {} +components: + schemas: + # Property mirroring the multipart-file-upload example from the + # upgrade guide: `type: string, contentMediaType: application/ + # octet-stream`. Equivalent to 3.0 `type: string, format: binary`. + # Expected Go shape: openapi_types.File. + FileUploadFields: + type: object + required: + - orderId + - rawFile + - base64Field + properties: + orderId: + type: integer + rawFile: + type: string + contentMediaType: application/octet-stream + description: Raw binary file content. + imageFile: + type: string + contentMediaType: image/png + description: | + A non-octet-stream contentMediaType still routes to + openapi_types.File -- any contentMediaType signals + "this string is binary content of a specific type." + base64Field: + type: string + contentEncoding: base64 + description: | + Base64-encoded binary as a JSON string. Mirrors the + 3.0 `format: byte` idiom -- expected Go shape: `[]byte`. + base64UrlField: + type: string + contentEncoding: base64url + description: | + base64url is intentionally NOT mapped to []byte: Go's + encoding/json treats []byte as standard padded base64 + (RFC4648 section 4), which would silently corrupt URL-safe + characters (`-`/`_`) on unmarshal and re-emit them as + standard base64 on marshal. Expected Go shape: `string`, + so the user retains the declared wire encoding and can + apply the appropriate codec. + bothKeywords: + type: string + contentEncoding: base64 + contentMediaType: image/png + description: | + When both are set (base64-encoded binary of a specific + media type), the file mapping wins: openapi_types.File. + This matches the 3.0 behavior where `format: binary` would + dominate over any encoding hint. + explicitFormatWins: + type: string + format: date + contentMediaType: application/octet-stream + description: | + An explicit `format` always overrides the contentMediaType- + derived synthesis. Here `format: date` keeps its Go type + (openapi_types.Date) even though contentMediaType would + otherwise have routed to openapi_types.File. diff --git a/internal/test/openapi31_content_keywords/spec/types.gen.go b/internal/test/openapi31_content_keywords/spec/types.gen.go new file mode 100644 index 0000000000..f6b0e62d7c --- /dev/null +++ b/internal/test/openapi31_content_keywords/spec/types.gen.go @@ -0,0 +1,45 @@ +// Package spec provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.0.0-00010101000000-000000000000 DO NOT EDIT. +package spec + +import ( + openapi_types "github.com/oapi-codegen/runtime/types" +) + +// FileUploadFields defines model for FileUploadFields. +type FileUploadFields struct { + // Base64Field Base64-encoded binary as a JSON string. Mirrors the + // 3.0 `format: byte` idiom -- expected Go shape: `[]byte`. + Base64Field []byte `json:"base64Field"` + + // Base64UrlField base64url is intentionally NOT mapped to []byte: Go's + // encoding/json treats []byte as standard padded base64 + // (RFC4648 section 4), which would silently corrupt URL-safe + // characters (`-`/`_`) on unmarshal and re-emit them as + // standard base64 on marshal. Expected Go shape: `string`, + // so the user retains the declared wire encoding and can + // apply the appropriate codec. + Base64UrlField *string `json:"base64UrlField,omitempty"` + + // BothKeywords When both are set (base64-encoded binary of a specific + // media type), the file mapping wins: openapi_types.File. + // This matches the 3.0 behavior where `format: binary` would + // dominate over any encoding hint. + BothKeywords *openapi_types.File `json:"bothKeywords,omitempty"` + + // ExplicitFormatWins An explicit `format` always overrides the contentMediaType- + // derived synthesis. Here `format: date` keeps its Go type + // (openapi_types.Date) even though contentMediaType would + // otherwise have routed to openapi_types.File. + ExplicitFormatWins *openapi_types.Date `json:"explicitFormatWins,omitempty"` + + // ImageFile A non-octet-stream contentMediaType still routes to + // openapi_types.File -- any contentMediaType signals + // "this string is binary content of a specific type." + ImageFile *openapi_types.File `json:"imageFile,omitempty"` + OrderId int `json:"orderId"` + + // RawFile Raw binary file content. + RawFile openapi_types.File `json:"rawFile"` +} diff --git a/internal/test/openapi31_nullable/config_3_0.yaml b/internal/test/openapi31_nullable/config_3_0.yaml new file mode 100644 index 0000000000..0f2dac5130 --- /dev/null +++ b/internal/test/openapi31_nullable/config_3_0.yaml @@ -0,0 +1,7 @@ +# yaml-language-server: $schema=../../../configuration-schema.json +package: spec_3_0 +generate: + models: true +output-options: + skip-prune: true +output: spec_3_0/types.gen.go diff --git a/internal/test/openapi31_nullable/config_3_1.yaml b/internal/test/openapi31_nullable/config_3_1.yaml new file mode 100644 index 0000000000..85bf98d8e4 --- /dev/null +++ b/internal/test/openapi31_nullable/config_3_1.yaml @@ -0,0 +1,7 @@ +# yaml-language-server: $schema=../../../configuration-schema.json +package: spec_3_1 +generate: + models: true +output-options: + skip-prune: true +output: spec_3_1/types.gen.go diff --git a/internal/test/openapi31_nullable/doc.go b/internal/test/openapi31_nullable/doc.go new file mode 100644 index 0000000000..a9e9d8246a --- /dev/null +++ b/internal/test/openapi31_nullable/doc.go @@ -0,0 +1,9 @@ +// Package openapi31_nullable verifies that the OpenAPI 3.1 type-array +// nullable idiom (`type: ["X","null"]`) produces the same Go shape as the +// OpenAPI 3.0 `nullable: true` idiom. The generated types are emitted in +// the spec_3_0/ and spec_3_1/ subpackages and exercised by the +// instantiation tests in this directory. +package openapi31_nullable + +//go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen --config=config_3_0.yaml spec_3_0.yaml +//go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen --config=config_3_1.yaml spec_3_1.yaml diff --git a/internal/test/openapi31_nullable/openapi31_nullable_test.go b/internal/test/openapi31_nullable/openapi31_nullable_test.go new file mode 100644 index 0000000000..22ad21ef9c --- /dev/null +++ b/internal/test/openapi31_nullable/openapi31_nullable_test.go @@ -0,0 +1,278 @@ +// Package openapi31_nullable verifies that the OpenAPI 3.1 type-array +// nullable idiom (`type: ["X","null"]`) generates the same Go field shape +// as the OpenAPI 3.0 `nullable: true` idiom: a pointer to the underlying +// type. The test is structural -- it instantiates the generated types and +// assigns through the pointer field -- rather than string-matching the +// generated source. +package openapi31_nullable + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + spec30 "github.com/oapi-codegen/oapi-codegen/v2/internal/test/openapi31_nullable/spec_3_0" + spec31 "github.com/oapi-codegen/oapi-codegen/v2/internal/test/openapi31_nullable/spec_3_1" +) + +// TestNicknameIsPointer_3_0 asserts that the 3.0 spec's `nullable: true` +// produces a `*string` field. Compile-time verification: the assignment +// of `&nick` only succeeds if Nickname is `*string`. +func TestNicknameIsPointer_3_0(t *testing.T) { + nick := "rex" + p := spec30.Pet{ + Name: "fluffy", + Nickname: &nick, + } + require.NotNil(t, p.Nickname) + assert.Equal(t, "rex", *p.Nickname) + + // nil-nickname round-trip + p2 := spec30.Pet{Name: "fluffy"} + assert.Nil(t, p2.Nickname) +} + +// TestNicknameIsPointer_3_1 asserts that the 3.1 spec's +// `type: ["string","null"]` produces a `*string` field, identical in +// shape to the 3.0 control case. +func TestNicknameIsPointer_3_1(t *testing.T) { + nick := "rex" + p := spec31.Pet{ + Name: "fluffy", + Nickname: &nick, + } + require.NotNil(t, p.Nickname) + assert.Equal(t, "rex", *p.Nickname) + + p2 := spec31.Pet{Name: "fluffy"} + assert.Nil(t, p2.Nickname) +} + +// TestNullableArrayAndObjectFields_3_1 asserts that nullable array and +// nullable inline-object fields generate as pointer-to-slice and +// pointer-to-struct respectively in 3.1. Regression for the case where +// `type: ["array","null"]` and `type: ["object","null"]` were rejected +// before schemaPrimaryType was applied at the GenerateGoSchema dispatch +// (see pkg/codegen/schema.go). +func TestNullableArrayAndObjectFields_3_1(t *testing.T) { + tags := []string{"good", "boy"} + id := "owner-1" + p := spec31.Pet{ + Name: "fluffy", + Tags: &tags, + Owner: &struct { + Id *string `json:"id,omitempty"` + }{Id: &id}, + } + require.NotNil(t, p.Tags) + assert.Equal(t, []string{"good", "boy"}, *p.Tags) + require.NotNil(t, p.Owner) + require.NotNil(t, p.Owner.Id) + assert.Equal(t, "owner-1", *p.Owner.Id) + + // Zero-value: nullable fields must be nil, not empty. + p2 := spec31.Pet{Name: "fluffy"} + assert.Nil(t, p2.Tags) + assert.Nil(t, p2.Owner) +} + +// TestNullableArrayAndObjectFields_3_0 is the matching control case +// asserting that `nullable: true` arrays and inline objects in 3.0 +// generate the same pointer shape. +func TestNullableArrayAndObjectFields_3_0(t *testing.T) { + tags := []string{"good", "boy"} + id := "owner-1" + p := spec30.Pet{ + Name: "fluffy", + Tags: &tags, + Owner: &struct { + Id *string `json:"id,omitempty"` + }{Id: &id}, + } + require.NotNil(t, p.Tags) + assert.Equal(t, []string{"good", "boy"}, *p.Tags) + require.NotNil(t, p.Owner) + require.NotNil(t, p.Owner.Id) + assert.Equal(t, "owner-1", *p.Owner.Id) + + p2 := spec30.Pet{Name: "fluffy"} + assert.Nil(t, p2.Tags) + assert.Nil(t, p2.Owner) +} + +// TestNullableUnspecifiedObject_3_1 asserts that a bare nullable +// object (`type: ["object","null"]` with no `properties:`) generates +// as `*map[string]interface{}`. This is the gap flagged in the +// kin-openapi-3.1 PR review: `Schema.Is(...)` strict equality on a +// 3.1 type-array failed to recognize the primary type as "object", +// routing the schema away from the unspecified-object code path. +// Also asserts that the reversed type-array order +// (`type: ["null","object"]`) resolves identically -- no code path +// may peek only at the first element of the array. +func TestNullableUnspecifiedObject_3_1(t *testing.T) { + extras := map[string]interface{}{"k": "v"} + metadata := map[string]interface{}{"j": float64(1)} + p := spec31.Pet{ + Name: "fluffy", + Extras: &extras, + Metadata: &metadata, + } + require.NotNil(t, p.Extras) + assert.Equal(t, "v", (*p.Extras)["k"]) + require.NotNil(t, p.Metadata) + assert.Equal(t, float64(1), (*p.Metadata)["j"]) + + // Zero-value: both unspecified-object fields must be nil. + p2 := spec31.Pet{Name: "fluffy"} + assert.Nil(t, p2.Extras) + assert.Nil(t, p2.Metadata) +} + +// TestNullableUnspecifiedObject_3_0 is the matching 3.0 control case +// asserting parity with the 3.1 type-array form. +func TestNullableUnspecifiedObject_3_0(t *testing.T) { + extras := map[string]interface{}{"k": "v"} + p := spec30.Pet{ + Name: "fluffy", + Extras: &extras, + } + require.NotNil(t, p.Extras) + assert.Equal(t, "v", (*p.Extras)["k"]) + + p2 := spec30.Pet{Name: "fluffy"} + assert.Nil(t, p2.Extras) +} + +// TestNullableViaAnyOfOneOf_3_1 asserts that `anyOf: [{type: string}, +// {type: "null"}]` and the matching `oneOf` form both generate as +// `*string`, identical to the type-array idiom (`type: ["string", +// "null"]`). The `{"type": "null"}` branch is a nullability marker and +// must be skipped during union generation -- before the fix, the +// recursive GenerateGoSchema call on the null-only branch failed with +// `unhandled Schema type: &[null]`. And once null is filtered out, +// the single remaining branch must be collapsed to its underlying +// type instead of being wrapped in a one-variant union, so the two +// idioms produce the same Go API surface. +func TestNullableViaAnyOfOneOf_3_1(t *testing.T) { + nick := "rex" + + // Compile-time check: the AnyOf and OneOf fields must be *string + // (assignment of &nick succeeds only for a pointer-to-string field). + p := spec31.Pet{ + Name: "fluffy", + NicknameAnyOf: &nick, + NicknameOneOf: &nick, + } + require.NotNil(t, p.NicknameAnyOf) + require.NotNil(t, p.NicknameOneOf) + assert.Equal(t, "rex", *p.NicknameAnyOf) + assert.Equal(t, "rex", *p.NicknameOneOf) + + // Zero-value: both nullable fields must be nil. + p2 := spec31.Pet{Name: "fluffy"} + assert.Nil(t, p2.NicknameAnyOf) + assert.Nil(t, p2.NicknameOneOf) + + // JSON round-trip: an explicit string in / explicit string out; + // missing field decodes to nil and re-encodes as absent (omitempty). + const populated = `{"name":"fluffy","nicknameAnyOf":"rex","nicknameOneOf":"rex"}` + encoded, err := json.Marshal(p) + require.NoError(t, err) + assert.JSONEq(t, populated, string(encoded)) + + const empty = `{"name":"fluffy"}` + var p3 spec31.Pet + require.NoError(t, json.Unmarshal([]byte(empty), &p3)) + assert.Nil(t, p3.NicknameAnyOf) + assert.Nil(t, p3.NicknameOneOf) +} + +// TestNullableDiscriminatedUnion_3_1 asserts that a `oneOf` with a +// discriminator and a `{"type": "null"}` branch generates without the +// `discriminator: not all schemas were mapped` error. Before the fix, +// the null branch was filtered out of mapping construction but still +// counted toward the expected-mapping total, so the completeness check +// `len(Mapping) < len(elements)` falsely tripped for nullable +// discriminated unions. The fix compares against the number of +// non-null branches actually processed. +func TestNullableDiscriminatedUnion_3_1(t *testing.T) { + // Cat round-trip via the generated discriminated-union accessors. + const catJSON = `{"kind":"Cat","meow":true}` + var pet spec31.DiscriminatedPet + require.NoError(t, json.Unmarshal([]byte(catJSON), &pet)) + cat, err := pet.AsCat() + require.NoError(t, err) + require.NotNil(t, cat.Meow) + assert.True(t, *cat.Meow) + + encoded, err := json.Marshal(pet) + require.NoError(t, err) + assert.JSONEq(t, catJSON, string(encoded)) + + // Dog round-trip: the second non-null branch must also map. + const dogJSON = `{"kind":"Dog","bark":true}` + var pet2 spec31.DiscriminatedPet + require.NoError(t, json.Unmarshal([]byte(dogJSON), &pet2)) + dog, err := pet2.AsDog() + require.NoError(t, err) + require.NotNil(t, dog.Bark) + assert.True(t, *dog.Bark) +} + +// TestJsonRoundTrip_NullableFields_AcrossVersions asserts that a JSON +// payload with an explicit null nickname unmarshals to (*string)(nil) in +// both spec versions, and that JSON output omits the field when nil due +// to omitempty. The two generated structs must marshal identically for +// the nullable field. +func TestJsonRoundTrip_NullableFields_AcrossVersions(t *testing.T) { + const withName = `{"name":"fluffy"}` + const withBoth = `{"name":"fluffy","nickname":"rex"}` + + for _, tc := range []struct { + name string + // fn30 / fn31 unmarshal the input into each version's Pet type + // and return a JSON re-marshal so we can assert equality. + fn30 func(input string) (string, *string, error) + fn31 func(input string) (string, *string, error) + }{ + { + name: "unmarshal/marshal symmetric across versions", + fn30: func(input string) (string, *string, error) { + var p spec30.Pet + if err := json.Unmarshal([]byte(input), &p); err != nil { + return "", nil, err + } + out, err := json.Marshal(p) + return string(out), p.Nickname, err + }, + fn31: func(input string) (string, *string, error) { + var p spec31.Pet + if err := json.Unmarshal([]byte(input), &p); err != nil { + return "", nil, err + } + out, err := json.Marshal(p) + return string(out), p.Nickname, err + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + for _, in := range []string{withName, withBoth} { + out30, n30, err30 := tc.fn30(in) + require.NoError(t, err30) + out31, n31, err31 := tc.fn31(in) + require.NoError(t, err31) + assert.JSONEq(t, in, out30, "3.0 round-trip should be lossless") + assert.JSONEq(t, in, out31, "3.1 round-trip should be lossless") + assert.JSONEq(t, out30, out31, "3.0 and 3.1 must marshal identically") + if n30 == nil { + assert.Nil(t, n31) + } else { + require.NotNil(t, n31) + assert.Equal(t, *n30, *n31) + } + } + }) + } +} diff --git a/internal/test/openapi31_nullable/spec_3_0.yaml b/internal/test/openapi31_nullable/spec_3_0.yaml new file mode 100644 index 0000000000..8ede3dbc17 --- /dev/null +++ b/internal/test/openapi31_nullable/spec_3_0.yaml @@ -0,0 +1,43 @@ +openapi: 3.0.3 +info: + title: Nullable test (OpenAPI 3.0) + version: 1.0.0 + description: | + Control case for the OpenAPI 3.1 nullable backport. Each nullable + field uses the 3.0 idiom (`nullable: true`) so the generated Go + shape can be compared against the 3.1 type-array idiom. +paths: {} +components: + schemas: + Pet: + type: object + required: + - name + properties: + name: + type: string + description: Required, non-nullable. + nickname: + type: string + nullable: true + description: "Optional, nullable scalar via 3.0 `nullable: true`." + tags: + type: array + nullable: true + items: + type: string + description: "Optional, nullable array." + owner: + type: object + nullable: true + properties: + id: + type: string + description: "Optional, nullable inline object." + extras: + type: object + nullable: true + description: | + Optional, nullable *unspecified* object (no `properties:`). + 3.0 control case for the 3.1 `type: ["object","null"]` + equivalent; expected shape: `*map[string]interface{}`. diff --git a/internal/test/openapi31_nullable/spec_3_0/types.gen.go b/internal/test/openapi31_nullable/spec_3_0/types.gen.go new file mode 100644 index 0000000000..b9dd7df8ee --- /dev/null +++ b/internal/test/openapi31_nullable/spec_3_0/types.gen.go @@ -0,0 +1,26 @@ +// Package spec_3_0 provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.0.0-00010101000000-000000000000 DO NOT EDIT. +package spec_3_0 + +// Pet defines model for Pet. +type Pet struct { + // Extras Optional, nullable *unspecified* object (no `properties:`). + // 3.0 control case for the 3.1 `type: ["object","null"]` + // equivalent; expected shape: `*map[string]interface{}`. + Extras *map[string]interface{} `json:"extras,omitempty"` + + // Name Required, non-nullable. + Name string `json:"name"` + + // Nickname Optional, nullable scalar via 3.0 `nullable: true`. + Nickname *string `json:"nickname,omitempty"` + + // Owner Optional, nullable inline object. + Owner *struct { + Id *string `json:"id,omitempty"` + } `json:"owner,omitempty"` + + // Tags Optional, nullable array. + Tags *[]string `json:"tags,omitempty"` +} diff --git a/internal/test/openapi31_nullable/spec_3_1.yaml b/internal/test/openapi31_nullable/spec_3_1.yaml new file mode 100644 index 0000000000..cea52ed852 --- /dev/null +++ b/internal/test/openapi31_nullable/spec_3_1.yaml @@ -0,0 +1,99 @@ +openapi: 3.1.0 +info: + title: Nullable test (OpenAPI 3.1) + version: 1.0.0 + description: | + Each nullable field uses the 3.1 type-array idiom + (`type: ["X","null"]`). The generated Go must match the 3.0 control + case so callers see no behavioral difference between spec versions. +paths: {} +components: + schemas: + Pet: + type: object + required: + - name + properties: + name: + type: string + description: Required, non-nullable. + nickname: + type: ["string", "null"] + description: Optional, nullable scalar via 3.1 type-array idiom. + tags: + type: ["array", "null"] + items: + type: string + description: Optional, nullable array. + owner: + type: ["object", "null"] + properties: + id: + type: string + description: Optional, nullable inline object. + extras: + type: ["object", "null"] + description: | + Optional, nullable *unspecified* object (no `properties:`). + Regression for the gap where `Schema.Is(...)` strict + equality on a 3.1 type-array failed to recognize the + primary type as "object", routing the schema away from + the unspecified-object code path. Expected shape: + `*map[string]interface{}`. + metadata: + type: ["null", "object"] + description: | + Same as `extras` but with the type-array order reversed. + Both orderings must resolve identically; this guards + against any code path that inspects only the first + element of the type array. + nicknameAnyOf: + anyOf: + - type: string + - type: "null" + description: | + OpenAPI 3.1: a `{"type": "null"}` branch in `anyOf` is a + nullability marker, not a separate union variant. Should + generate the same shape as `type: ["string","null"]` (i.e. + `*string`). Regression for a previous crash with + "unhandled Schema type: &[null]". + nicknameOneOf: + oneOf: + - type: string + - type: "null" + description: Same as `nicknameAnyOf` but using `oneOf`. + favorite: + $ref: '#/components/schemas/DiscriminatedPet' + description: | + Nullable discriminated union (`oneOf: [Cat, Dog, null]` with a + discriminator). Before this branch's fix to the union- + completeness check, the null branch was skipped during + mapping construction but still counted toward the + expected-mapping count, so generation failed with + `discriminator: not all schemas were mapped`. The two real + branches must map and the null branch must be tolerated. + DiscriminatedPet: + oneOf: + - $ref: '#/components/schemas/Cat' + - $ref: '#/components/schemas/Dog' + - type: "null" + discriminator: + propertyName: kind + Cat: + type: object + required: + - kind + properties: + kind: + type: string + meow: + type: boolean + Dog: + type: object + required: + - kind + properties: + kind: + type: string + bark: + type: boolean diff --git a/internal/test/openapi31_nullable/spec_3_1/types.gen.go b/internal/test/openapi31_nullable/spec_3_1/types.gen.go new file mode 100644 index 0000000000..8d4d54d31d --- /dev/null +++ b/internal/test/openapi31_nullable/spec_3_1/types.gen.go @@ -0,0 +1,167 @@ +// Package spec_3_1 provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.0.0-00010101000000-000000000000 DO NOT EDIT. +package spec_3_1 + +import ( + "encoding/json" + "errors" + + "github.com/oapi-codegen/runtime" +) + +// Cat defines model for Cat. +type Cat struct { + Kind string `json:"kind"` + Meow *bool `json:"meow,omitempty"` +} + +// DiscriminatedPet defines model for DiscriminatedPet. +type DiscriminatedPet struct { + union json.RawMessage +} + +// Dog defines model for Dog. +type Dog struct { + Bark *bool `json:"bark,omitempty"` + Kind string `json:"kind"` +} + +// Pet defines model for Pet. +type Pet struct { + // Extras Optional, nullable *unspecified* object (no `properties:`). + // Regression for the gap where `Schema.Is(...)` strict + // equality on a 3.1 type-array failed to recognize the + // primary type as "object", routing the schema away from + // the unspecified-object code path. Expected shape: + // `*map[string]interface{}`. + Extras *map[string]interface{} `json:"extras,omitempty"` + + // Favorite Nullable discriminated union (`oneOf: [Cat, Dog, null]` with a + // discriminator). Before this branch's fix to the union- + // completeness check, the null branch was skipped during + // mapping construction but still counted toward the + // expected-mapping count, so generation failed with + // `discriminator: not all schemas were mapped`. The two real + // branches must map and the null branch must be tolerated. + Favorite *DiscriminatedPet `json:"favorite,omitempty"` + + // Metadata Same as `extras` but with the type-array order reversed. + // Both orderings must resolve identically; this guards + // against any code path that inspects only the first + // element of the type array. + Metadata *map[string]interface{} `json:"metadata,omitempty"` + + // Name Required, non-nullable. + Name string `json:"name"` + + // Nickname Optional, nullable scalar via 3.1 type-array idiom. + Nickname *string `json:"nickname,omitempty"` + + // NicknameAnyOf OpenAPI 3.1: a `{"type": "null"}` branch in `anyOf` is a + // nullability marker, not a separate union variant. Should + // generate the same shape as `type: ["string","null"]` (i.e. + // `*string`). Regression for a previous crash with + // "unhandled Schema type: &[null]". + NicknameAnyOf *string `json:"nicknameAnyOf,omitempty"` + + // NicknameOneOf Same as `nicknameAnyOf` but using `oneOf`. + NicknameOneOf *string `json:"nicknameOneOf,omitempty"` + + // Owner Optional, nullable inline object. + Owner *struct { + Id *string `json:"id,omitempty"` + } `json:"owner,omitempty"` + + // Tags Optional, nullable array. + Tags *[]string `json:"tags,omitempty"` +} + +// AsCat returns the union data inside the DiscriminatedPet as a Cat +func (t DiscriminatedPet) AsCat() (Cat, error) { + var body Cat + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromCat overwrites any union data inside the DiscriminatedPet as the provided Cat +func (t *DiscriminatedPet) FromCat(v Cat) error { + v.Kind = "Cat" + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeCat performs a merge with any union data inside the DiscriminatedPet, using the provided Cat +func (t *DiscriminatedPet) MergeCat(v Cat) error { + v.Kind = "Cat" + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsDog returns the union data inside the DiscriminatedPet as a Dog +func (t DiscriminatedPet) AsDog() (Dog, error) { + var body Dog + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromDog overwrites any union data inside the DiscriminatedPet as the provided Dog +func (t *DiscriminatedPet) FromDog(v Dog) error { + v.Kind = "Dog" + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeDog performs a merge with any union data inside the DiscriminatedPet, using the provided Dog +func (t *DiscriminatedPet) MergeDog(v Dog) error { + v.Kind = "Dog" + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +func (t DiscriminatedPet) Discriminator() (string, error) { + var discriminator struct { + Discriminator string `json:"kind"` + } + err := json.Unmarshal(t.union, &discriminator) + return discriminator.Discriminator, err +} + +func (t DiscriminatedPet) ValueByDiscriminator() (interface{}, error) { + discriminator, err := t.Discriminator() + if err != nil { + return nil, err + } + switch discriminator { + case "Cat": + return t.AsCat() + case "Dog": + return t.AsDog() + default: + return nil, errors.New("unknown discriminator value: " + discriminator) + } +} + +func (t DiscriminatedPet) MarshalJSON() ([]byte, error) { + b, err := t.union.MarshalJSON() + return b, err +} + +func (t *DiscriminatedPet) UnmarshalJSON(b []byte) error { + err := t.union.UnmarshalJSON(b) + return err +} diff --git a/internal/test/openapi31_polish/config.yaml b/internal/test/openapi31_polish/config.yaml new file mode 100644 index 0000000000..8d068cc372 --- /dev/null +++ b/internal/test/openapi31_polish/config.yaml @@ -0,0 +1,7 @@ +# yaml-language-server: $schema=../../../configuration-schema.json +package: openapi31_polish +generate: + models: true +output-options: + skip-prune: true +output: openapi31_polish.gen.go diff --git a/internal/test/openapi31_polish/doc.go b/internal/test/openapi31_polish/doc.go new file mode 100644 index 0000000000..d24c20c3ab --- /dev/null +++ b/internal/test/openapi31_polish/doc.go @@ -0,0 +1,7 @@ +// Package openapi31_polish verifies the OpenAPI 3.1 polish features: +// `examples` (plural array) propagating into Go doc comments, and +// scalar `const` synthesizing a typed alias + singleton constant via +// the existing enum-codegen path. +package openapi31_polish + +//go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen --config=config.yaml spec.yaml diff --git a/internal/test/openapi31_polish/openapi31_polish.gen.go b/internal/test/openapi31_polish/openapi31_polish.gen.go new file mode 100644 index 0000000000..8fe06338fe --- /dev/null +++ b/internal/test/openapi31_polish/openapi31_polish.gen.go @@ -0,0 +1,33 @@ +// Package openapi31_polish provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.0.0-00010101000000-000000000000 DO NOT EDIT. +package openapi31_polish + +// Defines values for Status. +const ( + Active Status = "active" +) + +// Valid indicates whether the value is a known member of the Status enum. +func (e Status) Valid() bool { + switch e { + case Active: + return true + default: + return false + } +} + +// Pet defines model for Pet. +type Pet struct { + // Lives Examples: 9 + Lives int `json:"lives"` + + // Name The pet's name. + // + // Examples: Whiskers, Rex + Name string `json:"name"` +} + +// Status defines model for Status. +type Status string diff --git a/internal/test/openapi31_polish/openapi31_polish_test.go b/internal/test/openapi31_polish/openapi31_polish_test.go new file mode 100644 index 0000000000..db9cf5bbe2 --- /dev/null +++ b/internal/test/openapi31_polish/openapi31_polish_test.go @@ -0,0 +1,89 @@ +// Package openapi31_polish tests two OpenAPI 3.1 polish features: +// +// - `examples:` (plural array) folding into Go doc comments. Doc +// comments aren't runtime-introspectable, so the test parses the +// generated source file and asserts the expected text appears in +// the type's field comments. +// +// - Scalar `const:` becoming a typed alias plus a singleton constant +// via the existing enum-codegen path. The test exercises this by +// instantiating the typed value (compile-time guarantee that the +// type and constant exist) and asserting the constant's value. +package openapi31_polish + +import ( + "go/ast" + "go/parser" + "go/token" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestStatusConstSchema verifies that a scalar `const` schema produces a +// typed alias and a singleton constant. Compile-time check: `Active` is +// declared as `const Active Status = "active"`, so type inference here +// gives `s` the type `Status` -- if the codegen had emitted Active as +// untyped, this would not compile. +func TestStatusConstSchema(t *testing.T) { + s := Active + assert.Equal(t, "active", string(s)) + assert.True(t, s.Valid(), "Active should be a valid Status enum member") +} + +// TestPetExampleComments verifies that `examples:` on a property +// surfaces in the generated field's Go doc comment. We can't introspect +// doc comments at runtime, so this parses the generated source file and +// asserts each field's doc-comment text. +func TestPetExampleComments(t *testing.T) { + fs := token.NewFileSet() + f, err := parser.ParseFile(fs, "openapi31_polish.gen.go", nil, parser.ParseComments) + require.NoError(t, err, "could not parse generated file") + + fields := petFieldComments(t, f) + + // `name` had description="The pet's name." plus two examples; both + // must appear in the field doc. + require.Contains(t, fields, "Name") + assert.Contains(t, fields["Name"], "The pet's name.", + "Name field should preserve the original description") + assert.Contains(t, fields["Name"], "Examples: Whiskers, Rex", + "Name field should surface the plural examples on its own paragraph") + + // `lives` had no description, only an example value. + require.Contains(t, fields, "Lives") + assert.Contains(t, fields["Lives"], "Examples: 9", + "Lives field should surface the integer example as a doc fragment") +} + +// petFieldComments extracts the doc comment text for each field of the +// Pet struct from a parsed AST. Returns map[fieldName]commentText. +func petFieldComments(t *testing.T, f *ast.File) map[string]string { + t.Helper() + out := map[string]string{} + for _, decl := range f.Decls { + gd, ok := decl.(*ast.GenDecl) + if !ok || gd.Tok != token.TYPE { + continue + } + for _, spec := range gd.Specs { + ts, ok := spec.(*ast.TypeSpec) + if !ok || ts.Name.Name != "Pet" { + continue + } + st, ok := ts.Type.(*ast.StructType) + if !ok { + continue + } + for _, field := range st.Fields.List { + if len(field.Names) == 0 || field.Doc == nil { + continue + } + out[field.Names[0].Name] = strings.TrimSpace(field.Doc.Text()) + } + } + } + return out +} diff --git a/internal/test/openapi31_polish/spec.yaml b/internal/test/openapi31_polish/spec.yaml new file mode 100644 index 0000000000..2a742d9228 --- /dev/null +++ b/internal/test/openapi31_polish/spec.yaml @@ -0,0 +1,33 @@ +openapi: 3.1.0 +info: + title: OpenAPI 3.1 polish features + version: 1.0.0 + description: | + Verifies the smaller 3.1 polish features: + * `examples` (plural array) propagates into Go doc comments. + * `const` on a scalar schema becomes a typed alias + singleton + constant via the existing enum-codegen path. +paths: {} +components: + schemas: + Pet: + type: object + required: [name, lives] + properties: + name: + type: string + description: The pet's name. + examples: + - "Whiskers" + - "Rex" + lives: + type: integer + examples: + - 9 + + # Top-level `const` schema -- with no `enum:` set, the codegen + # synthesizes a single-value enum from the const so the schema + # becomes a typed alias plus a singleton constant. + Status: + type: string + const: active diff --git a/internal/test/webhooks/chi/config.yaml b/internal/test/webhooks/chi/config.yaml new file mode 100644 index 0000000000..b7aa4983b3 --- /dev/null +++ b/internal/test/webhooks/chi/config.yaml @@ -0,0 +1,9 @@ +# yaml-language-server: $schema=../../../../configuration-schema.json +package: chi +generate: + models: true + client: true + chi-server: true +output-options: + skip-prune: true +output: webhooks.gen.go diff --git a/internal/test/webhooks/chi/doc.go b/internal/test/webhooks/chi/doc.go new file mode 100644 index 0000000000..0a4a49f4aa --- /dev/null +++ b/internal/test/webhooks/chi/doc.go @@ -0,0 +1,10 @@ +// Package webhooks_chi verifies that the chi-server flag emits a +// compilable WebhookReceiverInterface alongside chi's path-server +// interface. Chi shares stdhttp's (w, r) handler signature, so the +// receiver shape is structurally identical to internal/test/webhooks +// (which already round-trip-tests the runtime behavior). This package +// is a compile-time assertion that the chi/chi-receiver.tmpl renders +// valid Go. +package chi + +//go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen --config=config.yaml ../spec.yaml diff --git a/internal/test/webhooks/chi/webhooks.gen.go b/internal/test/webhooks/chi/webhooks.gen.go new file mode 100644 index 0000000000..30099ddce8 --- /dev/null +++ b/internal/test/webhooks/chi/webhooks.gen.go @@ -0,0 +1,460 @@ +// Package chi provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.0.0-00010101000000-000000000000 DO NOT EDIT. +package chi + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/go-chi/chi/v5" +) + +// Defines values for PetStatusEventStatus. +const ( + Available PetStatusEventStatus = "available" + Pending PetStatusEventStatus = "pending" + Sold PetStatusEventStatus = "sold" +) + +// Valid indicates whether the value is a known member of the PetStatusEventStatus enum. +func (e PetStatusEventStatus) Valid() bool { + switch e { + case Available: + return true + case Pending: + return true + case Sold: + return true + default: + return false + } +} + +// PetStatusEvent defines model for PetStatusEvent. +type PetStatusEvent struct { + Id string `json:"id"` + Status PetStatusEventStatus `json:"status"` +} + +// PetStatusEventStatus defines model for PetStatusEvent.Status. +type PetStatusEventStatus string + +// PetStatusChangedJSONRequestBody defines body for PetStatusChanged for application/json ContentType. +type PetStatusChangedJSONRequestBody = PetStatusEvent + +// RequestEditorFn is the function signature for the RequestEditor callback function +type RequestEditorFn func(ctx context.Context, req *http.Request) error + +// Doer performs HTTP requests. +// +// The standard http.Client implements this interface. +type HttpRequestDoer interface { + Do(req *http.Request) (*http.Response, error) +} + +// Client which conforms to the OpenAPI3 specification for this service. +type Client struct { + // The endpoint of the server conforming to this interface, with scheme, + // https://api.deepmap.com for example. This can contain a path relative + // to the server, such as https://api.deepmap.com/dev-test, and all the + // paths in the swagger spec will be appended to the server. + Server string + + // Doer for performing requests, typically a *http.Client with any + // customized settings, such as certificate chains. + Client HttpRequestDoer + + // A list of callbacks for modifying requests which are generated before sending over + // the network. + RequestEditors []RequestEditorFn +} + +// ClientOption allows setting custom parameters during construction +type ClientOption func(*Client) error + +// Creates a new Client, with reasonable defaults +func NewClient(server string, opts ...ClientOption) (*Client, error) { + // create a client with sane default values + client := Client{ + Server: server, + } + // mutate client and add all optional params + for _, o := range opts { + if err := o(&client); err != nil { + return nil, err + } + } + // ensure the server URL always has a trailing slash + if !strings.HasSuffix(client.Server, "/") { + client.Server += "/" + } + // create httpClient, if not already present + if client.Client == nil { + client.Client = &http.Client{} + } + return &client, nil +} + +// WithHTTPClient allows overriding the default Doer, which is +// automatically created using http.Client. This is useful for tests. +func WithHTTPClient(doer HttpRequestDoer) ClientOption { + return func(c *Client) error { + c.Client = doer + return nil + } +} + +// WithRequestEditorFn allows setting up a callback function, which will be +// called right before sending the request. This can be used to mutate the request. +func WithRequestEditorFn(fn RequestEditorFn) ClientOption { + return func(c *Client) error { + c.RequestEditors = append(c.RequestEditors, fn) + return nil + } +} + +// The interface specification for the client above. +type ClientInterface interface { +} + +func (c *Client) applyEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error { + for _, r := range c.RequestEditors { + if err := r(ctx, req); err != nil { + return err + } + } + for _, r := range additionalEditors { + if err := r(ctx, req); err != nil { + return err + } + } + return nil +} + +// ClientWithResponses builds on ClientInterface to offer response payloads +type ClientWithResponses struct { + ClientInterface +} + +// NewClientWithResponses creates a new ClientWithResponses, which wraps +// Client with return type handling +func NewClientWithResponses(server string, opts ...ClientOption) (*ClientWithResponses, error) { + client, err := NewClient(server, opts...) + if err != nil { + return nil, err + } + return &ClientWithResponses{client}, nil +} + +// WithBaseURL overrides the baseURL. +func WithBaseURL(baseURL string) ClientOption { + return func(c *Client) error { + newBaseURL, err := url.Parse(baseURL) + if err != nil { + return err + } + c.Server = newBaseURL.String() + return nil + } +} + +// ClientWithResponsesInterface is the interface specification for the client with responses above. +type ClientWithResponsesInterface interface { +} + +// WebhookInitiator sends OpenAPI 3.1 webhook requests to target URLs. +// Modeled on the generated Client, but with no stored Server -- the full +// target URL is provided per-call by the caller (typically discovered +// from a subscription registration). +type WebhookInitiator struct { + // Doer for performing requests, typically a *http.Client with any + // customized settings, such as certificate chains. + Client HttpRequestDoer + + // A list of callbacks for modifying requests which are generated before sending over + // the network. + RequestEditors []RequestEditorFn +} + +// WebhookInitiatorOption allows setting custom parameters during construction. +type WebhookInitiatorOption func(*WebhookInitiator) error + +// NewWebhookInitiator creates a new WebhookInitiator with reasonable defaults. +func NewWebhookInitiator(opts ...WebhookInitiatorOption) (*WebhookInitiator, error) { + initiator := WebhookInitiator{} + for _, o := range opts { + if err := o(&initiator); err != nil { + return nil, err + } + } + if initiator.Client == nil { + initiator.Client = &http.Client{} + } + return &initiator, nil +} + +// WithWebhookHTTPClient allows overriding the default Doer, which is +// automatically created using http.Client. This is useful for tests. +func WithWebhookHTTPClient(doer HttpRequestDoer) WebhookInitiatorOption { + return func(p *WebhookInitiator) error { + p.Client = doer + return nil + } +} + +// WithWebhookRequestEditorFn allows setting up a callback function, which +// will be called right before sending the webhook request. This can be +// used to mutate the request, e.g. to add signature headers. +func WithWebhookRequestEditorFn(fn RequestEditorFn) WebhookInitiatorOption { + return func(p *WebhookInitiator) error { + p.RequestEditors = append(p.RequestEditors, fn) + return nil + } +} + +func (p *WebhookInitiator) applyWebhookEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error { + for _, r := range p.RequestEditors { + if err := r(ctx, req); err != nil { + return err + } + } + for _, r := range additionalEditors { + if err := r(ctx, req); err != nil { + return err + } + } + return nil +} + +// WebhookInitiatorInterface is the interface specification for the webhook initiator. +type WebhookInitiatorInterface interface { + // PetStatusChangedWithBody fires the petStatusChanged webhook with any body + PetStatusChangedWithBody(ctx context.Context, targetURL string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + PetStatusChanged(ctx context.Context, targetURL string, body PetStatusChangedJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) +} + +func (p *WebhookInitiator) PetStatusChangedWithBody(ctx context.Context, targetURL string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPetStatusChangedWebhookRequestWithBody(targetURL, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := p.applyWebhookEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return p.Client.Do(req) +} + +func (p *WebhookInitiator) PetStatusChanged(ctx context.Context, targetURL string, body PetStatusChangedJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPetStatusChangedWebhookRequest(targetURL, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := p.applyWebhookEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return p.Client.Do(req) +} + +// NewPetStatusChangedWebhookRequest builds a application/json POST request for the petStatusChanged webhook +func NewPetStatusChangedWebhookRequest(targetURL string, body PetStatusChangedJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewPetStatusChangedWebhookRequestWithBody(targetURL, "application/json", bodyReader) +} + +// NewPetStatusChangedWebhookRequestWithBody builds a POST request for the petStatusChanged webhook with any body +func NewPetStatusChangedWebhookRequestWithBody(targetURL string, contentType string, body io.Reader) (*http.Request, error) { + var err error + _ = err + + reqURL, err := url.Parse(targetURL) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, reqURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// ServerInterface represents all server handlers. +type ServerInterface interface { +} + +// Unimplemented server implementation that returns http.StatusNotImplemented for each endpoint. + +type Unimplemented struct{} + +// ServerInterfaceWrapper converts contexts to parameters. +type ServerInterfaceWrapper struct { + Handler ServerInterface + HandlerMiddlewares []MiddlewareFunc + ErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error) +} + +type MiddlewareFunc func(http.Handler) http.Handler + +type UnescapedCookieParamError struct { + ParamName string + Err error +} + +func (e *UnescapedCookieParamError) Error() string { + return fmt.Sprintf("error unescaping cookie parameter '%s'", e.ParamName) +} + +func (e *UnescapedCookieParamError) Unwrap() error { + return e.Err +} + +type UnmarshalingParamError struct { + ParamName string + Err error +} + +func (e *UnmarshalingParamError) Error() string { + return fmt.Sprintf("Error unmarshaling parameter %s as JSON: %s", e.ParamName, e.Err.Error()) +} + +func (e *UnmarshalingParamError) Unwrap() error { + return e.Err +} + +type RequiredParamError struct { + ParamName string +} + +func (e *RequiredParamError) Error() string { + return fmt.Sprintf("Query argument %s is required, but not found", e.ParamName) +} + +type RequiredHeaderError struct { + ParamName string + Err error +} + +func (e *RequiredHeaderError) Error() string { + return fmt.Sprintf("Header parameter %s is required, but not found", e.ParamName) +} + +func (e *RequiredHeaderError) Unwrap() error { + return e.Err +} + +type InvalidParamFormatError struct { + ParamName string + Err error +} + +func (e *InvalidParamFormatError) Error() string { + return fmt.Sprintf("Invalid format for parameter %s: %s", e.ParamName, e.Err.Error()) +} + +func (e *InvalidParamFormatError) Unwrap() error { + return e.Err +} + +type TooManyValuesForParamError struct { + ParamName string + Count int +} + +func (e *TooManyValuesForParamError) Error() string { + return fmt.Sprintf("Expected one value for %s, got %d", e.ParamName, e.Count) +} + +// Handler creates http.Handler with routing matching OpenAPI spec. +func Handler(si ServerInterface) http.Handler { + return HandlerWithOptions(si, ChiServerOptions{}) +} + +type ChiServerOptions struct { + BaseURL string + BaseRouter chi.Router + Middlewares []MiddlewareFunc + ErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error) +} + +// HandlerFromMux creates http.Handler with routing matching OpenAPI spec based on the provided mux. +func HandlerFromMux(si ServerInterface, r chi.Router) http.Handler { + return HandlerWithOptions(si, ChiServerOptions{ + BaseRouter: r, + }) +} + +func HandlerFromMuxWithBaseURL(si ServerInterface, r chi.Router, baseURL string) http.Handler { + return HandlerWithOptions(si, ChiServerOptions{ + BaseURL: baseURL, + BaseRouter: r, + }) +} + +// HandlerWithOptions creates http.Handler with additional options +func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handler { + r := options.BaseRouter + + if r == nil { + r = chi.NewRouter() + } + if options.ErrorHandlerFunc == nil { + options.ErrorHandlerFunc = func(w http.ResponseWriter, r *http.Request, err error) { + http.Error(w, err.Error(), http.StatusBadRequest) + } + } + + return r +} + +// WebhookReceiverInterface represents handlers for receiving inbound +// webhook requests. Each webhook becomes a Handle*Webhook +// method that the implementation fills in. The caller mounts the per- +// webhook http.Handler returned by {Op}WebhookHandler at +// whatever URL path they advertise to senders. +type WebhookReceiverInterface interface { + // Notifies subscribers that a pet's status changed. + // HandlePetStatusChangedWebhook handles the POST webhook for petStatusChanged. + HandlePetStatusChangedWebhook(w http.ResponseWriter, r *http.Request) +} + +// WebhookReceiverMiddlewareFunc wraps an http.Handler with cross- +// cutting behavior (signature verification, logging, rate limiting, ...). +type WebhookReceiverMiddlewareFunc func(http.Handler) http.Handler + +// PetStatusChangedWebhookHandler returns the http.Handler for the petStatusChanged webhook. +// Mount this at the URL path advertised to webhook senders. errHandler +// may be nil; if so, parameter-binding errors return 400 with the error +// message. Middlewares are applied in the order provided -- the last +// argument becomes the outermost wrapper. +func PetStatusChangedWebhookHandler(si WebhookReceiverInterface, errHandler func(w http.ResponseWriter, r *http.Request, err error), middlewares ...WebhookReceiverMiddlewareFunc) http.Handler { + if errHandler == nil { + errHandler = func(w http.ResponseWriter, r *http.Request, err error) { + http.Error(w, err.Error(), http.StatusBadRequest) + } + } + var h http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + si.HandlePetStatusChangedWebhook(w, r) + }) + for _, mw := range middlewares { + h = mw(h) + } + return h +} diff --git a/internal/test/webhooks/echo/config.yaml b/internal/test/webhooks/echo/config.yaml new file mode 100644 index 0000000000..425787aad6 --- /dev/null +++ b/internal/test/webhooks/echo/config.yaml @@ -0,0 +1,9 @@ +# yaml-language-server: $schema=../../../../configuration-schema.json +package: echo +generate: + models: true + client: true + echo-server: true +output-options: + skip-prune: true +output: webhooks.gen.go diff --git a/internal/test/webhooks/echo/doc.go b/internal/test/webhooks/echo/doc.go new file mode 100644 index 0000000000..108fdb3805 --- /dev/null +++ b/internal/test/webhooks/echo/doc.go @@ -0,0 +1,7 @@ +// Package webhooks_echo verifies that the echo-server flag emits a +// compilable WebhookReceiverInterface with echo's (ctx echo.Context) +// error signature. Compile-time assertion only; runtime round-trip is +// covered by internal/test/webhooks (stdhttp). +package echo + +//go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen --config=config.yaml ../spec.yaml diff --git a/internal/test/webhooks/echo/webhooks.gen.go b/internal/test/webhooks/echo/webhooks.gen.go new file mode 100644 index 0000000000..a67f022ac3 --- /dev/null +++ b/internal/test/webhooks/echo/webhooks.gen.go @@ -0,0 +1,376 @@ +// Package echo provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.0.0-00010101000000-000000000000 DO NOT EDIT. +package echo + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/url" + "strings" + + "github.com/labstack/echo/v4" +) + +// Defines values for PetStatusEventStatus. +const ( + Available PetStatusEventStatus = "available" + Pending PetStatusEventStatus = "pending" + Sold PetStatusEventStatus = "sold" +) + +// Valid indicates whether the value is a known member of the PetStatusEventStatus enum. +func (e PetStatusEventStatus) Valid() bool { + switch e { + case Available: + return true + case Pending: + return true + case Sold: + return true + default: + return false + } +} + +// PetStatusEvent defines model for PetStatusEvent. +type PetStatusEvent struct { + Id string `json:"id"` + Status PetStatusEventStatus `json:"status"` +} + +// PetStatusEventStatus defines model for PetStatusEvent.Status. +type PetStatusEventStatus string + +// PetStatusChangedJSONRequestBody defines body for PetStatusChanged for application/json ContentType. +type PetStatusChangedJSONRequestBody = PetStatusEvent + +// RequestEditorFn is the function signature for the RequestEditor callback function +type RequestEditorFn func(ctx context.Context, req *http.Request) error + +// Doer performs HTTP requests. +// +// The standard http.Client implements this interface. +type HttpRequestDoer interface { + Do(req *http.Request) (*http.Response, error) +} + +// Client which conforms to the OpenAPI3 specification for this service. +type Client struct { + // The endpoint of the server conforming to this interface, with scheme, + // https://api.deepmap.com for example. This can contain a path relative + // to the server, such as https://api.deepmap.com/dev-test, and all the + // paths in the swagger spec will be appended to the server. + Server string + + // Doer for performing requests, typically a *http.Client with any + // customized settings, such as certificate chains. + Client HttpRequestDoer + + // A list of callbacks for modifying requests which are generated before sending over + // the network. + RequestEditors []RequestEditorFn +} + +// ClientOption allows setting custom parameters during construction +type ClientOption func(*Client) error + +// Creates a new Client, with reasonable defaults +func NewClient(server string, opts ...ClientOption) (*Client, error) { + // create a client with sane default values + client := Client{ + Server: server, + } + // mutate client and add all optional params + for _, o := range opts { + if err := o(&client); err != nil { + return nil, err + } + } + // ensure the server URL always has a trailing slash + if !strings.HasSuffix(client.Server, "/") { + client.Server += "/" + } + // create httpClient, if not already present + if client.Client == nil { + client.Client = &http.Client{} + } + return &client, nil +} + +// WithHTTPClient allows overriding the default Doer, which is +// automatically created using http.Client. This is useful for tests. +func WithHTTPClient(doer HttpRequestDoer) ClientOption { + return func(c *Client) error { + c.Client = doer + return nil + } +} + +// WithRequestEditorFn allows setting up a callback function, which will be +// called right before sending the request. This can be used to mutate the request. +func WithRequestEditorFn(fn RequestEditorFn) ClientOption { + return func(c *Client) error { + c.RequestEditors = append(c.RequestEditors, fn) + return nil + } +} + +// The interface specification for the client above. +type ClientInterface interface { +} + +func (c *Client) applyEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error { + for _, r := range c.RequestEditors { + if err := r(ctx, req); err != nil { + return err + } + } + for _, r := range additionalEditors { + if err := r(ctx, req); err != nil { + return err + } + } + return nil +} + +// ClientWithResponses builds on ClientInterface to offer response payloads +type ClientWithResponses struct { + ClientInterface +} + +// NewClientWithResponses creates a new ClientWithResponses, which wraps +// Client with return type handling +func NewClientWithResponses(server string, opts ...ClientOption) (*ClientWithResponses, error) { + client, err := NewClient(server, opts...) + if err != nil { + return nil, err + } + return &ClientWithResponses{client}, nil +} + +// WithBaseURL overrides the baseURL. +func WithBaseURL(baseURL string) ClientOption { + return func(c *Client) error { + newBaseURL, err := url.Parse(baseURL) + if err != nil { + return err + } + c.Server = newBaseURL.String() + return nil + } +} + +// ClientWithResponsesInterface is the interface specification for the client with responses above. +type ClientWithResponsesInterface interface { +} + +// WebhookInitiator sends OpenAPI 3.1 webhook requests to target URLs. +// Modeled on the generated Client, but with no stored Server -- the full +// target URL is provided per-call by the caller (typically discovered +// from a subscription registration). +type WebhookInitiator struct { + // Doer for performing requests, typically a *http.Client with any + // customized settings, such as certificate chains. + Client HttpRequestDoer + + // A list of callbacks for modifying requests which are generated before sending over + // the network. + RequestEditors []RequestEditorFn +} + +// WebhookInitiatorOption allows setting custom parameters during construction. +type WebhookInitiatorOption func(*WebhookInitiator) error + +// NewWebhookInitiator creates a new WebhookInitiator with reasonable defaults. +func NewWebhookInitiator(opts ...WebhookInitiatorOption) (*WebhookInitiator, error) { + initiator := WebhookInitiator{} + for _, o := range opts { + if err := o(&initiator); err != nil { + return nil, err + } + } + if initiator.Client == nil { + initiator.Client = &http.Client{} + } + return &initiator, nil +} + +// WithWebhookHTTPClient allows overriding the default Doer, which is +// automatically created using http.Client. This is useful for tests. +func WithWebhookHTTPClient(doer HttpRequestDoer) WebhookInitiatorOption { + return func(p *WebhookInitiator) error { + p.Client = doer + return nil + } +} + +// WithWebhookRequestEditorFn allows setting up a callback function, which +// will be called right before sending the webhook request. This can be +// used to mutate the request, e.g. to add signature headers. +func WithWebhookRequestEditorFn(fn RequestEditorFn) WebhookInitiatorOption { + return func(p *WebhookInitiator) error { + p.RequestEditors = append(p.RequestEditors, fn) + return nil + } +} + +func (p *WebhookInitiator) applyWebhookEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error { + for _, r := range p.RequestEditors { + if err := r(ctx, req); err != nil { + return err + } + } + for _, r := range additionalEditors { + if err := r(ctx, req); err != nil { + return err + } + } + return nil +} + +// WebhookInitiatorInterface is the interface specification for the webhook initiator. +type WebhookInitiatorInterface interface { + // PetStatusChangedWithBody fires the petStatusChanged webhook with any body + PetStatusChangedWithBody(ctx context.Context, targetURL string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + PetStatusChanged(ctx context.Context, targetURL string, body PetStatusChangedJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) +} + +func (p *WebhookInitiator) PetStatusChangedWithBody(ctx context.Context, targetURL string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPetStatusChangedWebhookRequestWithBody(targetURL, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := p.applyWebhookEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return p.Client.Do(req) +} + +func (p *WebhookInitiator) PetStatusChanged(ctx context.Context, targetURL string, body PetStatusChangedJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPetStatusChangedWebhookRequest(targetURL, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := p.applyWebhookEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return p.Client.Do(req) +} + +// NewPetStatusChangedWebhookRequest builds a application/json POST request for the petStatusChanged webhook +func NewPetStatusChangedWebhookRequest(targetURL string, body PetStatusChangedJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewPetStatusChangedWebhookRequestWithBody(targetURL, "application/json", bodyReader) +} + +// NewPetStatusChangedWebhookRequestWithBody builds a POST request for the petStatusChanged webhook with any body +func NewPetStatusChangedWebhookRequestWithBody(targetURL string, contentType string, body io.Reader) (*http.Request, error) { + var err error + _ = err + + reqURL, err := url.Parse(targetURL) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, reqURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// ServerInterface represents all server handlers. +type ServerInterface interface { +} + +// ServerInterfaceWrapper converts echo contexts to parameters. +type ServerInterfaceWrapper struct { + Handler ServerInterface +} + +// This is a simple interface which specifies echo.Route addition functions which +// are present on both echo.Echo and echo.Group, since we want to allow using +// either of them for path registration +type EchoRouter interface { + CONNECT(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route + DELETE(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route + GET(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route + HEAD(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route + OPTIONS(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route + PATCH(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route + POST(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route + PUT(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route + TRACE(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route +} + +// RegisterHandlersOptions configures RegisterHandlersWithOptions. +type RegisterHandlersOptions struct { + // BaseURL is prepended to every registered path so the API can be served + // under a prefix. + BaseURL string + // OperationMiddlewares lets the caller attach per-operation middleware at + // registration time. The map key is the OpenAPI `operationId` value as it + // appears in the spec (the raw, un-normalized form). Operations that have + // no entry are registered with no extra middleware. A nil map disables + // per-operation middleware entirely. + OperationMiddlewares map[string][]echo.MiddlewareFunc +} + +// RegisterHandlers adds each server route to the EchoRouter. +func RegisterHandlers(router EchoRouter, si ServerInterface) { + RegisterHandlersWithOptions(router, si, RegisterHandlersOptions{}) +} + +// RegisterHandlersWithBaseURL registers handlers and prepends BaseURL to the +// paths so the API can be served under a prefix. +func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL string) { + RegisterHandlersWithOptions(router, si, RegisterHandlersOptions{BaseURL: baseURL}) +} + +// RegisterHandlersWithOptions registers handlers using the supplied options, +// including any per-operation middleware. +func RegisterHandlersWithOptions(router EchoRouter, si ServerInterface, options RegisterHandlersOptions) { + +} + +// WebhookReceiverInterface represents handlers for receiving inbound +// webhook requests. Each webhook becomes a Handle*Webhook +// method that the implementation fills in. The caller mounts the per- +// webhook echo.HandlerFunc returned by {Op}WebhookHandler at +// whatever URL path they advertise to senders. +type WebhookReceiverInterface interface { + // Notifies subscribers that a pet's status changed. + // HandlePetStatusChangedWebhook handles the POST webhook for petStatusChanged. + HandlePetStatusChangedWebhook(ctx echo.Context) error +} + +// PetStatusChangedWebhookHandler returns the echo.HandlerFunc for the petStatusChanged webhook. +// Mount this at the URL path advertised to webhook senders. Errors +// during parameter binding are returned via echo.NewHTTPError so echo's +// error-handling chain reports them as 400. Middlewares are applied in +// the order provided -- the last argument becomes the outermost wrapper. +func PetStatusChangedWebhookHandler(si WebhookReceiverInterface, middlewares ...echo.MiddlewareFunc) echo.HandlerFunc { + h := echo.HandlerFunc(func(ctx echo.Context) error { + return si.HandlePetStatusChangedWebhook(ctx) + }) + for _, mw := range middlewares { + h = mw(h) + } + return h +} diff --git a/internal/test/webhooks/fiber/config.yaml b/internal/test/webhooks/fiber/config.yaml new file mode 100644 index 0000000000..bb048b6150 --- /dev/null +++ b/internal/test/webhooks/fiber/config.yaml @@ -0,0 +1,9 @@ +# yaml-language-server: $schema=../../../../configuration-schema.json +package: fiber +generate: + models: true + client: true + fiber-server: true +output-options: + skip-prune: true +output: webhooks.gen.go diff --git a/internal/test/webhooks/fiber/doc.go b/internal/test/webhooks/fiber/doc.go new file mode 100644 index 0000000000..31ba979d1f --- /dev/null +++ b/internal/test/webhooks/fiber/doc.go @@ -0,0 +1,6 @@ +// Package webhooks_fiber verifies that the fiber-server flag emits a +// compilable WebhookReceiverInterface with fiber's (c *fiber.Ctx) +// error signature. Compile-time assertion only. +package fiber + +//go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen --config=config.yaml ../spec.yaml diff --git a/internal/test/webhooks/fiber/webhooks.gen.go b/internal/test/webhooks/fiber/webhooks.gen.go new file mode 100644 index 0000000000..c98581e65c --- /dev/null +++ b/internal/test/webhooks/fiber/webhooks.gen.go @@ -0,0 +1,348 @@ +// Package fiber provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.0.0-00010101000000-000000000000 DO NOT EDIT. +package fiber + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/url" + "strings" + + "github.com/gofiber/fiber/v2" +) + +// Defines values for PetStatusEventStatus. +const ( + Available PetStatusEventStatus = "available" + Pending PetStatusEventStatus = "pending" + Sold PetStatusEventStatus = "sold" +) + +// Valid indicates whether the value is a known member of the PetStatusEventStatus enum. +func (e PetStatusEventStatus) Valid() bool { + switch e { + case Available: + return true + case Pending: + return true + case Sold: + return true + default: + return false + } +} + +// PetStatusEvent defines model for PetStatusEvent. +type PetStatusEvent struct { + Id string `json:"id"` + Status PetStatusEventStatus `json:"status"` +} + +// PetStatusEventStatus defines model for PetStatusEvent.Status. +type PetStatusEventStatus string + +// PetStatusChangedJSONRequestBody defines body for PetStatusChanged for application/json ContentType. +type PetStatusChangedJSONRequestBody = PetStatusEvent + +// RequestEditorFn is the function signature for the RequestEditor callback function +type RequestEditorFn func(ctx context.Context, req *http.Request) error + +// Doer performs HTTP requests. +// +// The standard http.Client implements this interface. +type HttpRequestDoer interface { + Do(req *http.Request) (*http.Response, error) +} + +// Client which conforms to the OpenAPI3 specification for this service. +type Client struct { + // The endpoint of the server conforming to this interface, with scheme, + // https://api.deepmap.com for example. This can contain a path relative + // to the server, such as https://api.deepmap.com/dev-test, and all the + // paths in the swagger spec will be appended to the server. + Server string + + // Doer for performing requests, typically a *http.Client with any + // customized settings, such as certificate chains. + Client HttpRequestDoer + + // A list of callbacks for modifying requests which are generated before sending over + // the network. + RequestEditors []RequestEditorFn +} + +// ClientOption allows setting custom parameters during construction +type ClientOption func(*Client) error + +// Creates a new Client, with reasonable defaults +func NewClient(server string, opts ...ClientOption) (*Client, error) { + // create a client with sane default values + client := Client{ + Server: server, + } + // mutate client and add all optional params + for _, o := range opts { + if err := o(&client); err != nil { + return nil, err + } + } + // ensure the server URL always has a trailing slash + if !strings.HasSuffix(client.Server, "/") { + client.Server += "/" + } + // create httpClient, if not already present + if client.Client == nil { + client.Client = &http.Client{} + } + return &client, nil +} + +// WithHTTPClient allows overriding the default Doer, which is +// automatically created using http.Client. This is useful for tests. +func WithHTTPClient(doer HttpRequestDoer) ClientOption { + return func(c *Client) error { + c.Client = doer + return nil + } +} + +// WithRequestEditorFn allows setting up a callback function, which will be +// called right before sending the request. This can be used to mutate the request. +func WithRequestEditorFn(fn RequestEditorFn) ClientOption { + return func(c *Client) error { + c.RequestEditors = append(c.RequestEditors, fn) + return nil + } +} + +// The interface specification for the client above. +type ClientInterface interface { +} + +func (c *Client) applyEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error { + for _, r := range c.RequestEditors { + if err := r(ctx, req); err != nil { + return err + } + } + for _, r := range additionalEditors { + if err := r(ctx, req); err != nil { + return err + } + } + return nil +} + +// ClientWithResponses builds on ClientInterface to offer response payloads +type ClientWithResponses struct { + ClientInterface +} + +// NewClientWithResponses creates a new ClientWithResponses, which wraps +// Client with return type handling +func NewClientWithResponses(server string, opts ...ClientOption) (*ClientWithResponses, error) { + client, err := NewClient(server, opts...) + if err != nil { + return nil, err + } + return &ClientWithResponses{client}, nil +} + +// WithBaseURL overrides the baseURL. +func WithBaseURL(baseURL string) ClientOption { + return func(c *Client) error { + newBaseURL, err := url.Parse(baseURL) + if err != nil { + return err + } + c.Server = newBaseURL.String() + return nil + } +} + +// ClientWithResponsesInterface is the interface specification for the client with responses above. +type ClientWithResponsesInterface interface { +} + +// WebhookInitiator sends OpenAPI 3.1 webhook requests to target URLs. +// Modeled on the generated Client, but with no stored Server -- the full +// target URL is provided per-call by the caller (typically discovered +// from a subscription registration). +type WebhookInitiator struct { + // Doer for performing requests, typically a *http.Client with any + // customized settings, such as certificate chains. + Client HttpRequestDoer + + // A list of callbacks for modifying requests which are generated before sending over + // the network. + RequestEditors []RequestEditorFn +} + +// WebhookInitiatorOption allows setting custom parameters during construction. +type WebhookInitiatorOption func(*WebhookInitiator) error + +// NewWebhookInitiator creates a new WebhookInitiator with reasonable defaults. +func NewWebhookInitiator(opts ...WebhookInitiatorOption) (*WebhookInitiator, error) { + initiator := WebhookInitiator{} + for _, o := range opts { + if err := o(&initiator); err != nil { + return nil, err + } + } + if initiator.Client == nil { + initiator.Client = &http.Client{} + } + return &initiator, nil +} + +// WithWebhookHTTPClient allows overriding the default Doer, which is +// automatically created using http.Client. This is useful for tests. +func WithWebhookHTTPClient(doer HttpRequestDoer) WebhookInitiatorOption { + return func(p *WebhookInitiator) error { + p.Client = doer + return nil + } +} + +// WithWebhookRequestEditorFn allows setting up a callback function, which +// will be called right before sending the webhook request. This can be +// used to mutate the request, e.g. to add signature headers. +func WithWebhookRequestEditorFn(fn RequestEditorFn) WebhookInitiatorOption { + return func(p *WebhookInitiator) error { + p.RequestEditors = append(p.RequestEditors, fn) + return nil + } +} + +func (p *WebhookInitiator) applyWebhookEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error { + for _, r := range p.RequestEditors { + if err := r(ctx, req); err != nil { + return err + } + } + for _, r := range additionalEditors { + if err := r(ctx, req); err != nil { + return err + } + } + return nil +} + +// WebhookInitiatorInterface is the interface specification for the webhook initiator. +type WebhookInitiatorInterface interface { + // PetStatusChangedWithBody fires the petStatusChanged webhook with any body + PetStatusChangedWithBody(ctx context.Context, targetURL string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + PetStatusChanged(ctx context.Context, targetURL string, body PetStatusChangedJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) +} + +func (p *WebhookInitiator) PetStatusChangedWithBody(ctx context.Context, targetURL string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPetStatusChangedWebhookRequestWithBody(targetURL, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := p.applyWebhookEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return p.Client.Do(req) +} + +func (p *WebhookInitiator) PetStatusChanged(ctx context.Context, targetURL string, body PetStatusChangedJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPetStatusChangedWebhookRequest(targetURL, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := p.applyWebhookEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return p.Client.Do(req) +} + +// NewPetStatusChangedWebhookRequest builds a application/json POST request for the petStatusChanged webhook +func NewPetStatusChangedWebhookRequest(targetURL string, body PetStatusChangedJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewPetStatusChangedWebhookRequestWithBody(targetURL, "application/json", bodyReader) +} + +// NewPetStatusChangedWebhookRequestWithBody builds a POST request for the petStatusChanged webhook with any body +func NewPetStatusChangedWebhookRequestWithBody(targetURL string, contentType string, body io.Reader) (*http.Request, error) { + var err error + _ = err + + reqURL, err := url.Parse(targetURL) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, reqURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// ServerInterface represents all server handlers. +type ServerInterface interface { +} + +// ServerInterfaceWrapper converts contexts to parameters. +type ServerInterfaceWrapper struct { + Handler ServerInterface + HandlerMiddlewares []HandlerMiddlewareFunc +} + +type MiddlewareFunc fiber.Handler +type HandlerMiddlewareFunc func(c *fiber.Ctx, next fiber.Handler) error + +// FiberServerOptions provides options for the Fiber server. +type FiberServerOptions struct { + BaseURL string + Middlewares []MiddlewareFunc + HandlerMiddlewares []HandlerMiddlewareFunc +} + +// RegisterHandlers creates http.Handler with routing matching OpenAPI spec. +func RegisterHandlers(router fiber.Router, si ServerInterface) { + RegisterHandlersWithOptions(router, si, FiberServerOptions{}) +} + +// RegisterHandlersWithOptions creates http.Handler with additional options +func RegisterHandlersWithOptions(router fiber.Router, si ServerInterface, options FiberServerOptions) { + +} + +// WebhookReceiverInterface represents handlers for receiving inbound +// webhook requests. Each webhook becomes a Handle*Webhook +// method that the implementation fills in. The caller mounts the per- +// webhook fiber.Handler returned by {Op}WebhookHandler at +// whatever URL path they advertise to senders. +type WebhookReceiverInterface interface { + // Notifies subscribers that a pet's status changed. + // HandlePetStatusChangedWebhook handles the POST webhook for petStatusChanged. + HandlePetStatusChangedWebhook(c *fiber.Ctx) error +} + +// PetStatusChangedWebhookHandler returns the fiber.Handler for the petStatusChanged webhook. +// Mount this at the URL path advertised to webhook senders. Errors +// during parameter binding are returned via fiber.NewError so fiber's +// error chain reports them as 400. Per-handler middleware is not +// generated here; use fiber.App.Use() for engine-level middleware. +func PetStatusChangedWebhookHandler(si WebhookReceiverInterface) fiber.Handler { + return func(c *fiber.Ctx) error { + return si.HandlePetStatusChangedWebhook(c) + } +} diff --git a/internal/test/webhooks/gin/config.yaml b/internal/test/webhooks/gin/config.yaml new file mode 100644 index 0000000000..4ec8f1d49a --- /dev/null +++ b/internal/test/webhooks/gin/config.yaml @@ -0,0 +1,9 @@ +# yaml-language-server: $schema=../../../../configuration-schema.json +package: gin +generate: + models: true + client: true + gin-server: true +output-options: + skip-prune: true +output: webhooks.gen.go diff --git a/internal/test/webhooks/gin/doc.go b/internal/test/webhooks/gin/doc.go new file mode 100644 index 0000000000..6dcf3519dd --- /dev/null +++ b/internal/test/webhooks/gin/doc.go @@ -0,0 +1,6 @@ +// Package webhooks_gin verifies that the gin-server flag emits a +// compilable WebhookReceiverInterface with gin's (c *gin.Context) +// signature. Compile-time assertion only. +package gin + +//go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen --config=config.yaml ../spec.yaml diff --git a/internal/test/webhooks/gin/webhooks.gen.go b/internal/test/webhooks/gin/webhooks.gen.go new file mode 100644 index 0000000000..3aa54867f7 --- /dev/null +++ b/internal/test/webhooks/gin/webhooks.gen.go @@ -0,0 +1,349 @@ +// Package gin provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.0.0-00010101000000-000000000000 DO NOT EDIT. +package gin + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/url" + "strings" + + "github.com/gin-gonic/gin" +) + +// Defines values for PetStatusEventStatus. +const ( + Available PetStatusEventStatus = "available" + Pending PetStatusEventStatus = "pending" + Sold PetStatusEventStatus = "sold" +) + +// Valid indicates whether the value is a known member of the PetStatusEventStatus enum. +func (e PetStatusEventStatus) Valid() bool { + switch e { + case Available: + return true + case Pending: + return true + case Sold: + return true + default: + return false + } +} + +// PetStatusEvent defines model for PetStatusEvent. +type PetStatusEvent struct { + Id string `json:"id"` + Status PetStatusEventStatus `json:"status"` +} + +// PetStatusEventStatus defines model for PetStatusEvent.Status. +type PetStatusEventStatus string + +// PetStatusChangedJSONRequestBody defines body for PetStatusChanged for application/json ContentType. +type PetStatusChangedJSONRequestBody = PetStatusEvent + +// RequestEditorFn is the function signature for the RequestEditor callback function +type RequestEditorFn func(ctx context.Context, req *http.Request) error + +// Doer performs HTTP requests. +// +// The standard http.Client implements this interface. +type HttpRequestDoer interface { + Do(req *http.Request) (*http.Response, error) +} + +// Client which conforms to the OpenAPI3 specification for this service. +type Client struct { + // The endpoint of the server conforming to this interface, with scheme, + // https://api.deepmap.com for example. This can contain a path relative + // to the server, such as https://api.deepmap.com/dev-test, and all the + // paths in the swagger spec will be appended to the server. + Server string + + // Doer for performing requests, typically a *http.Client with any + // customized settings, such as certificate chains. + Client HttpRequestDoer + + // A list of callbacks for modifying requests which are generated before sending over + // the network. + RequestEditors []RequestEditorFn +} + +// ClientOption allows setting custom parameters during construction +type ClientOption func(*Client) error + +// Creates a new Client, with reasonable defaults +func NewClient(server string, opts ...ClientOption) (*Client, error) { + // create a client with sane default values + client := Client{ + Server: server, + } + // mutate client and add all optional params + for _, o := range opts { + if err := o(&client); err != nil { + return nil, err + } + } + // ensure the server URL always has a trailing slash + if !strings.HasSuffix(client.Server, "/") { + client.Server += "/" + } + // create httpClient, if not already present + if client.Client == nil { + client.Client = &http.Client{} + } + return &client, nil +} + +// WithHTTPClient allows overriding the default Doer, which is +// automatically created using http.Client. This is useful for tests. +func WithHTTPClient(doer HttpRequestDoer) ClientOption { + return func(c *Client) error { + c.Client = doer + return nil + } +} + +// WithRequestEditorFn allows setting up a callback function, which will be +// called right before sending the request. This can be used to mutate the request. +func WithRequestEditorFn(fn RequestEditorFn) ClientOption { + return func(c *Client) error { + c.RequestEditors = append(c.RequestEditors, fn) + return nil + } +} + +// The interface specification for the client above. +type ClientInterface interface { +} + +func (c *Client) applyEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error { + for _, r := range c.RequestEditors { + if err := r(ctx, req); err != nil { + return err + } + } + for _, r := range additionalEditors { + if err := r(ctx, req); err != nil { + return err + } + } + return nil +} + +// ClientWithResponses builds on ClientInterface to offer response payloads +type ClientWithResponses struct { + ClientInterface +} + +// NewClientWithResponses creates a new ClientWithResponses, which wraps +// Client with return type handling +func NewClientWithResponses(server string, opts ...ClientOption) (*ClientWithResponses, error) { + client, err := NewClient(server, opts...) + if err != nil { + return nil, err + } + return &ClientWithResponses{client}, nil +} + +// WithBaseURL overrides the baseURL. +func WithBaseURL(baseURL string) ClientOption { + return func(c *Client) error { + newBaseURL, err := url.Parse(baseURL) + if err != nil { + return err + } + c.Server = newBaseURL.String() + return nil + } +} + +// ClientWithResponsesInterface is the interface specification for the client with responses above. +type ClientWithResponsesInterface interface { +} + +// WebhookInitiator sends OpenAPI 3.1 webhook requests to target URLs. +// Modeled on the generated Client, but with no stored Server -- the full +// target URL is provided per-call by the caller (typically discovered +// from a subscription registration). +type WebhookInitiator struct { + // Doer for performing requests, typically a *http.Client with any + // customized settings, such as certificate chains. + Client HttpRequestDoer + + // A list of callbacks for modifying requests which are generated before sending over + // the network. + RequestEditors []RequestEditorFn +} + +// WebhookInitiatorOption allows setting custom parameters during construction. +type WebhookInitiatorOption func(*WebhookInitiator) error + +// NewWebhookInitiator creates a new WebhookInitiator with reasonable defaults. +func NewWebhookInitiator(opts ...WebhookInitiatorOption) (*WebhookInitiator, error) { + initiator := WebhookInitiator{} + for _, o := range opts { + if err := o(&initiator); err != nil { + return nil, err + } + } + if initiator.Client == nil { + initiator.Client = &http.Client{} + } + return &initiator, nil +} + +// WithWebhookHTTPClient allows overriding the default Doer, which is +// automatically created using http.Client. This is useful for tests. +func WithWebhookHTTPClient(doer HttpRequestDoer) WebhookInitiatorOption { + return func(p *WebhookInitiator) error { + p.Client = doer + return nil + } +} + +// WithWebhookRequestEditorFn allows setting up a callback function, which +// will be called right before sending the webhook request. This can be +// used to mutate the request, e.g. to add signature headers. +func WithWebhookRequestEditorFn(fn RequestEditorFn) WebhookInitiatorOption { + return func(p *WebhookInitiator) error { + p.RequestEditors = append(p.RequestEditors, fn) + return nil + } +} + +func (p *WebhookInitiator) applyWebhookEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error { + for _, r := range p.RequestEditors { + if err := r(ctx, req); err != nil { + return err + } + } + for _, r := range additionalEditors { + if err := r(ctx, req); err != nil { + return err + } + } + return nil +} + +// WebhookInitiatorInterface is the interface specification for the webhook initiator. +type WebhookInitiatorInterface interface { + // PetStatusChangedWithBody fires the petStatusChanged webhook with any body + PetStatusChangedWithBody(ctx context.Context, targetURL string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + PetStatusChanged(ctx context.Context, targetURL string, body PetStatusChangedJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) +} + +func (p *WebhookInitiator) PetStatusChangedWithBody(ctx context.Context, targetURL string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPetStatusChangedWebhookRequestWithBody(targetURL, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := p.applyWebhookEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return p.Client.Do(req) +} + +func (p *WebhookInitiator) PetStatusChanged(ctx context.Context, targetURL string, body PetStatusChangedJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPetStatusChangedWebhookRequest(targetURL, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := p.applyWebhookEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return p.Client.Do(req) +} + +// NewPetStatusChangedWebhookRequest builds a application/json POST request for the petStatusChanged webhook +func NewPetStatusChangedWebhookRequest(targetURL string, body PetStatusChangedJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewPetStatusChangedWebhookRequestWithBody(targetURL, "application/json", bodyReader) +} + +// NewPetStatusChangedWebhookRequestWithBody builds a POST request for the petStatusChanged webhook with any body +func NewPetStatusChangedWebhookRequestWithBody(targetURL string, contentType string, body io.Reader) (*http.Request, error) { + var err error + _ = err + + reqURL, err := url.Parse(targetURL) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, reqURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// ServerInterface represents all server handlers. +type ServerInterface interface { +} + +// ServerInterfaceWrapper converts contexts to parameters. +type ServerInterfaceWrapper struct { + Handler ServerInterface + HandlerMiddlewares []MiddlewareFunc + ErrorHandler func(*gin.Context, error, int) +} + +type MiddlewareFunc func(c *gin.Context) + +// GinServerOptions provides options for the Gin server. +type GinServerOptions struct { + BaseURL string + Middlewares []MiddlewareFunc + ErrorHandler func(*gin.Context, error, int) +} + +// RegisterHandlers creates http.Handler with routing matching OpenAPI spec. +func RegisterHandlers(router gin.IRouter, si ServerInterface) { + RegisterHandlersWithOptions(router, si, GinServerOptions{}) +} + +// RegisterHandlersWithOptions creates http.Handler with additional options +func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options GinServerOptions) { + +} + +// WebhookReceiverInterface represents handlers for receiving inbound +// webhook requests. Each webhook becomes a Handle*Webhook +// method that the implementation fills in. The caller mounts the per- +// webhook gin.HandlerFunc returned by {Op}WebhookHandler at +// whatever URL path they advertise to senders. +type WebhookReceiverInterface interface { + // Notifies subscribers that a pet's status changed. + // HandlePetStatusChangedWebhook handles the POST webhook for petStatusChanged. + HandlePetStatusChangedWebhook(c *gin.Context) +} + +// PetStatusChangedWebhookHandler returns the gin.HandlerFunc for the petStatusChanged webhook. +// Mount this at the URL path advertised to webhook senders. +// Parameter-binding errors abort the request with 400 and a JSON body +// of the form {"error": "..."}. Engine-level middleware can be applied +// via gin.Engine.Use(); per-handler middleware is not generated here +// (gin's idiom prefers route-group / engine .Use composition). +func PetStatusChangedWebhookHandler(si WebhookReceiverInterface) gin.HandlerFunc { + return func(c *gin.Context) { + si.HandlePetStatusChangedWebhook(c) + } +} diff --git a/internal/test/webhooks/gorilla/config.yaml b/internal/test/webhooks/gorilla/config.yaml new file mode 100644 index 0000000000..9900db0b69 --- /dev/null +++ b/internal/test/webhooks/gorilla/config.yaml @@ -0,0 +1,9 @@ +# yaml-language-server: $schema=../../../../configuration-schema.json +package: gorilla +generate: + models: true + client: true + gorilla-server: true +output-options: + skip-prune: true +output: webhooks.gen.go diff --git a/internal/test/webhooks/gorilla/doc.go b/internal/test/webhooks/gorilla/doc.go new file mode 100644 index 0000000000..ef9acf108b --- /dev/null +++ b/internal/test/webhooks/gorilla/doc.go @@ -0,0 +1,7 @@ +// Package webhooks_gorilla verifies that the gorilla/mux server flag +// emits a compilable WebhookReceiverInterface alongside gorilla's +// path-server interface. Same (w, r) signature as stdhttp/chi, so the +// receiver shape is identical; this is a compile-time assertion. +package gorilla + +//go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen --config=config.yaml ../spec.yaml diff --git a/internal/test/webhooks/gorilla/webhooks.gen.go b/internal/test/webhooks/gorilla/webhooks.gen.go new file mode 100644 index 0000000000..2bd8bf8a42 --- /dev/null +++ b/internal/test/webhooks/gorilla/webhooks.gen.go @@ -0,0 +1,456 @@ +// Package gorilla provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.0.0-00010101000000-000000000000 DO NOT EDIT. +package gorilla + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/gorilla/mux" +) + +// Defines values for PetStatusEventStatus. +const ( + Available PetStatusEventStatus = "available" + Pending PetStatusEventStatus = "pending" + Sold PetStatusEventStatus = "sold" +) + +// Valid indicates whether the value is a known member of the PetStatusEventStatus enum. +func (e PetStatusEventStatus) Valid() bool { + switch e { + case Available: + return true + case Pending: + return true + case Sold: + return true + default: + return false + } +} + +// PetStatusEvent defines model for PetStatusEvent. +type PetStatusEvent struct { + Id string `json:"id"` + Status PetStatusEventStatus `json:"status"` +} + +// PetStatusEventStatus defines model for PetStatusEvent.Status. +type PetStatusEventStatus string + +// PetStatusChangedJSONRequestBody defines body for PetStatusChanged for application/json ContentType. +type PetStatusChangedJSONRequestBody = PetStatusEvent + +// RequestEditorFn is the function signature for the RequestEditor callback function +type RequestEditorFn func(ctx context.Context, req *http.Request) error + +// Doer performs HTTP requests. +// +// The standard http.Client implements this interface. +type HttpRequestDoer interface { + Do(req *http.Request) (*http.Response, error) +} + +// Client which conforms to the OpenAPI3 specification for this service. +type Client struct { + // The endpoint of the server conforming to this interface, with scheme, + // https://api.deepmap.com for example. This can contain a path relative + // to the server, such as https://api.deepmap.com/dev-test, and all the + // paths in the swagger spec will be appended to the server. + Server string + + // Doer for performing requests, typically a *http.Client with any + // customized settings, such as certificate chains. + Client HttpRequestDoer + + // A list of callbacks for modifying requests which are generated before sending over + // the network. + RequestEditors []RequestEditorFn +} + +// ClientOption allows setting custom parameters during construction +type ClientOption func(*Client) error + +// Creates a new Client, with reasonable defaults +func NewClient(server string, opts ...ClientOption) (*Client, error) { + // create a client with sane default values + client := Client{ + Server: server, + } + // mutate client and add all optional params + for _, o := range opts { + if err := o(&client); err != nil { + return nil, err + } + } + // ensure the server URL always has a trailing slash + if !strings.HasSuffix(client.Server, "/") { + client.Server += "/" + } + // create httpClient, if not already present + if client.Client == nil { + client.Client = &http.Client{} + } + return &client, nil +} + +// WithHTTPClient allows overriding the default Doer, which is +// automatically created using http.Client. This is useful for tests. +func WithHTTPClient(doer HttpRequestDoer) ClientOption { + return func(c *Client) error { + c.Client = doer + return nil + } +} + +// WithRequestEditorFn allows setting up a callback function, which will be +// called right before sending the request. This can be used to mutate the request. +func WithRequestEditorFn(fn RequestEditorFn) ClientOption { + return func(c *Client) error { + c.RequestEditors = append(c.RequestEditors, fn) + return nil + } +} + +// The interface specification for the client above. +type ClientInterface interface { +} + +func (c *Client) applyEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error { + for _, r := range c.RequestEditors { + if err := r(ctx, req); err != nil { + return err + } + } + for _, r := range additionalEditors { + if err := r(ctx, req); err != nil { + return err + } + } + return nil +} + +// ClientWithResponses builds on ClientInterface to offer response payloads +type ClientWithResponses struct { + ClientInterface +} + +// NewClientWithResponses creates a new ClientWithResponses, which wraps +// Client with return type handling +func NewClientWithResponses(server string, opts ...ClientOption) (*ClientWithResponses, error) { + client, err := NewClient(server, opts...) + if err != nil { + return nil, err + } + return &ClientWithResponses{client}, nil +} + +// WithBaseURL overrides the baseURL. +func WithBaseURL(baseURL string) ClientOption { + return func(c *Client) error { + newBaseURL, err := url.Parse(baseURL) + if err != nil { + return err + } + c.Server = newBaseURL.String() + return nil + } +} + +// ClientWithResponsesInterface is the interface specification for the client with responses above. +type ClientWithResponsesInterface interface { +} + +// WebhookInitiator sends OpenAPI 3.1 webhook requests to target URLs. +// Modeled on the generated Client, but with no stored Server -- the full +// target URL is provided per-call by the caller (typically discovered +// from a subscription registration). +type WebhookInitiator struct { + // Doer for performing requests, typically a *http.Client with any + // customized settings, such as certificate chains. + Client HttpRequestDoer + + // A list of callbacks for modifying requests which are generated before sending over + // the network. + RequestEditors []RequestEditorFn +} + +// WebhookInitiatorOption allows setting custom parameters during construction. +type WebhookInitiatorOption func(*WebhookInitiator) error + +// NewWebhookInitiator creates a new WebhookInitiator with reasonable defaults. +func NewWebhookInitiator(opts ...WebhookInitiatorOption) (*WebhookInitiator, error) { + initiator := WebhookInitiator{} + for _, o := range opts { + if err := o(&initiator); err != nil { + return nil, err + } + } + if initiator.Client == nil { + initiator.Client = &http.Client{} + } + return &initiator, nil +} + +// WithWebhookHTTPClient allows overriding the default Doer, which is +// automatically created using http.Client. This is useful for tests. +func WithWebhookHTTPClient(doer HttpRequestDoer) WebhookInitiatorOption { + return func(p *WebhookInitiator) error { + p.Client = doer + return nil + } +} + +// WithWebhookRequestEditorFn allows setting up a callback function, which +// will be called right before sending the webhook request. This can be +// used to mutate the request, e.g. to add signature headers. +func WithWebhookRequestEditorFn(fn RequestEditorFn) WebhookInitiatorOption { + return func(p *WebhookInitiator) error { + p.RequestEditors = append(p.RequestEditors, fn) + return nil + } +} + +func (p *WebhookInitiator) applyWebhookEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error { + for _, r := range p.RequestEditors { + if err := r(ctx, req); err != nil { + return err + } + } + for _, r := range additionalEditors { + if err := r(ctx, req); err != nil { + return err + } + } + return nil +} + +// WebhookInitiatorInterface is the interface specification for the webhook initiator. +type WebhookInitiatorInterface interface { + // PetStatusChangedWithBody fires the petStatusChanged webhook with any body + PetStatusChangedWithBody(ctx context.Context, targetURL string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + PetStatusChanged(ctx context.Context, targetURL string, body PetStatusChangedJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) +} + +func (p *WebhookInitiator) PetStatusChangedWithBody(ctx context.Context, targetURL string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPetStatusChangedWebhookRequestWithBody(targetURL, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := p.applyWebhookEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return p.Client.Do(req) +} + +func (p *WebhookInitiator) PetStatusChanged(ctx context.Context, targetURL string, body PetStatusChangedJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPetStatusChangedWebhookRequest(targetURL, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := p.applyWebhookEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return p.Client.Do(req) +} + +// NewPetStatusChangedWebhookRequest builds a application/json POST request for the petStatusChanged webhook +func NewPetStatusChangedWebhookRequest(targetURL string, body PetStatusChangedJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewPetStatusChangedWebhookRequestWithBody(targetURL, "application/json", bodyReader) +} + +// NewPetStatusChangedWebhookRequestWithBody builds a POST request for the petStatusChanged webhook with any body +func NewPetStatusChangedWebhookRequestWithBody(targetURL string, contentType string, body io.Reader) (*http.Request, error) { + var err error + _ = err + + reqURL, err := url.Parse(targetURL) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, reqURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// ServerInterface represents all server handlers. +type ServerInterface interface { +} + +// ServerInterfaceWrapper converts contexts to parameters. +type ServerInterfaceWrapper struct { + Handler ServerInterface + HandlerMiddlewares []MiddlewareFunc + ErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error) +} + +type MiddlewareFunc func(http.Handler) http.Handler + +type UnescapedCookieParamError struct { + ParamName string + Err error +} + +func (e *UnescapedCookieParamError) Error() string { + return fmt.Sprintf("error unescaping cookie parameter '%s'", e.ParamName) +} + +func (e *UnescapedCookieParamError) Unwrap() error { + return e.Err +} + +type UnmarshalingParamError struct { + ParamName string + Err error +} + +func (e *UnmarshalingParamError) Error() string { + return fmt.Sprintf("Error unmarshaling parameter %s as JSON: %s", e.ParamName, e.Err.Error()) +} + +func (e *UnmarshalingParamError) Unwrap() error { + return e.Err +} + +type RequiredParamError struct { + ParamName string +} + +func (e *RequiredParamError) Error() string { + return fmt.Sprintf("Query argument %s is required, but not found", e.ParamName) +} + +type RequiredHeaderError struct { + ParamName string + Err error +} + +func (e *RequiredHeaderError) Error() string { + return fmt.Sprintf("Header parameter %s is required, but not found", e.ParamName) +} + +func (e *RequiredHeaderError) Unwrap() error { + return e.Err +} + +type InvalidParamFormatError struct { + ParamName string + Err error +} + +func (e *InvalidParamFormatError) Error() string { + return fmt.Sprintf("Invalid format for parameter %s: %s", e.ParamName, e.Err.Error()) +} + +func (e *InvalidParamFormatError) Unwrap() error { + return e.Err +} + +type TooManyValuesForParamError struct { + ParamName string + Count int +} + +func (e *TooManyValuesForParamError) Error() string { + return fmt.Sprintf("Expected one value for %s, got %d", e.ParamName, e.Count) +} + +// Handler creates http.Handler with routing matching OpenAPI spec. +func Handler(si ServerInterface) http.Handler { + return HandlerWithOptions(si, GorillaServerOptions{}) +} + +type GorillaServerOptions struct { + BaseURL string + BaseRouter *mux.Router + Middlewares []MiddlewareFunc + ErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error) +} + +// HandlerFromMux creates http.Handler with routing matching OpenAPI spec based on the provided mux. +func HandlerFromMux(si ServerInterface, r *mux.Router) http.Handler { + return HandlerWithOptions(si, GorillaServerOptions{ + BaseRouter: r, + }) +} + +func HandlerFromMuxWithBaseURL(si ServerInterface, r *mux.Router, baseURL string) http.Handler { + return HandlerWithOptions(si, GorillaServerOptions{ + BaseURL: baseURL, + BaseRouter: r, + }) +} + +// HandlerWithOptions creates http.Handler with additional options +func HandlerWithOptions(si ServerInterface, options GorillaServerOptions) http.Handler { + r := options.BaseRouter + + if r == nil { + r = mux.NewRouter() + } + if options.ErrorHandlerFunc == nil { + options.ErrorHandlerFunc = func(w http.ResponseWriter, r *http.Request, err error) { + http.Error(w, err.Error(), http.StatusBadRequest) + } + } + + return r +} + +// WebhookReceiverInterface represents handlers for receiving inbound +// webhook requests. Each webhook becomes a Handle*Webhook +// method that the implementation fills in. The caller mounts the per- +// webhook http.Handler returned by {Op}WebhookHandler at +// whatever URL path they advertise to senders. +type WebhookReceiverInterface interface { + // Notifies subscribers that a pet's status changed. + // HandlePetStatusChangedWebhook handles the POST webhook for petStatusChanged. + HandlePetStatusChangedWebhook(w http.ResponseWriter, r *http.Request) +} + +// WebhookReceiverMiddlewareFunc wraps an http.Handler with cross- +// cutting behavior (signature verification, logging, rate limiting, ...). +type WebhookReceiverMiddlewareFunc func(http.Handler) http.Handler + +// PetStatusChangedWebhookHandler returns the http.Handler for the petStatusChanged webhook. +// Mount this at the URL path advertised to webhook senders. errHandler +// may be nil; if so, parameter-binding errors return 400 with the error +// message. Middlewares are applied in the order provided -- the last +// argument becomes the outermost wrapper. +func PetStatusChangedWebhookHandler(si WebhookReceiverInterface, errHandler func(w http.ResponseWriter, r *http.Request, err error), middlewares ...WebhookReceiverMiddlewareFunc) http.Handler { + if errHandler == nil { + errHandler = func(w http.ResponseWriter, r *http.Request, err error) { + http.Error(w, err.Error(), http.StatusBadRequest) + } + } + var h http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + si.HandlePetStatusChangedWebhook(w, r) + }) + for _, mw := range middlewares { + h = mw(h) + } + return h +} diff --git a/internal/test/webhooks/iris/config.yaml b/internal/test/webhooks/iris/config.yaml new file mode 100644 index 0000000000..5ab02e9527 --- /dev/null +++ b/internal/test/webhooks/iris/config.yaml @@ -0,0 +1,9 @@ +# yaml-language-server: $schema=../../../../configuration-schema.json +package: iris +generate: + models: true + client: true + iris-server: true +output-options: + skip-prune: true +output: webhooks.gen.go diff --git a/internal/test/webhooks/iris/doc.go b/internal/test/webhooks/iris/doc.go new file mode 100644 index 0000000000..3704787d76 --- /dev/null +++ b/internal/test/webhooks/iris/doc.go @@ -0,0 +1,6 @@ +// Package webhooks_iris verifies that the iris-server flag emits a +// compilable WebhookReceiverInterface with iris's (ctx iris.Context) +// signature. Compile-time assertion only. +package iris + +//go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen --config=config.yaml ../spec.yaml diff --git a/internal/test/webhooks/iris/webhooks.gen.go b/internal/test/webhooks/iris/webhooks.gen.go new file mode 100644 index 0000000000..2be7989803 --- /dev/null +++ b/internal/test/webhooks/iris/webhooks.gen.go @@ -0,0 +1,346 @@ +// Package iris provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.0.0-00010101000000-000000000000 DO NOT EDIT. +package iris + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/url" + "strings" + + "github.com/kataras/iris/v12" +) + +// Defines values for PetStatusEventStatus. +const ( + Available PetStatusEventStatus = "available" + Pending PetStatusEventStatus = "pending" + Sold PetStatusEventStatus = "sold" +) + +// Valid indicates whether the value is a known member of the PetStatusEventStatus enum. +func (e PetStatusEventStatus) Valid() bool { + switch e { + case Available: + return true + case Pending: + return true + case Sold: + return true + default: + return false + } +} + +// PetStatusEvent defines model for PetStatusEvent. +type PetStatusEvent struct { + Id string `json:"id"` + Status PetStatusEventStatus `json:"status"` +} + +// PetStatusEventStatus defines model for PetStatusEvent.Status. +type PetStatusEventStatus string + +// PetStatusChangedJSONRequestBody defines body for PetStatusChanged for application/json ContentType. +type PetStatusChangedJSONRequestBody = PetStatusEvent + +// RequestEditorFn is the function signature for the RequestEditor callback function +type RequestEditorFn func(ctx context.Context, req *http.Request) error + +// Doer performs HTTP requests. +// +// The standard http.Client implements this interface. +type HttpRequestDoer interface { + Do(req *http.Request) (*http.Response, error) +} + +// Client which conforms to the OpenAPI3 specification for this service. +type Client struct { + // The endpoint of the server conforming to this interface, with scheme, + // https://api.deepmap.com for example. This can contain a path relative + // to the server, such as https://api.deepmap.com/dev-test, and all the + // paths in the swagger spec will be appended to the server. + Server string + + // Doer for performing requests, typically a *http.Client with any + // customized settings, such as certificate chains. + Client HttpRequestDoer + + // A list of callbacks for modifying requests which are generated before sending over + // the network. + RequestEditors []RequestEditorFn +} + +// ClientOption allows setting custom parameters during construction +type ClientOption func(*Client) error + +// Creates a new Client, with reasonable defaults +func NewClient(server string, opts ...ClientOption) (*Client, error) { + // create a client with sane default values + client := Client{ + Server: server, + } + // mutate client and add all optional params + for _, o := range opts { + if err := o(&client); err != nil { + return nil, err + } + } + // ensure the server URL always has a trailing slash + if !strings.HasSuffix(client.Server, "/") { + client.Server += "/" + } + // create httpClient, if not already present + if client.Client == nil { + client.Client = &http.Client{} + } + return &client, nil +} + +// WithHTTPClient allows overriding the default Doer, which is +// automatically created using http.Client. This is useful for tests. +func WithHTTPClient(doer HttpRequestDoer) ClientOption { + return func(c *Client) error { + c.Client = doer + return nil + } +} + +// WithRequestEditorFn allows setting up a callback function, which will be +// called right before sending the request. This can be used to mutate the request. +func WithRequestEditorFn(fn RequestEditorFn) ClientOption { + return func(c *Client) error { + c.RequestEditors = append(c.RequestEditors, fn) + return nil + } +} + +// The interface specification for the client above. +type ClientInterface interface { +} + +func (c *Client) applyEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error { + for _, r := range c.RequestEditors { + if err := r(ctx, req); err != nil { + return err + } + } + for _, r := range additionalEditors { + if err := r(ctx, req); err != nil { + return err + } + } + return nil +} + +// ClientWithResponses builds on ClientInterface to offer response payloads +type ClientWithResponses struct { + ClientInterface +} + +// NewClientWithResponses creates a new ClientWithResponses, which wraps +// Client with return type handling +func NewClientWithResponses(server string, opts ...ClientOption) (*ClientWithResponses, error) { + client, err := NewClient(server, opts...) + if err != nil { + return nil, err + } + return &ClientWithResponses{client}, nil +} + +// WithBaseURL overrides the baseURL. +func WithBaseURL(baseURL string) ClientOption { + return func(c *Client) error { + newBaseURL, err := url.Parse(baseURL) + if err != nil { + return err + } + c.Server = newBaseURL.String() + return nil + } +} + +// ClientWithResponsesInterface is the interface specification for the client with responses above. +type ClientWithResponsesInterface interface { +} + +// WebhookInitiator sends OpenAPI 3.1 webhook requests to target URLs. +// Modeled on the generated Client, but with no stored Server -- the full +// target URL is provided per-call by the caller (typically discovered +// from a subscription registration). +type WebhookInitiator struct { + // Doer for performing requests, typically a *http.Client with any + // customized settings, such as certificate chains. + Client HttpRequestDoer + + // A list of callbacks for modifying requests which are generated before sending over + // the network. + RequestEditors []RequestEditorFn +} + +// WebhookInitiatorOption allows setting custom parameters during construction. +type WebhookInitiatorOption func(*WebhookInitiator) error + +// NewWebhookInitiator creates a new WebhookInitiator with reasonable defaults. +func NewWebhookInitiator(opts ...WebhookInitiatorOption) (*WebhookInitiator, error) { + initiator := WebhookInitiator{} + for _, o := range opts { + if err := o(&initiator); err != nil { + return nil, err + } + } + if initiator.Client == nil { + initiator.Client = &http.Client{} + } + return &initiator, nil +} + +// WithWebhookHTTPClient allows overriding the default Doer, which is +// automatically created using http.Client. This is useful for tests. +func WithWebhookHTTPClient(doer HttpRequestDoer) WebhookInitiatorOption { + return func(p *WebhookInitiator) error { + p.Client = doer + return nil + } +} + +// WithWebhookRequestEditorFn allows setting up a callback function, which +// will be called right before sending the webhook request. This can be +// used to mutate the request, e.g. to add signature headers. +func WithWebhookRequestEditorFn(fn RequestEditorFn) WebhookInitiatorOption { + return func(p *WebhookInitiator) error { + p.RequestEditors = append(p.RequestEditors, fn) + return nil + } +} + +func (p *WebhookInitiator) applyWebhookEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error { + for _, r := range p.RequestEditors { + if err := r(ctx, req); err != nil { + return err + } + } + for _, r := range additionalEditors { + if err := r(ctx, req); err != nil { + return err + } + } + return nil +} + +// WebhookInitiatorInterface is the interface specification for the webhook initiator. +type WebhookInitiatorInterface interface { + // PetStatusChangedWithBody fires the petStatusChanged webhook with any body + PetStatusChangedWithBody(ctx context.Context, targetURL string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + PetStatusChanged(ctx context.Context, targetURL string, body PetStatusChangedJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) +} + +func (p *WebhookInitiator) PetStatusChangedWithBody(ctx context.Context, targetURL string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPetStatusChangedWebhookRequestWithBody(targetURL, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := p.applyWebhookEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return p.Client.Do(req) +} + +func (p *WebhookInitiator) PetStatusChanged(ctx context.Context, targetURL string, body PetStatusChangedJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPetStatusChangedWebhookRequest(targetURL, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := p.applyWebhookEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return p.Client.Do(req) +} + +// NewPetStatusChangedWebhookRequest builds a application/json POST request for the petStatusChanged webhook +func NewPetStatusChangedWebhookRequest(targetURL string, body PetStatusChangedJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewPetStatusChangedWebhookRequestWithBody(targetURL, "application/json", bodyReader) +} + +// NewPetStatusChangedWebhookRequestWithBody builds a POST request for the petStatusChanged webhook with any body +func NewPetStatusChangedWebhookRequestWithBody(targetURL string, contentType string, body io.Reader) (*http.Request, error) { + var err error + _ = err + + reqURL, err := url.Parse(targetURL) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, reqURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// ServerInterface represents all server handlers. +type ServerInterface interface { +} + +// ServerInterfaceWrapper converts echo contexts to parameters. +type ServerInterfaceWrapper struct { + Handler ServerInterface +} + +type MiddlewareFunc iris.Handler + +// IrisServerOption is the option for iris server +type IrisServerOptions struct { + BaseURL string + Middlewares []MiddlewareFunc +} + +// RegisterHandlers creates http.Handler with routing matching OpenAPI spec. +func RegisterHandlers(router *iris.Application, si ServerInterface) { + RegisterHandlersWithOptions(router, si, IrisServerOptions{}) +} + +// RegisterHandlersWithOptions creates http.Handler with additional options +func RegisterHandlersWithOptions(router *iris.Application, si ServerInterface, options IrisServerOptions) { + + router.Build() +} + +// WebhookReceiverInterface represents handlers for receiving inbound +// webhook requests. Each webhook becomes a Handle*Webhook +// method that the implementation fills in. The caller mounts the per- +// webhook iris.Handler returned by {Op}WebhookHandler at +// whatever URL path they advertise to senders. +type WebhookReceiverInterface interface { + // Notifies subscribers that a pet's status changed. + // HandlePetStatusChangedWebhook handles the POST webhook for petStatusChanged. + HandlePetStatusChangedWebhook(ctx iris.Context) +} + +// PetStatusChangedWebhookHandler returns the iris.Handler for the petStatusChanged webhook. +// Mount this at the URL path advertised to webhook senders. +// Parameter-binding errors set status 400 with a plain-text reason. +// Per-handler middleware is not generated; iris's idiom prefers +// app.Use() / Party-level middleware. +func PetStatusChangedWebhookHandler(si WebhookReceiverInterface) iris.Handler { + return func(ctx iris.Context) { + si.HandlePetStatusChangedWebhook(ctx) + } +} diff --git a/internal/test/webhooks/spec.yaml b/internal/test/webhooks/spec.yaml new file mode 100644 index 0000000000..3c95b029fc --- /dev/null +++ b/internal/test/webhooks/spec.yaml @@ -0,0 +1,35 @@ +openapi: 3.1.0 +info: + title: Webhooks test + version: 1.0.0 + description: | + Verifies the OpenAPI 3.1 webhook codegen end-to-end: a service that + emits PetStatusChanged webhooks via WebhookInitiator, and a + subscriber that receives them via WebhookReceiverInterface. +paths: {} +webhooks: + petStatusChanged: + post: + operationId: PetStatusChanged + summary: Notifies subscribers that a pet's status changed. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PetStatusEvent' + responses: + '204': + description: Acknowledged + +components: + schemas: + PetStatusEvent: + type: object + required: [id, status] + properties: + id: + type: string + status: + type: string + enum: [available, pending, sold] diff --git a/internal/test/webhooks/stdhttp/config.yaml b/internal/test/webhooks/stdhttp/config.yaml new file mode 100644 index 0000000000..7dfee142d4 --- /dev/null +++ b/internal/test/webhooks/stdhttp/config.yaml @@ -0,0 +1,9 @@ +# yaml-language-server: $schema=../../../../configuration-schema.json +package: stdhttp +generate: + models: true + client: true + std-http-server: true +output-options: + skip-prune: true +output: webhooks.gen.go diff --git a/internal/test/webhooks/stdhttp/doc.go b/internal/test/webhooks/stdhttp/doc.go new file mode 100644 index 0000000000..e87c9d0a30 --- /dev/null +++ b/internal/test/webhooks/stdhttp/doc.go @@ -0,0 +1,8 @@ +// Package stdhttp verifies OpenAPI 3.1 webhook codegen end-to-end for +// the stdhttp server flavor: the generated WebhookInitiator fires a +// webhook against a httptest server, and the generated +// WebhookReceiverInterface handler receives it. The test asserts the +// payload round-trips intact. +package stdhttp + +//go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen --config=config.yaml ../spec.yaml diff --git a/internal/test/webhooks/stdhttp/webhooks.gen.go b/internal/test/webhooks/stdhttp/webhooks.gen.go new file mode 100644 index 0000000000..5754d79a68 --- /dev/null +++ b/internal/test/webhooks/stdhttp/webhooks.gen.go @@ -0,0 +1,462 @@ +//go:build go1.22 + +// Package stdhttp provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.0.0-00010101000000-000000000000 DO NOT EDIT. +package stdhttp + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" +) + +// Defines values for PetStatusEventStatus. +const ( + Available PetStatusEventStatus = "available" + Pending PetStatusEventStatus = "pending" + Sold PetStatusEventStatus = "sold" +) + +// Valid indicates whether the value is a known member of the PetStatusEventStatus enum. +func (e PetStatusEventStatus) Valid() bool { + switch e { + case Available: + return true + case Pending: + return true + case Sold: + return true + default: + return false + } +} + +// PetStatusEvent defines model for PetStatusEvent. +type PetStatusEvent struct { + Id string `json:"id"` + Status PetStatusEventStatus `json:"status"` +} + +// PetStatusEventStatus defines model for PetStatusEvent.Status. +type PetStatusEventStatus string + +// PetStatusChangedJSONRequestBody defines body for PetStatusChanged for application/json ContentType. +type PetStatusChangedJSONRequestBody = PetStatusEvent + +// RequestEditorFn is the function signature for the RequestEditor callback function +type RequestEditorFn func(ctx context.Context, req *http.Request) error + +// Doer performs HTTP requests. +// +// The standard http.Client implements this interface. +type HttpRequestDoer interface { + Do(req *http.Request) (*http.Response, error) +} + +// Client which conforms to the OpenAPI3 specification for this service. +type Client struct { + // The endpoint of the server conforming to this interface, with scheme, + // https://api.deepmap.com for example. This can contain a path relative + // to the server, such as https://api.deepmap.com/dev-test, and all the + // paths in the swagger spec will be appended to the server. + Server string + + // Doer for performing requests, typically a *http.Client with any + // customized settings, such as certificate chains. + Client HttpRequestDoer + + // A list of callbacks for modifying requests which are generated before sending over + // the network. + RequestEditors []RequestEditorFn +} + +// ClientOption allows setting custom parameters during construction +type ClientOption func(*Client) error + +// Creates a new Client, with reasonable defaults +func NewClient(server string, opts ...ClientOption) (*Client, error) { + // create a client with sane default values + client := Client{ + Server: server, + } + // mutate client and add all optional params + for _, o := range opts { + if err := o(&client); err != nil { + return nil, err + } + } + // ensure the server URL always has a trailing slash + if !strings.HasSuffix(client.Server, "/") { + client.Server += "/" + } + // create httpClient, if not already present + if client.Client == nil { + client.Client = &http.Client{} + } + return &client, nil +} + +// WithHTTPClient allows overriding the default Doer, which is +// automatically created using http.Client. This is useful for tests. +func WithHTTPClient(doer HttpRequestDoer) ClientOption { + return func(c *Client) error { + c.Client = doer + return nil + } +} + +// WithRequestEditorFn allows setting up a callback function, which will be +// called right before sending the request. This can be used to mutate the request. +func WithRequestEditorFn(fn RequestEditorFn) ClientOption { + return func(c *Client) error { + c.RequestEditors = append(c.RequestEditors, fn) + return nil + } +} + +// The interface specification for the client above. +type ClientInterface interface { +} + +func (c *Client) applyEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error { + for _, r := range c.RequestEditors { + if err := r(ctx, req); err != nil { + return err + } + } + for _, r := range additionalEditors { + if err := r(ctx, req); err != nil { + return err + } + } + return nil +} + +// ClientWithResponses builds on ClientInterface to offer response payloads +type ClientWithResponses struct { + ClientInterface +} + +// NewClientWithResponses creates a new ClientWithResponses, which wraps +// Client with return type handling +func NewClientWithResponses(server string, opts ...ClientOption) (*ClientWithResponses, error) { + client, err := NewClient(server, opts...) + if err != nil { + return nil, err + } + return &ClientWithResponses{client}, nil +} + +// WithBaseURL overrides the baseURL. +func WithBaseURL(baseURL string) ClientOption { + return func(c *Client) error { + newBaseURL, err := url.Parse(baseURL) + if err != nil { + return err + } + c.Server = newBaseURL.String() + return nil + } +} + +// ClientWithResponsesInterface is the interface specification for the client with responses above. +type ClientWithResponsesInterface interface { +} + +// WebhookInitiator sends OpenAPI 3.1 webhook requests to target URLs. +// Modeled on the generated Client, but with no stored Server -- the full +// target URL is provided per-call by the caller (typically discovered +// from a subscription registration). +type WebhookInitiator struct { + // Doer for performing requests, typically a *http.Client with any + // customized settings, such as certificate chains. + Client HttpRequestDoer + + // A list of callbacks for modifying requests which are generated before sending over + // the network. + RequestEditors []RequestEditorFn +} + +// WebhookInitiatorOption allows setting custom parameters during construction. +type WebhookInitiatorOption func(*WebhookInitiator) error + +// NewWebhookInitiator creates a new WebhookInitiator with reasonable defaults. +func NewWebhookInitiator(opts ...WebhookInitiatorOption) (*WebhookInitiator, error) { + initiator := WebhookInitiator{} + for _, o := range opts { + if err := o(&initiator); err != nil { + return nil, err + } + } + if initiator.Client == nil { + initiator.Client = &http.Client{} + } + return &initiator, nil +} + +// WithWebhookHTTPClient allows overriding the default Doer, which is +// automatically created using http.Client. This is useful for tests. +func WithWebhookHTTPClient(doer HttpRequestDoer) WebhookInitiatorOption { + return func(p *WebhookInitiator) error { + p.Client = doer + return nil + } +} + +// WithWebhookRequestEditorFn allows setting up a callback function, which +// will be called right before sending the webhook request. This can be +// used to mutate the request, e.g. to add signature headers. +func WithWebhookRequestEditorFn(fn RequestEditorFn) WebhookInitiatorOption { + return func(p *WebhookInitiator) error { + p.RequestEditors = append(p.RequestEditors, fn) + return nil + } +} + +func (p *WebhookInitiator) applyWebhookEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error { + for _, r := range p.RequestEditors { + if err := r(ctx, req); err != nil { + return err + } + } + for _, r := range additionalEditors { + if err := r(ctx, req); err != nil { + return err + } + } + return nil +} + +// WebhookInitiatorInterface is the interface specification for the webhook initiator. +type WebhookInitiatorInterface interface { + // PetStatusChangedWithBody fires the petStatusChanged webhook with any body + PetStatusChangedWithBody(ctx context.Context, targetURL string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + PetStatusChanged(ctx context.Context, targetURL string, body PetStatusChangedJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) +} + +func (p *WebhookInitiator) PetStatusChangedWithBody(ctx context.Context, targetURL string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPetStatusChangedWebhookRequestWithBody(targetURL, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := p.applyWebhookEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return p.Client.Do(req) +} + +func (p *WebhookInitiator) PetStatusChanged(ctx context.Context, targetURL string, body PetStatusChangedJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPetStatusChangedWebhookRequest(targetURL, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := p.applyWebhookEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return p.Client.Do(req) +} + +// NewPetStatusChangedWebhookRequest builds a application/json POST request for the petStatusChanged webhook +func NewPetStatusChangedWebhookRequest(targetURL string, body PetStatusChangedJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewPetStatusChangedWebhookRequestWithBody(targetURL, "application/json", bodyReader) +} + +// NewPetStatusChangedWebhookRequestWithBody builds a POST request for the petStatusChanged webhook with any body +func NewPetStatusChangedWebhookRequestWithBody(targetURL string, contentType string, body io.Reader) (*http.Request, error) { + var err error + _ = err + + reqURL, err := url.Parse(targetURL) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, reqURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// ServerInterface represents all server handlers. +type ServerInterface interface { +} + +// ServerInterfaceWrapper converts contexts to parameters. +type ServerInterfaceWrapper struct { + Handler ServerInterface + HandlerMiddlewares []MiddlewareFunc + ErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error) +} + +type MiddlewareFunc func(http.Handler) http.Handler + +type UnescapedCookieParamError struct { + ParamName string + Err error +} + +func (e *UnescapedCookieParamError) Error() string { + return fmt.Sprintf("error unescaping cookie parameter '%s'", e.ParamName) +} + +func (e *UnescapedCookieParamError) Unwrap() error { + return e.Err +} + +type UnmarshalingParamError struct { + ParamName string + Err error +} + +func (e *UnmarshalingParamError) Error() string { + return fmt.Sprintf("Error unmarshaling parameter %s as JSON: %s", e.ParamName, e.Err.Error()) +} + +func (e *UnmarshalingParamError) Unwrap() error { + return e.Err +} + +type RequiredParamError struct { + ParamName string +} + +func (e *RequiredParamError) Error() string { + return fmt.Sprintf("Query argument %s is required, but not found", e.ParamName) +} + +type RequiredHeaderError struct { + ParamName string + Err error +} + +func (e *RequiredHeaderError) Error() string { + return fmt.Sprintf("Header parameter %s is required, but not found", e.ParamName) +} + +func (e *RequiredHeaderError) Unwrap() error { + return e.Err +} + +type InvalidParamFormatError struct { + ParamName string + Err error +} + +func (e *InvalidParamFormatError) Error() string { + return fmt.Sprintf("Invalid format for parameter %s: %s", e.ParamName, e.Err.Error()) +} + +func (e *InvalidParamFormatError) Unwrap() error { + return e.Err +} + +type TooManyValuesForParamError struct { + ParamName string + Count int +} + +func (e *TooManyValuesForParamError) Error() string { + return fmt.Sprintf("Expected one value for %s, got %d", e.ParamName, e.Count) +} + +// Handler creates http.Handler with routing matching OpenAPI spec. +func Handler(si ServerInterface) http.Handler { + return HandlerWithOptions(si, StdHTTPServerOptions{}) +} + +// ServeMux is an abstraction of [http.ServeMux]. +type ServeMux interface { + HandleFunc(pattern string, handler func(http.ResponseWriter, *http.Request)) + http.Handler +} + +type StdHTTPServerOptions struct { + BaseURL string + BaseRouter ServeMux + Middlewares []MiddlewareFunc + ErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error) +} + +// HandlerFromMux creates http.Handler with routing matching OpenAPI spec based on the provided mux. +func HandlerFromMux(si ServerInterface, m ServeMux) http.Handler { + return HandlerWithOptions(si, StdHTTPServerOptions{ + BaseRouter: m, + }) +} + +func HandlerFromMuxWithBaseURL(si ServerInterface, m ServeMux, baseURL string) http.Handler { + return HandlerWithOptions(si, StdHTTPServerOptions{ + BaseURL: baseURL, + BaseRouter: m, + }) +} + +// HandlerWithOptions creates http.Handler with additional options +func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.Handler { + m := options.BaseRouter + + if m == nil { + m = http.NewServeMux() + } + if options.ErrorHandlerFunc == nil { + options.ErrorHandlerFunc = func(w http.ResponseWriter, r *http.Request, err error) { + http.Error(w, err.Error(), http.StatusBadRequest) + } + } + + return m +} + +// WebhookReceiverInterface represents handlers for receiving inbound +// webhook requests. Each webhook becomes a Handle*Webhook +// method that the implementation fills in. The caller mounts the per- +// webhook http.Handler returned by {Op}WebhookHandler at +// whatever URL path they advertise to senders. +type WebhookReceiverInterface interface { + // Notifies subscribers that a pet's status changed. + // HandlePetStatusChangedWebhook handles the POST webhook for petStatusChanged. + HandlePetStatusChangedWebhook(w http.ResponseWriter, r *http.Request) +} + +// WebhookReceiverMiddlewareFunc wraps an http.Handler with cross- +// cutting behavior (signature verification, logging, rate limiting, ...). +type WebhookReceiverMiddlewareFunc func(http.Handler) http.Handler + +// PetStatusChangedWebhookHandler returns the http.Handler for the petStatusChanged webhook. +// Mount this at the URL path advertised to webhook senders. errHandler +// may be nil; if so, parameter-binding errors return 400 with the error +// message. Middlewares are applied in the order provided -- the last +// argument becomes the outermost wrapper. +func PetStatusChangedWebhookHandler(si WebhookReceiverInterface, errHandler func(w http.ResponseWriter, r *http.Request, err error), middlewares ...WebhookReceiverMiddlewareFunc) http.Handler { + if errHandler == nil { + errHandler = func(w http.ResponseWriter, r *http.Request, err error) { + http.Error(w, err.Error(), http.StatusBadRequest) + } + } + var h http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + si.HandlePetStatusChangedWebhook(w, r) + }) + for _, mw := range middlewares { + h = mw(h) + } + return h +} diff --git a/internal/test/webhooks/stdhttp/webhooks_test.go b/internal/test/webhooks/stdhttp/webhooks_test.go new file mode 100644 index 0000000000..30e8091244 --- /dev/null +++ b/internal/test/webhooks/stdhttp/webhooks_test.go @@ -0,0 +1,158 @@ +// Package stdhttp tests the OpenAPI 3.1 webhook codegen end-to-end: +// the WebhookInitiator fires a webhook against an httptest.Server that +// registers the WebhookReceiverInterface handler, and the test asserts +// the payload round-trips intact. +package stdhttp + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// fakeReceiver captures whatever the handler is given so the test can +// assert what arrived. +type fakeReceiver struct { + gotMethod string + gotContentType string + gotEvent PetStatusEvent + called bool +} + +func (f *fakeReceiver) HandlePetStatusChangedWebhook(w http.ResponseWriter, r *http.Request) { + f.called = true + f.gotMethod = r.Method + f.gotContentType = r.Header.Get("Content-Type") + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + defer func() { _ = r.Body.Close() }() + if err := json.Unmarshal(body, &f.gotEvent); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func TestWebhookRoundTrip(t *testing.T) { + receiver := &fakeReceiver{} + + // Mount the generated factory at a test path. In real usage callers + // pick whatever URL they advertise to subscribers; the factory is + // path-agnostic so the test can pick anything. + mux := http.NewServeMux() + mux.Handle("POST /hooks/pet-status", PetStatusChangedWebhookHandler(receiver, nil)) + srv := httptest.NewServer(mux) + defer srv.Close() + + initiator, err := NewWebhookInitiator() + require.NoError(t, err) + + event := PetStatusEvent{ + Id: "pet-42", + Status: Sold, + } + + resp, err := initiator.PetStatusChanged(context.Background(), srv.URL+"/hooks/pet-status", event) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + + require.Equal(t, http.StatusNoContent, resp.StatusCode) + require.True(t, receiver.called, "webhook handler should have been called") + assert.Equal(t, "POST", receiver.gotMethod) + assert.Equal(t, "application/json", receiver.gotContentType) + assert.Equal(t, event, receiver.gotEvent) +} + +// TestWebhookInitiatorRequestEditor verifies WithWebhookRequestEditorFn +// composes onto every request, mirroring the path Client's RequestEditor +// behavior. This is the integration-level assertion that webhook-side +// middleware support is structurally identical to client-side. +func TestWebhookInitiatorRequestEditor(t *testing.T) { + const sigHeader = "X-Webhook-Signature" + const sigValue = "t=1234,v1=deadbeef" + + receiver := &capturingReceiver{} + mux := http.NewServeMux() + mux.Handle("POST /hooks", PetStatusChangedWebhookHandler(receiver, nil)) + srv := httptest.NewServer(mux) + defer srv.Close() + + initiator, err := NewWebhookInitiator( + WithWebhookRequestEditorFn(func(ctx context.Context, req *http.Request) error { + req.Header.Set(sigHeader, sigValue) + return nil + }), + ) + require.NoError(t, err) + + resp, err := initiator.PetStatusChanged(context.Background(), srv.URL+"/hooks", PetStatusEvent{ + Id: "pet-1", + Status: Available, + }) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + + assert.Equal(t, sigValue, receiver.gotHeaders.Get(sigHeader), + "per-call editor was not applied to the outgoing request") +} + +type capturingReceiver struct { + gotHeaders http.Header +} + +func (c *capturingReceiver) HandlePetStatusChangedWebhook(w http.ResponseWriter, r *http.Request) { + c.gotHeaders = r.Header.Clone() + w.WriteHeader(http.StatusNoContent) +} + +// TestWebhookReceiverMiddleware verifies middlewares wrap the handler in +// declared order (outermost first), matching the standard handler +// composition convention. +func TestWebhookReceiverMiddleware(t *testing.T) { + var order []string + mw := func(name string) WebhookReceiverMiddlewareFunc { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + order = append(order, name+":pre") + next.ServeHTTP(w, r) + order = append(order, name+":post") + }) + } + } + + mux := http.NewServeMux() + mux.Handle("POST /hooks", + PetStatusChangedWebhookHandler(&capturingReceiver{}, nil, mw("outer"), mw("inner"))) + srv := httptest.NewServer(mux) + defer srv.Close() + + initiator, err := NewWebhookInitiator() + require.NoError(t, err) + resp, err := initiator.PetStatusChanged(context.Background(), srv.URL+"/hooks", PetStatusEvent{ + Id: "x", Status: Pending, + }) + require.NoError(t, err) + _ = resp.Body.Close() + + // Middlewares are applied in order with the LAST argument becoming + // the OUTERMOST wrapper (each iteration assigns h = mw(h)). So we + // expect: inner:pre -> outer:pre is the wrong order; check the + // actual implementation's intent. + // + // In our generated factory: + // for _, mw := range middlewares { h = mw(h) } + // the final mw becomes outermost. So with ("outer", "inner") the + // "inner" middleware is the outermost wrapper. + assert.Equal(t, + []string{"inner:pre", "outer:pre", "outer:post", "inner:post"}, + order) +} diff --git a/pkg/codegen/codegen.go b/pkg/codegen/codegen.go index 6d57c487f4..7b890d3a08 100644 --- a/pkg/codegen/codegen.go +++ b/pkg/codegen/codegen.go @@ -49,8 +49,16 @@ var templates embed.FS // globalState stores all global state. Please don't put global state anywhere // else so that we can easily track it. var globalState struct { - options Configuration - spec *openapi3.T + options Configuration + spec *openapi3.T + // is31 is true when the loaded spec declares OpenAPI version >=3.1. + // All version-aware behavior (e.g. nullable detection, webhook emission) + // reads from this field. Do NOT expose this field to templates -- + // templates are version-blind and consume pre-computed derived fields + // (e.g. Schema.Nullable) populated by version-aware Go helpers. + // Adding `is31` to TemplateFunctions or branching on the OpenAPI + // version inside a template is a layering violation. + is31 bool importMapping importMap // initialismsMap stores initialisms as "lower(initialism) -> initialism" map. // List of initialisms was taken from https://staticcheck.io/docs/configuration/options/#initialisms. @@ -150,6 +158,7 @@ func Generate(spec *openapi3.T, opts Configuration) (string, error) { // This is global state globalState.options = opts globalState.spec = spec + globalState.is31 = spec.IsOpenAPI31OrLater() globalState.importMapping = constructImportMapping(opts.ImportMapping) if opts.OutputOptions.TypeMapping != nil { globalState.typeMapping = DefaultTypeMapping.Merge(*opts.OutputOptions.TypeMapping) @@ -245,7 +254,29 @@ func Generate(spec *openapi3.T, opts Configuration) (string, error) { return "", fmt.Errorf("error creating operation definitions: %w", err) } - xGoTypeImports, err := OperationImports(ops) + // Webhooks (OpenAPI 3.1+) flow through the same OperationDefinition + // shape as paths but render via webhook-specific templates. The + // gather walks swagger.Webhooks unconditionally -- the map is only + // populated by kin-openapi for 3.1+ documents, so 3.0 specs return + // an empty slice naturally. allOps is used for cross-cutting passes + // (type defs, import gathering); ops and webhookOps are passed + // separately to path-vs-webhook template generators. + webhookOps, err := WebhookOperationDefinitions(spec) + if err != nil { + return "", fmt.Errorf("error creating webhook operation definitions: %w", err) + } + + // Callbacks (OpenAPI 3.0+) are nested under path operations. Like + // webhooks they render via initiator/receiver templates, but they + // are NOT version-gated -- callbacks have been part of the spec + // since 3.0. Gather is no-op for specs without any callbacks. + callbackOps, err := CallbackOperationDefinitions(spec) + if err != nil { + return "", fmt.Errorf("error creating callback operation definitions: %w", err) + } + allOps := append(append(append([]OperationDefinition{}, ops...), webhookOps...), callbackOps...) + + xGoTypeImports, err := OperationImports(allOps) if err != nil { return "", fmt.Errorf("error getting operation imports: %w", err) } @@ -267,16 +298,18 @@ func Generate(spec *openapi3.T, opts Configuration) (string, error) { return "", fmt.Errorf("error generating code for type definitions: %w", err) } - opTypes, err := collectOperationTypes(ops) + // Pass allOps (regular paths + webhooks + callbacks) so op-derived + // types from webhook/callback operations are emitted too. + opTypes, err := collectOperationTypes(allOps) if err != nil { return "", fmt.Errorf("error collecting operation types: %w", err) } - opDecls, err := GenerateTypesForOperations(t, ops) + opDecls, err := GenerateTypesForOperations(t, allOps) if err != nil { return "", fmt.Errorf("error generating Go types for operations: %w", err) } - constantDefinitions, err = GenerateConstants(t, ops) + constantDefinitions, err = GenerateConstants(t, allOps) if err != nil { return "", fmt.Errorf("error generating constants: %w", err) } @@ -429,6 +462,179 @@ func Generate(spec *openapi3.T, opts Configuration) (string, error) { } } + // Webhook initiator pairs with the path Client. Emitted only when + // Generate.Client is on AND the spec has webhooks (3.1+). + var webhookInitiatorOut string + if opts.Generate.Client && len(webhookOps) > 0 { + webhookInitiatorOut, err = GenerateWebhookInitiator(t, webhookOps) + if err != nil { + return "", fmt.Errorf("error generating webhook initiator: %w", err) + } + } + + // Webhook receiver (stdhttp) pairs with the path StdHTTPServer. + // Emitted only when Generate.StdHTTPServer is on AND the spec has + // webhooks (3.1+). Uses the unified stdhttp receiver template, + // parameterized with prefix "Webhook". + var stdHTTPWebhookReceiverOut string + if opts.Generate.StdHTTPServer && len(webhookOps) > 0 { + stdHTTPWebhookReceiverOut, err = GenerateStdHTTPReceiver(t, "Webhook", webhookOps) + if err != nil { + return "", fmt.Errorf("error generating stdhttp webhook receiver: %w", err) + } + } + + // Webhook receiver (chi) -- chi shares stdhttp's (w, r) handler + // signature, so the receiver shape is identical; only the template + // path differs. Emitted only when Generate.ChiServer is on. + var chiWebhookReceiverOut string + if opts.Generate.ChiServer && len(webhookOps) > 0 { + chiWebhookReceiverOut, err = GenerateChiReceiver(t, "Webhook", webhookOps) + if err != nil { + return "", fmt.Errorf("error generating chi webhook receiver: %w", err) + } + } + + // Webhook receiver (gorilla/mux) -- same (w, r) signature as + // stdhttp/chi. + var gorillaWebhookReceiverOut string + if opts.Generate.GorillaServer && len(webhookOps) > 0 { + gorillaWebhookReceiverOut, err = GenerateGorillaReceiver(t, "Webhook", webhookOps) + if err != nil { + return "", fmt.Errorf("error generating gorilla webhook receiver: %w", err) + } + } + + // Webhook receiver (echo v4) -- (ctx echo.Context) error signature. + var echoWebhookReceiverOut string + if opts.Generate.EchoServer && len(webhookOps) > 0 { + echoWebhookReceiverOut, err = GenerateEchoReceiver(t, "Webhook", webhookOps) + if err != nil { + return "", fmt.Errorf("error generating echo webhook receiver: %w", err) + } + } + + // Webhook receiver (echo v5) -- (ctx *echo.Context) error signature. + var echo5WebhookReceiverOut string + if opts.Generate.Echo5Server && len(webhookOps) > 0 { + echo5WebhookReceiverOut, err = GenerateEcho5Receiver(t, "Webhook", webhookOps) + if err != nil { + return "", fmt.Errorf("error generating echo5 webhook receiver: %w", err) + } + } + + // Webhook receiver (gin) -- (c *gin.Context) signature. + var ginWebhookReceiverOut string + if opts.Generate.GinServer && len(webhookOps) > 0 { + ginWebhookReceiverOut, err = GenerateGinReceiver(t, "Webhook", webhookOps) + if err != nil { + return "", fmt.Errorf("error generating gin webhook receiver: %w", err) + } + } + + // Webhook receiver (fiber v2) -- (c *fiber.Ctx) error signature. + var fiberWebhookReceiverOut string + if opts.Generate.FiberServer && len(webhookOps) > 0 { + fiberWebhookReceiverOut, err = GenerateFiberReceiver(t, "Webhook", webhookOps) + if err != nil { + return "", fmt.Errorf("error generating fiber webhook receiver: %w", err) + } + } + + // Webhook receiver (iris) -- (ctx iris.Context) signature. + var irisWebhookReceiverOut string + if opts.Generate.IrisServer && len(webhookOps) > 0 { + irisWebhookReceiverOut, err = GenerateIrisReceiver(t, "Webhook", webhookOps) + if err != nil { + return "", fmt.Errorf("error generating iris webhook receiver: %w", err) + } + } + + // Callback initiator pairs with the path Client. Emitted whenever + // Generate.Client is on and the spec declares any callbacks -- + // callbacks predate 3.1 so this is not version-gated. + var callbackInitiatorOut string + if opts.Generate.Client && len(callbackOps) > 0 { + callbackInitiatorOut, err = GenerateCallbackInitiator(t, callbackOps) + if err != nil { + return "", fmt.Errorf("error generating callback initiator: %w", err) + } + } + + // Callback receiver (stdhttp) pairs with the path StdHTTPServer. + // Same reasoning as the initiator: not version-gated. Uses the + // unified stdhttp receiver template with prefix "Callback". + var stdHTTPCallbackReceiverOut string + if opts.Generate.StdHTTPServer && len(callbackOps) > 0 { + stdHTTPCallbackReceiverOut, err = GenerateStdHTTPReceiver(t, "Callback", callbackOps) + if err != nil { + return "", fmt.Errorf("error generating stdhttp callback receiver: %w", err) + } + } + + // Callback receiver (chi). + var chiCallbackReceiverOut string + if opts.Generate.ChiServer && len(callbackOps) > 0 { + chiCallbackReceiverOut, err = GenerateChiReceiver(t, "Callback", callbackOps) + if err != nil { + return "", fmt.Errorf("error generating chi callback receiver: %w", err) + } + } + + // Callback receiver (gorilla/mux). + var gorillaCallbackReceiverOut string + if opts.Generate.GorillaServer && len(callbackOps) > 0 { + gorillaCallbackReceiverOut, err = GenerateGorillaReceiver(t, "Callback", callbackOps) + if err != nil { + return "", fmt.Errorf("error generating gorilla callback receiver: %w", err) + } + } + + // Callback receiver (echo v4). + var echoCallbackReceiverOut string + if opts.Generate.EchoServer && len(callbackOps) > 0 { + echoCallbackReceiverOut, err = GenerateEchoReceiver(t, "Callback", callbackOps) + if err != nil { + return "", fmt.Errorf("error generating echo callback receiver: %w", err) + } + } + + // Callback receiver (echo v5). + var echo5CallbackReceiverOut string + if opts.Generate.Echo5Server && len(callbackOps) > 0 { + echo5CallbackReceiverOut, err = GenerateEcho5Receiver(t, "Callback", callbackOps) + if err != nil { + return "", fmt.Errorf("error generating echo5 callback receiver: %w", err) + } + } + + // Callback receiver (gin). + var ginCallbackReceiverOut string + if opts.Generate.GinServer && len(callbackOps) > 0 { + ginCallbackReceiverOut, err = GenerateGinReceiver(t, "Callback", callbackOps) + if err != nil { + return "", fmt.Errorf("error generating gin callback receiver: %w", err) + } + } + + // Callback receiver (fiber v2). + var fiberCallbackReceiverOut string + if opts.Generate.FiberServer && len(callbackOps) > 0 { + fiberCallbackReceiverOut, err = GenerateFiberReceiver(t, "Callback", callbackOps) + if err != nil { + return "", fmt.Errorf("error generating fiber callback receiver: %w", err) + } + } + + // Callback receiver (iris). + var irisCallbackReceiverOut string + if opts.Generate.IrisServer && len(callbackOps) > 0 { + irisCallbackReceiverOut, err = GenerateIrisReceiver(t, "Callback", callbackOps) + if err != nil { + return "", fmt.Errorf("error generating iris callback receiver: %w", err) + } + } + var inlinedSpec string if opts.Generate.EmbeddedSpec { inlinedSpec, err = GenerateInlinedSpec(t, globalState.importMapping, spec) @@ -480,6 +686,18 @@ func Generate(spec *openapi3.T, opts Configuration) (string, error) { if err != nil { return "", fmt.Errorf("error writing client: %w", err) } + if webhookInitiatorOut != "" { + _, err = w.WriteString(webhookInitiatorOut) + if err != nil { + return "", fmt.Errorf("error writing webhook initiator: %w", err) + } + } + if callbackInitiatorOut != "" { + _, err = w.WriteString(callbackInitiatorOut) + if err != nil { + return "", fmt.Errorf("error writing callback initiator: %w", err) + } + } } if opts.Generate.IrisServer { @@ -487,7 +705,18 @@ func Generate(spec *openapi3.T, opts Configuration) (string, error) { if err != nil { return "", fmt.Errorf("error writing server path handlers: %w", err) } - + if irisWebhookReceiverOut != "" { + _, err = w.WriteString(irisWebhookReceiverOut) + if err != nil { + return "", fmt.Errorf("error writing iris webhook receiver: %w", err) + } + } + if irisCallbackReceiverOut != "" { + _, err = w.WriteString(irisCallbackReceiverOut) + if err != nil { + return "", fmt.Errorf("error writing iris callback receiver: %w", err) + } + } } if opts.Generate.EchoServer { @@ -495,6 +724,18 @@ func Generate(spec *openapi3.T, opts Configuration) (string, error) { if err != nil { return "", fmt.Errorf("error writing server path handlers: %w", err) } + if echoWebhookReceiverOut != "" { + _, err = w.WriteString(echoWebhookReceiverOut) + if err != nil { + return "", fmt.Errorf("error writing echo webhook receiver: %w", err) + } + } + if echoCallbackReceiverOut != "" { + _, err = w.WriteString(echoCallbackReceiverOut) + if err != nil { + return "", fmt.Errorf("error writing echo callback receiver: %w", err) + } + } } if opts.Generate.Echo5Server { @@ -502,6 +743,18 @@ func Generate(spec *openapi3.T, opts Configuration) (string, error) { if err != nil { return "", fmt.Errorf("error writing server path handlers: %w", err) } + if echo5WebhookReceiverOut != "" { + _, err = w.WriteString(echo5WebhookReceiverOut) + if err != nil { + return "", fmt.Errorf("error writing echo5 webhook receiver: %w", err) + } + } + if echo5CallbackReceiverOut != "" { + _, err = w.WriteString(echo5CallbackReceiverOut) + if err != nil { + return "", fmt.Errorf("error writing echo5 callback receiver: %w", err) + } + } } if opts.Generate.ChiServer { @@ -509,6 +762,18 @@ func Generate(spec *openapi3.T, opts Configuration) (string, error) { if err != nil { return "", fmt.Errorf("error writing server path handlers: %w", err) } + if chiWebhookReceiverOut != "" { + _, err = w.WriteString(chiWebhookReceiverOut) + if err != nil { + return "", fmt.Errorf("error writing chi webhook receiver: %w", err) + } + } + if chiCallbackReceiverOut != "" { + _, err = w.WriteString(chiCallbackReceiverOut) + if err != nil { + return "", fmt.Errorf("error writing chi callback receiver: %w", err) + } + } } if opts.Generate.FiberServer { @@ -516,6 +781,18 @@ func Generate(spec *openapi3.T, opts Configuration) (string, error) { if err != nil { return "", fmt.Errorf("error writing server path handlers: %w", err) } + if fiberWebhookReceiverOut != "" { + _, err = w.WriteString(fiberWebhookReceiverOut) + if err != nil { + return "", fmt.Errorf("error writing fiber webhook receiver: %w", err) + } + } + if fiberCallbackReceiverOut != "" { + _, err = w.WriteString(fiberCallbackReceiverOut) + if err != nil { + return "", fmt.Errorf("error writing fiber callback receiver: %w", err) + } + } } if opts.Generate.GinServer { @@ -523,6 +800,18 @@ func Generate(spec *openapi3.T, opts Configuration) (string, error) { if err != nil { return "", fmt.Errorf("error writing server path handlers: %w", err) } + if ginWebhookReceiverOut != "" { + _, err = w.WriteString(ginWebhookReceiverOut) + if err != nil { + return "", fmt.Errorf("error writing gin webhook receiver: %w", err) + } + } + if ginCallbackReceiverOut != "" { + _, err = w.WriteString(ginCallbackReceiverOut) + if err != nil { + return "", fmt.Errorf("error writing gin callback receiver: %w", err) + } + } } if opts.Generate.GorillaServer { @@ -530,6 +819,18 @@ func Generate(spec *openapi3.T, opts Configuration) (string, error) { if err != nil { return "", fmt.Errorf("error writing server path handlers: %w", err) } + if gorillaWebhookReceiverOut != "" { + _, err = w.WriteString(gorillaWebhookReceiverOut) + if err != nil { + return "", fmt.Errorf("error writing gorilla webhook receiver: %w", err) + } + } + if gorillaCallbackReceiverOut != "" { + _, err = w.WriteString(gorillaCallbackReceiverOut) + if err != nil { + return "", fmt.Errorf("error writing gorilla callback receiver: %w", err) + } + } } if opts.Generate.StdHTTPServer { @@ -537,6 +838,18 @@ func Generate(spec *openapi3.T, opts Configuration) (string, error) { if err != nil { return "", fmt.Errorf("error writing server path handlers: %w", err) } + if stdHTTPWebhookReceiverOut != "" { + _, err = w.WriteString(stdHTTPWebhookReceiverOut) + if err != nil { + return "", fmt.Errorf("error writing stdhttp webhook receiver: %w", err) + } + } + if stdHTTPCallbackReceiverOut != "" { + _, err = w.WriteString(stdHTTPCallbackReceiverOut) + if err != nil { + return "", fmt.Errorf("error writing stdhttp callback receiver: %w", err) + } + } } if opts.Generate.Strict { @@ -1463,7 +1776,7 @@ func GoSchemaImports(schemas ...*openapi3.SchemaRef) (map[string]goImport, error } schemaVal := sref.Value - t := schemaVal.Type + t := schemaPrimaryType(schemaVal.Type) if t.Slice() == nil || t.Is("object") { for _, v := range schemaVal.Properties { imprts, err := GoSchemaImports(v) diff --git a/pkg/codegen/configuration.go b/pkg/codegen/configuration.go index c472c6db75..5048c26af6 100644 --- a/pkg/codegen/configuration.go +++ b/pkg/codegen/configuration.go @@ -363,6 +363,12 @@ type OutputOptions struct { // defined constants; set this to true to suppress that method when it // conflicts with user-defined methods of the same name. SkipEnumValidate bool `yaml:"skip-enum-validate,omitempty"` + // SkipEnumViaOneOf disables detection of the OpenAPI 3.1 enum-via-oneOf + // idiom: a schema with `type: string|integer` and `oneOf:` members that + // each carry `const` + `title` will normally be emitted as a Go enum with + // named constants. Set this to true to fall through to the standard union + // generator instead. + SkipEnumViaOneOf bool `yaml:"skip-enum-via-oneof,omitempty"` // Only include operations that have one of these tags. Ignored when empty. IncludeTags []string `yaml:"include-tags,omitempty"` // Exclude operations that have one of these tags. Ignored when empty. diff --git a/pkg/codegen/merge_schemas.go b/pkg/codegen/merge_schemas.go index 3becb5e412..8d8025bbcd 100644 --- a/pkg/codegen/merge_schemas.go +++ b/pkg/codegen/merge_schemas.go @@ -317,19 +317,36 @@ func mergeOpenapiSchemas(s1, s2 openapi3.Schema, allOf bool, seenSchemaRef map[s } result.UniqueItems = s1.UniqueItems - if s1.ExclusiveMin != s2.ExclusiveMin { + if !reflect.DeepEqual(s1.ExclusiveMin, s2.ExclusiveMin) { return openapi3.Schema{}, errors.New("merging two schemas with different ExclusiveMin") } result.ExclusiveMin = s1.ExclusiveMin - if s1.ExclusiveMax != s2.ExclusiveMax { + if !reflect.DeepEqual(s1.ExclusiveMax, s2.ExclusiveMax) { return openapi3.Schema{}, errors.New("merging two schemas with different ExclusiveMax") } result.ExclusiveMax = s1.ExclusiveMax - if s1.Nullable != s2.Nullable { + // Compare nullability via schemaIsNullable so this works the same way + // regardless of spec version: in 3.0 it reads s.Nullable, in 3.1 it + // reads "null" from the type array. Type merging itself is NOT version + // branched -- the equalTypes() check at result.Type assignment above + // uses the same slice-comparison code path in both modes: + // + // 3.0: ["string"] vs ["string"] -> equal -> merged + // 3.1: ["string","null"] vs ["string","null"] -> equal -> merged + // 3.1 mismatch: ["string","null"] vs ["string"] -> length differs + // -> error from + // equalTypes + // + // Because result.Type was already assigned (line 232) and carries any + // "null" entry forward, the merged result is correctly nullable in 3.1 + // without needing to touch result.Nullable. The result.Nullable copy + // below is a no-op in 3.1 (s1.Nullable is always false there) but kept + // for 3.0 correctness, where Nullable is the only nullability carrier. + if schemaIsNullable(&s1) != schemaIsNullable(&s2) { return openapi3.Schema{}, errors.New("merging two schemas with different Nullable") } diff --git a/pkg/codegen/operations.go b/pkg/codegen/operations.go index 3d54b232af..3a150b7e41 100644 --- a/pkg/codegen/operations.go +++ b/pkg/codegen/operations.go @@ -62,7 +62,7 @@ func (pd ParameterDefinition) ZeroValueIsNil() bool { return false } - if pd.Schema.OAPISchema.Type.Is("array") { + if schemaPrimaryType(pd.Schema.OAPISchema.Type).Is("array") { return true } @@ -347,6 +347,24 @@ type OperationDefinition struct { IsAlias bool // True when this path is a $ref alias of another path item AliasTarget string // When IsAlias is true, this is the OperationId of the canonical operation (for route registration to reference the correct wrapper) PathItemRef string // The path item's $ref (if any); used to qualify externally-loaded schemas referenced from this operation's responses + + // IsWebhook is true when this OperationDefinition was sourced from + // spec.Webhooks (OpenAPI 3.1+). Webhook operations have no path + // template; the target URL is supplied per-call by the initiator. + IsWebhook bool + // WebhookName is the spec.Webhooks map key when IsWebhook is true. + WebhookName string + + // IsCallback is true when this OperationDefinition was sourced from + // a parent operation's `callbacks:` block (OpenAPI 3.0+). Callback + // operations have no path template at codegen time; the target URL + // is the runtime callback URL discovered via the spec's callback + // expression (typically a field on the parent operation's request + // body) and is supplied per-call by the initiator. + IsCallback bool + // CallbackName is the parent operation's `callbacks:` map key + // (e.g. "treePlanted") when IsCallback is true. + CallbackName string } // HandlerName returns the OperationId to use when referencing the server-side @@ -370,6 +388,40 @@ func (o *OperationDefinition) MiddlewareKey() string { return o.OperationId } +// SourceName returns WebhookName when IsWebhook, CallbackName when +// IsCallback, or empty otherwise. Templates use this to label the +// emitted handler uniformly without branching on which kind of source +// the operation came from. +func (o OperationDefinition) SourceName() string { + if o.IsWebhook { + return o.WebhookName + } + if o.IsCallback { + return o.CallbackName + } + return "" +} + +// ReceiverTemplateData is the input to the per-framework webhook / +// callback receiver template. Prefix selects between "Webhook" and +// "Callback" (and the lowercase form for prose), so a single template +// per framework handles both kinds. +type ReceiverTemplateData struct { + Prefix string // "Webhook" or "Callback" + PrefixLower string // lowercase form of Prefix, for prose + Operations []OperationDefinition +} + +// NewReceiverTemplateData builds the template input for the given +// prefix ("Webhook" or "Callback") and operation list. +func NewReceiverTemplateData(prefix string, ops []OperationDefinition) ReceiverTemplateData { + return ReceiverTemplateData{ + Prefix: prefix, + PrefixLower: strings.ToLower(prefix), + Operations: ops, + } +} + // Params returns the list of all parameters except Path parameters. Path parameters // are handled differently from the rest, since they're mandatory. func (o *OperationDefinition) Params() []ParameterDefinition { @@ -1108,6 +1160,238 @@ func OperationDefinitions(swagger *openapi3.T) ([]OperationDefinition, error) { return operations, nil } +// WebhookOperationDefinitions extracts OpenAPI 3.1+ webhook operations +// from swagger.Webhooks into the same OperationDefinition shape used for +// path operations, so they flow through the same downstream pipeline +// (body / response generation, type definitions, etc.) but are routed +// to webhook-specific templates. +// +// kin-openapi only populates the Webhooks field for OpenAPI 3.1+ +// documents, so a missing/empty map naturally short-circuits this for +// 3.0 specs without an explicit version check. +// +// The result mirrors OperationDefinitions in structure, minus the +// path-alias logic (webhooks have no path template) and the path- +// parameter extraction (webhooks have no path params). +func WebhookOperationDefinitions(swagger *openapi3.T) ([]OperationDefinition, error) { + var operations []OperationDefinition + if swagger == nil || len(swagger.Webhooks) == 0 { + return operations, nil + } + + for _, webhookName := range SortedMapKeys(swagger.Webhooks) { + pathItem := swagger.Webhooks[webhookName] + if pathItem == nil { + continue + } + + // Path-item-level parameters apply to every method on the + // webhook (rare for webhooks, but honored defensively). + globalParams, err := DescribeParameters(pathItem.Parameters, nil) + if err != nil { + return nil, fmt.Errorf("error describing webhook %q parameters: %w", webhookName, err) + } + + pathOps := pathItem.Operations() + for _, opName := range SortedMapKeys(pathOps) { + op := pathOps[opName] + + // Prefer an explicit operationId on the webhook operation; + // otherwise derive from the webhook map key. Either way, + // run through the configured name normalizer. + operationId := op.OperationID + if operationId == "" { + operationId = webhookName + } + operationId = nameNormalizer(operationId) + operationId = typeNamePrefix(operationId) + operationId + + localParams, err := DescribeParameters(op.Parameters, []string{operationId + "Params"}) + if err != nil { + return nil, fmt.Errorf("error describing webhook %q operation params: %w", webhookName, err) + } + allParams, err := CombineOperationParameters(globalParams, localParams) + if err != nil { + return nil, err + } + + bodyDefinitions, typeDefinitions, err := GenerateBodyDefinitions(operationId, op.RequestBody, pathItem.Ref) + if err != nil { + return nil, fmt.Errorf("error generating body definitions for webhook %q: %w", webhookName, err) + } + + responseDefinitions, err := GenerateResponseDefinitions(operationId, op.Responses.Map(), pathItem.Ref) + if err != nil { + return nil, fmt.Errorf("error generating response definitions for webhook %q: %w", webhookName, err) + } + + opDef := OperationDefinition{ + HeaderParams: FilterParameterDefinitionByType(allParams, "header"), + QueryParams: FilterParameterDefinitionByType(allParams, "query"), + CookieParams: FilterParameterDefinitionByType(allParams, "cookie"), + OperationId: nameNormalizer(operationId), + Summary: op.Summary, + Method: opName, + Path: "", + Spec: op, + Bodies: bodyDefinitions, + Responses: responseDefinitions, + TypeDefinitions: typeDefinitions, + IsWebhook: true, + WebhookName: webhookName, + } + + if op.Security != nil { + opDef.SecurityDefinitions = DescribeSecurityDefinition(*op.Security) + } else { + opDef.SecurityDefinitions = DescribeSecurityDefinition(swagger.Security) + } + + if op.RequestBody != nil { + opDef.BodyRequired = op.RequestBody.Value.Required + } + + opDef.TypeDefinitions = append(opDef.TypeDefinitions, GenerateTypeDefsForOperation(opDef)...) + operations = append(operations, opDef) + } + } + return operations, nil +} + +// CallbackOperationDefinitions extracts OpenAPI callback operations +// from spec.Paths...Callbacks into the same +// OperationDefinition shape used for path operations, so they flow +// through the same downstream pipeline (body / response generation, +// type definitions, etc.) but are routed to callback-specific +// templates. +// +// Callbacks have been part of the OpenAPI spec since 3.0, so this is +// not gated on version: any spec that declares callbacks gets them +// generated. The spec shape: +// +// paths: +// /api/plant_tree: +// post: +// operationId: PlantTree +// callbacks: +// treePlanted: # the callback map key +// '{$request.body#/url}': # the runtime URL expression +// post: +// operationId: TreePlanted +// +// Each leaf operation (the inner `post:` above) becomes one +// OperationDefinition with IsCallback=true and CallbackName set to the +// outer map key ("treePlanted"). The codegen does not interpret the URL +// expression itself -- the caller of the generated CallbackInitiator +// supplies the resolved target URL at runtime (the same way it would +// for a webhook). +func CallbackOperationDefinitions(swagger *openapi3.T) ([]OperationDefinition, error) { + var operations []OperationDefinition + if swagger == nil || swagger.Paths == nil { + return operations, nil + } + + for _, requestPath := range SortedMapKeys(swagger.Paths.Map()) { + pathItem := swagger.Paths.Value(requestPath) + if pathItem == nil { + continue + } + // Iterate path-item operations in sorted method order for + // deterministic output across runs (Operations() returns a + // map; range order is randomized). + parentOps := pathItem.Operations() + for _, parentMethod := range SortedMapKeys(parentOps) { + parentOp := parentOps[parentMethod] + if len(parentOp.Callbacks) == 0 { + continue + } + for _, callbackName := range SortedMapKeys(parentOp.Callbacks) { + cbRef := parentOp.Callbacks[callbackName] + if cbRef == nil || cbRef.Value == nil { + continue + } + cb := cbRef.Value + // A Callback maps URL-expression to PathItem; iterate + // in sorted key order for deterministic output. The + // internal map is private so use the accessor pair. + cbKeys := append([]string(nil), cb.Keys()...) + slices.Sort(cbKeys) + for _, urlExpr := range cbKeys { + cbPathItem := cb.Value(urlExpr) + if cbPathItem == nil { + continue + } + globalParams, err := DescribeParameters(cbPathItem.Parameters, nil) + if err != nil { + return nil, fmt.Errorf("error describing callback %q parameters: %w", callbackName, err) + } + + cbOps := cbPathItem.Operations() + for _, opName := range SortedMapKeys(cbOps) { + op := cbOps[opName] + + operationId := op.OperationID + if operationId == "" { + operationId = callbackName + } + operationId = nameNormalizer(operationId) + operationId = typeNamePrefix(operationId) + operationId + + localParams, err := DescribeParameters(op.Parameters, []string{operationId + "Params"}) + if err != nil { + return nil, fmt.Errorf("error describing callback %q operation params: %w", callbackName, err) + } + allParams, err := CombineOperationParameters(globalParams, localParams) + if err != nil { + return nil, err + } + + bodyDefinitions, typeDefinitions, err := GenerateBodyDefinitions(operationId, op.RequestBody, cbPathItem.Ref) + if err != nil { + return nil, fmt.Errorf("error generating body definitions for callback %q: %w", callbackName, err) + } + + responseDefinitions, err := GenerateResponseDefinitions(operationId, op.Responses.Map(), cbPathItem.Ref) + if err != nil { + return nil, fmt.Errorf("error generating response definitions for callback %q: %w", callbackName, err) + } + + opDef := OperationDefinition{ + HeaderParams: FilterParameterDefinitionByType(allParams, "header"), + QueryParams: FilterParameterDefinitionByType(allParams, "query"), + CookieParams: FilterParameterDefinitionByType(allParams, "cookie"), + OperationId: nameNormalizer(operationId), + Summary: op.Summary, + Method: opName, + Path: "", + Spec: op, + Bodies: bodyDefinitions, + Responses: responseDefinitions, + TypeDefinitions: typeDefinitions, + IsCallback: true, + CallbackName: callbackName, + } + + if op.Security != nil { + opDef.SecurityDefinitions = DescribeSecurityDefinition(*op.Security) + } else { + opDef.SecurityDefinitions = DescribeSecurityDefinition(swagger.Security) + } + + if op.RequestBody != nil { + opDef.BodyRequired = op.RequestBody.Value.Required + } + + opDef.TypeDefinitions = append(opDef.TypeDefinitions, GenerateTypeDefsForOperation(opDef)...) + operations = append(operations, opDef) + } + } + } + } + } + return operations, nil +} + func generateDefaultOperationID(opName string, requestPath string) (string, error) { if opName == "" { return "", fmt.Errorf("operation name cannot be an empty string") @@ -1335,7 +1619,10 @@ func GenerateResponseDefinitions(operationID string, responses map[string]*opena if err != nil { return nil, fmt.Errorf("error generating response header definition: %w", err) } - nullable := header.Value.Schema != nil && header.Value.Schema.Value != nil && header.Value.Schema.Value.Nullable + var nullable bool + if header.Value.Schema != nil { + nullable = schemaIsNullable(header.Value.Schema.Value) + } headerDefinition := ResponseHeaderDefinition{ Name: headerName, GoName: SchemaNameToTypeName(headerName), @@ -1576,6 +1863,93 @@ func GenerateClientWithResponses(t *template.Template, ops []OperationDefinition return GenerateTemplates([]string{"client-with-responses.tmpl"}, t, ops) } +// GenerateWebhookInitiator generates the WebhookInitiator -- the +// client-side analog for OpenAPI 3.1 webhooks. It mirrors the path +// Client (struct + options + per-method calls + request builders) but +// takes the target URL per-call instead of from a stored Server field. +// The caller passes only the webhook OperationDefinitions (gathered via +// WebhookOperationDefinitions); path operations are emitted by the +// regular Client templates. +func GenerateWebhookInitiator(t *template.Template, webhookOps []OperationDefinition) (string, error) { + return GenerateTemplates([]string{"webhook-initiator.tmpl"}, t, webhookOps) +} + +// GenerateCallbackInitiator generates the CallbackInitiator -- the +// client-side analog for OpenAPI callbacks. Structurally identical to +// GenerateWebhookInitiator but takes the callback OperationDefinitions +// gathered via CallbackOperationDefinitions, which walk paths/operations/ +// callbacks rather than spec.Webhooks. +func GenerateCallbackInitiator(t *template.Template, callbackOps []OperationDefinition) (string, error) { + return GenerateTemplates([]string{"callback-initiator.tmpl"}, t, callbackOps) +} + +// GenerateStdHTTPReceiver renders the merged stdhttp receiver template +// (used for both webhook and callback receivers). The caller selects +// between them by passing prefix "Webhook" or "Callback" along with the +// matching OperationDefinitions. The template emits a {Prefix}Receiver +// interface plus per-operation {Op}{Prefix}Handler factories with +// query/header parameter binding inline (matching the param-binding +// machinery used by the path-server middleware). +func GenerateStdHTTPReceiver(t *template.Template, prefix string, ops []OperationDefinition) (string, error) { + return GenerateTemplates([]string{"stdhttp/std-http-receiver.tmpl"}, t, NewReceiverTemplateData(prefix, ops)) +} + +// GenerateChiReceiver renders the chi receiver template. Chi shares +// stdhttp's (w, r) handler signature, so the template is structurally +// identical -- only the file path and (in the future, if needed) +// framework-specific helpers differ. +func GenerateChiReceiver(t *template.Template, prefix string, ops []OperationDefinition) (string, error) { + return GenerateTemplates([]string{"chi/chi-receiver.tmpl"}, t, NewReceiverTemplateData(prefix, ops)) +} + +// GenerateGorillaReceiver renders the gorilla/mux receiver template. +// Gorilla shares stdhttp's (w, r) handler signature, so the template +// is structurally identical to stdhttp's. +func GenerateGorillaReceiver(t *template.Template, prefix string, ops []OperationDefinition) (string, error) { + return GenerateTemplates([]string{"gorilla/gorilla-receiver.tmpl"}, t, NewReceiverTemplateData(prefix, ops)) +} + +// GenerateEchoReceiver renders the echo (v4) receiver template. Echo's +// handler shape is `(ctx echo.Context) error`, and binding errors are +// returned via echo.NewHTTPError so echo's framework error chain +// reports them as 400 -- there's no errHandler argument like the +// stdhttp receiver factory has. +func GenerateEchoReceiver(t *template.Template, prefix string, ops []OperationDefinition) (string, error) { + return GenerateTemplates([]string{"echo/echo-receiver.tmpl"}, t, NewReceiverTemplateData(prefix, ops)) +} + +// GenerateEcho5Receiver renders the echo (v5) receiver template. Same +// shape as v4 but with `*echo.Context` (pointer) -- the only API +// difference between echo v4 and v5 that affects the receiver. +func GenerateEcho5Receiver(t *template.Template, prefix string, ops []OperationDefinition) (string, error) { + return GenerateTemplates([]string{"echo/v5/echo-receiver.tmpl"}, t, NewReceiverTemplateData(prefix, ops)) +} + +// GenerateGinReceiver renders the gin receiver template. Gin's handler +// shape is `(c *gin.Context)` (no error return); binding errors abort +// with c.JSON(400, gin.H{"error": ...}). Per-handler middleware is not +// generated here -- gin's idiom prefers engine .Use() composition. +func GenerateGinReceiver(t *template.Template, prefix string, ops []OperationDefinition) (string, error) { + return GenerateTemplates([]string{"gin/gin-receiver.tmpl"}, t, NewReceiverTemplateData(prefix, ops)) +} + +// GenerateFiberReceiver renders the fiber (v2) receiver template. +// Fiber's handler shape is `(c *fiber.Ctx) error`; binding errors are +// returned via fiber.NewError so fiber's error chain reports them as +// 400. Per-handler middleware is not generated; use fiber.App.Use(). +func GenerateFiberReceiver(t *template.Template, prefix string, ops []OperationDefinition) (string, error) { + return GenerateTemplates([]string{"fiber/fiber-receiver.tmpl"}, t, NewReceiverTemplateData(prefix, ops)) +} + +// GenerateIrisReceiver renders the iris receiver template. Iris's +// handler shape is `(ctx iris.Context)` (no error return); binding +// errors set ctx.StatusCode(400) plus ctx.WriteString and return. +// Per-handler middleware is not generated; use app.Use() at the +// engine or Party level. +func GenerateIrisReceiver(t *template.Template, prefix string, ops []OperationDefinition) (string, error) { + return GenerateTemplates([]string{"iris/iris-receiver.tmpl"}, t, NewReceiverTemplateData(prefix, ops)) +} + // GenerateTemplates used to generate templates func GenerateTemplates(templates []string, t *template.Template, ops any) (string, error) { var generatedTemplates []string diff --git a/pkg/codegen/schema.go b/pkg/codegen/schema.go index 28b4b403bf..930e2b59e0 100644 --- a/pkg/codegen/schema.go +++ b/pkg/codegen/schema.go @@ -1,6 +1,7 @@ package codegen import ( + "encoding/json" "errors" "fmt" "maps" @@ -50,7 +51,7 @@ func (s Schema) IsPrimitive() bool { if s.OAPISchema == nil { return false } - t := s.OAPISchema.Type + t := schemaPrimaryType(s.OAPISchema.Type) return t.Is("string") || t.Is("integer") || t.Is("number") || t.Is("boolean") } @@ -189,7 +190,7 @@ func (p Property) ZeroValueIsNil() bool { return false } - if p.Schema.OAPISchema.Type.Is("array") { + if schemaPrimaryType(p.Schema.OAPISchema.Type).Is("array") { return true } @@ -369,6 +370,220 @@ func PropertiesEqual(a, b Property) bool { return a.JsonFieldName == b.JsonFieldName && a.Schema.TypeDecl() == b.Schema.TypeDecl() && a.Required == b.Required } +// schemaIsNullable reports whether an OpenAPI schema represents a nullable +// type. In OpenAPI 3.0 nullability is the explicit `nullable: true` flag; +// in OpenAPI 3.1 the `nullable` keyword was removed in favor of including +// "null" in the type array (e.g. `type: ["string","null"]`). +// +// Precondition: globalState.is31 must be set (Generate() does this at +// entry from swagger.IsOpenAPI31OrLater()). This helper does not take +// the version flag explicitly because every call site is reached +// through Generate(); calling it before globalState is initialized +// will silently take the wrong branch. +// +// Cross-version misuse (a 3.1 spec using `nullable: true`, or a 3.0 spec +// using `type: [..., "null"]`) is documented as invalid input -- this +// helper trusts its input to use the version-appropriate idiom. +func schemaIsNullable(s *openapi3.Schema) bool { + if s == nil { + return false + } + if globalState.is31 { + if s.Type != nil && s.Type.Includes("null") { + return true + } + // OpenAPI 3.1 also allows nullability to be expressed via an + // `anyOf` or `oneOf` branch whose only type is "null", which is + // semantically equivalent to including "null" in the outer + // type array. Detect it here so downstream code that wraps in a + // pointer (or nullable.Nullable[T]) reaches the right decision. + for _, branch := range s.AnyOf { + if branch != nil && isNullTypeSchema(branch.Value) { + return true + } + } + for _, branch := range s.OneOf { + if branch != nil && isNullTypeSchema(branch.Value) { + return true + } + } + return false + } + return s.Nullable +} + +// isNullTypeSchema reports whether an OpenAPI 3.1 schema is a bare +// `{"type": "null"}` -- i.e. a schema whose only type is "null" and +// which is otherwise empty of constraints. Used to detect the +// nullability-via-anyOf idiom in `schemaIsNullable` and to filter such +// branches out of `generateUnion` (they're nullability markers, not +// union variants for which we need a Go type). +func isNullTypeSchema(s *openapi3.Schema) bool { + if s == nil || s.Type == nil { + return false + } + slice := s.Type.Slice() + return len(slice) == 1 && slice[0] == "null" +} + +// enumViaOneOfValue is one branch of an OpenAPI 3.1 enum-via-oneOf schema. +// Title is the per-branch identifier (becomes the Go constant name); Value +// is the stringified `const` (the Go literal, unquoted; the enum +// generation pipeline applies the appropriate quoting via ValueWrapper). +type enumViaOneOfValue struct { + Title string + Value string +} + +// detectEnumViaOneOf reports whether the schema matches the OpenAPI 3.1 +// enum-via-oneOf idiom and, if so, returns the per-branch values in +// declaration order. +// +// The idiom: +// +// Severity: +// type: integer # or "string" +// oneOf: +// - title: HIGH +// const: 2 +// description: An urgent problem # optional +// - ... +// +// All members must carry both `title` and `const`; no member may itself +// be a composition (oneOf/allOf/anyOf) or declare properties. The outer +// schema's primary type must be a scalar (string or integer). +// +// Gated on globalState.is31 (the keyword `const` lands in OpenAPI 3.1) +// AND !SkipEnumViaOneOf so users can fall through to the standard union +// generator on demand. +// +// Precondition: globalState (both is31 and options) must be initialized +// (see schemaIsNullable for context). +func detectEnumViaOneOf(schema *openapi3.Schema) ([]enumViaOneOfValue, bool) { + if !globalState.is31 { + return nil, false + } + if globalState.options.OutputOptions.SkipEnumViaOneOf { + return nil, false + } + if schema == nil || len(schema.OneOf) == 0 { + return nil, false + } + primary := schemaPrimaryType(schema.Type) + if primary == nil { + return nil, false + } + if !primary.Is("string") && !primary.Is("integer") { + return nil, false + } + items := make([]enumViaOneOfValue, 0, len(schema.OneOf)) + for _, ref := range schema.OneOf { + if ref == nil || ref.Value == nil { + return nil, false + } + m := ref.Value + if m.Title == "" || m.Const == nil { + return nil, false + } + if len(m.OneOf) > 0 || len(m.AllOf) > 0 || len(m.AnyOf) > 0 { + return nil, false + } + if len(m.Properties) > 0 { + return nil, false + } + items = append(items, enumViaOneOfValue{ + Title: m.Title, + Value: fmt.Sprintf("%v", m.Const), + }) + } + return items, true +} + +// describeWithExamples folds a schema's example data into its +// description string for use in generated Go doc comments. Version-aware: +// in 3.0 it reads schema.Example (singular); in 3.1 it reads +// schema.Examples (plural array). Cross-version misuse is documented as +// invalid input -- this helper does not look at the off-version field. +// +// The output appends `Examples: , , ...` (or `Example: ` in +// 3.0) on a new paragraph after any existing description text. Non- +// string values are JSON-encoded so structured examples render +// readably. +// +// Precondition: globalState.is31 must be set (see schemaIsNullable +// for context). +func describeWithExamples(description string, schema *openapi3.Schema) string { + if schema == nil { + return description + } + var values []any + label := "Example" + if globalState.is31 { + if len(schema.Examples) == 0 { + return description + } + values = schema.Examples + label = "Examples" + } else { + if schema.Example == nil { + return description + } + values = []any{schema.Example} + } + + formatted := make([]string, 0, len(values)) + for _, v := range values { + formatted = append(formatted, formatExampleValue(v)) + } + + suffix := label + ": " + strings.Join(formatted, ", ") + if description == "" { + return suffix + } + return description + "\n\n" + suffix +} + +// formatExampleValue renders an example value for inclusion in a Go doc +// comment. Strings round-trip as themselves (no JSON quoting); other +// values JSON-encode so structured examples remain readable. +func formatExampleValue(v any) string { + if s, ok := v.(string); ok { + return s + } + if b, err := json.Marshal(v); err == nil { + return string(b) + } + return fmt.Sprintf("%v", v) +} + +// schemaPrimaryType returns the type slice used for primitive-type dispatch +// (`*Types.Is("string")` etc.). In OpenAPI 3.0 the type slice has at most +// one entry and is returned unchanged. In OpenAPI 3.1 the "null" entry is +// the nullability indicator (handled separately by schemaIsNullable); we +// strip it here so the rest of the codegen can keep dispatching with +// `Is("...")` as it always has. +// +// Precondition: globalState.is31 must be set (see schemaIsNullable +// for context). The early-return on `!globalState.is31` is correct only +// when the global state has actually been initialized -- a call before +// Generate() runs would silently use the 3.0 branch. +func schemaPrimaryType(t *openapi3.Types) *openapi3.Types { + if t == nil || !globalState.is31 { + return t + } + s := t.Slice() + if len(s) <= 1 { + return t + } + stripped := make(openapi3.Types, 0, len(s)) + for _, name := range s { + if name != "null" { + stripped = append(stripped, name) + } + } + return &stripped +} + func GenerateGoSchema(sref *openapi3.SchemaRef, path []string) (Schema, error) { // Add a fallback value in case the sref is nil. // i.e. the parent schema defines a type:array, but the array has @@ -417,7 +632,7 @@ func GenerateGoSchema(sref *openapi3.SchemaRef, path []string) (Schema, error) { return Schema{ GoType: refType, - Description: schema.Description, + Description: describeWithExamples(schema.Description, schema), DefineViaAlias: true, SkipOptionalPointer: skipOptionalPointer, OAPISchema: schema, @@ -425,7 +640,7 @@ func GenerateGoSchema(sref *openapi3.SchemaRef, path []string) (Schema, error) { } outSchema := Schema{ - Description: schema.Description, + Description: describeWithExamples(schema.Description, schema), OAPISchema: schema, SkipOptionalPointer: skipOptionalPointer, } @@ -503,8 +718,52 @@ func GenerateGoSchema(sref *openapi3.SchemaRef, path []string) (Schema, error) { return mergedSchema, nil } - // Schema type and format, eg. string / binary - t := schema.Type + // OpenAPI 3.1 enum-via-oneOf: a scalar schema whose oneOf branches + // each carry `title` + `const` is rendered as a Go typed enum, not as + // a union. Detection is gated by version + the SkipEnumViaOneOf flag; + // when the idiom does not match, fall through to standard handling + // (which routes oneOf into generateUnion further below). + if items, ok := detectEnumViaOneOf(schema); ok { + if err := oapiSchemaToGoType(schema, path, &outSchema); err != nil { + return Schema{}, fmt.Errorf("error resolving primitive type for enum-via-oneOf: %w", err) + } + // Force a typed declaration -- enums must not be aliased. + outSchema.DefineViaAlias = false + outSchema.EnumValues = make(map[string]string, len(items)) + for _, it := range items { + outSchema.EnumValues[SchemaNameToTypeName(it.Title)] = it.Value + } + // Non-toplevel schemas need an explicit AdditionalType so the + // downstream EnumDefinition collector picks them up; toplevel + // schemas are already collected via the components walk. + if len(path) > 1 { + var typeName string + if extension, ok := schema.Extensions[extGoTypeName]; ok { + tn, err := extString(extension) + if err != nil { + return outSchema, fmt.Errorf("invalid value for %q: %w", extGoTypeName, err) + } + typeName = tn + } else { + typeName = SchemaNameToTypeName(PathToTypeName(path)) + } + typeDef := TypeDefinition{ + TypeName: typeName, + JsonName: strings.Join(path, "."), + Schema: outSchema, + } + outSchema.AdditionalTypes = append(outSchema.AdditionalTypes, typeDef) + outSchema.RefType = typeName + } + return outSchema, nil + } + + // Schema type and format, eg. string / binary. In OpenAPI 3.1, `type` + // may include "null" alongside the primary type to express nullability; + // strip "null" up front so the object / fall-through dispatch sees the + // underlying type. Nullability itself is captured by schemaIsNullable() + // at the call sites that wrap the result in a pointer. + t := schemaPrimaryType(schema.Type) // Handle objects and empty schemas first as a special case if t.Slice() == nil || t.Is("object") { var outType string @@ -612,7 +871,7 @@ func GenerateGoSchema(sref *openapi3.SchemaRef, path []string) (Schema, error) { } description := "" if p.Value != nil { - description = p.Value.Description + description = describeWithExamples(p.Value.Description, p.Value) } prop := Property{ @@ -620,7 +879,7 @@ func GenerateGoSchema(sref *openapi3.SchemaRef, path []string) (Schema, error) { Schema: pSchema, Required: required, Description: description, - Nullable: p.Value.Nullable, + Nullable: schemaIsNullable(p.Value), ReadOnly: p.Value.ReadOnly, WriteOnly: p.Value.WriteOnly, Extensions: combinedSchemaExtensions(p), @@ -643,7 +902,15 @@ func GenerateGoSchema(sref *openapi3.SchemaRef, path []string) (Schema, error) { } } - outSchema.GoType = GenStructFromSchema(outSchema) + // Only generate a struct literal if the schema actually has + // struct content. When `generateUnion` collapses a one- + // element nullable union (`anyOf: [{type: X}, {type: "null"}]`) + // down to the bare X branch, it sets outSchema.GoType to the + // primitive's Go type and clears the struct-shaped fields; + // rebuilding `struct {}` here would clobber that. + if len(outSchema.Properties) > 0 || outSchema.HasAdditionalProperties || len(outSchema.UnionElements) > 0 { + outSchema.GoType = GenStructFromSchema(outSchema) + } } // Check for x-go-type-name. It behaves much like x-go-type, however, it will @@ -703,7 +970,7 @@ func GenerateGoSchema(sref *openapi3.SchemaRef, path []string) (Schema, error) { } return outSchema, nil - } else if len(schema.Enum) > 0 { + } else if len(schema.Enum) > 0 || (globalState.is31 && schema.Const != nil) { err := oapiSchemaToGoType(schema, path, &outSchema) // Enums need to be typed, so that the values aren't interchangeable, // so no matter what schema conversion thinks, we need to define a @@ -713,8 +980,16 @@ func GenerateGoSchema(sref *openapi3.SchemaRef, path []string) (Schema, error) { if err != nil { return Schema{}, fmt.Errorf("error resolving primitive type: %w", err) } - enumValues := make([]string, len(schema.Enum)) - for i, enumValue := range schema.Enum { + // OpenAPI 3.1 `const: X` is shorthand for `enum: [X]`. Fold the + // two through the same enum-codegen path here so a top-level + // `const` schema becomes a typed alias plus a singleton constant, + // using the same name-derivation rules `enum` uses. + enumSource := schema.Enum + if len(enumSource) == 0 { + enumSource = []any{schema.Const} + } + enumValues := make([]string, len(enumSource)) + for i, enumValue := range enumSource { enumValues[i] = fmt.Sprintf("%v", enumValue) } @@ -778,7 +1053,13 @@ func GenerateGoSchema(sref *openapi3.SchemaRef, path []string) (Schema, error) { // all non-object types. func oapiSchemaToGoType(schema *openapi3.Schema, path []string, outSchema *Schema) error { f := schema.Format - t := schema.Type + // In OpenAPI 3.1, `type` may be a multi-element array including "null" + // to express nullability. The dispatch below uses `*Types.Is("...")`, + // which only matches single-element type slices. Strip "null" up front + // so the dispatch sees the underlying primitive type. Nullability + // itself was already captured by schemaIsNullable() at the call sites + // that wrap the result in a pointer. + t := schemaPrimaryType(schema.Type) if t.Is("array") { // For arrays, we'll get the type of the Items and throw a @@ -788,11 +1069,18 @@ func oapiSchemaToGoType(schema *openapi3.Schema, path []string, outSchema *Schem return fmt.Errorf("error generating type for array: %w", err) } - if (arrayType.HasAdditionalProperties || len(arrayType.UnionElements) != 0) && arrayType.RefType == "" { + if (arrayType.HasAdditionalProperties || + len(arrayType.UnionElements) != 0 || + (globalState.options.OutputOptions.GenerateTypesForAnonymousSchemas && len(arrayType.Properties) > 0)) && + arrayType.RefType == "" { // If we have items which have additional properties or union values, // but are not a pre-defined type, we need to define a type // for them, which will be based on the field names we followed - // to get to the type. + // to get to the type. The third clause catches plain inline + // object items under generate-types-for-anonymous-schemas: the + // auto-hoist block in GenerateGoSchema only fires when + // len(path) > 1, so top-level array schemas (path length 1) fall + // through with their items left anonymous unless we hoist here. typeName := PathToTypeName(append(path, "Item")) typeDef := TypeDefinition{ @@ -806,7 +1094,7 @@ func oapiSchemaToGoType(schema *openapi3.Schema, path []string, outSchema *Schem } typeDeclaration := arrayType.TypeDecl() - if arrayType.OAPISchema != nil && arrayType.OAPISchema.Nullable { + if schemaIsNullable(arrayType.OAPISchema) { if globalState.options.OutputOptions.NullableType { typeDeclaration = "nullable.Nullable[" + typeDeclaration + "]" } else { @@ -837,7 +1125,49 @@ func oapiSchemaToGoType(schema *openapi3.Schema, path []string, outSchema *Schem outSchema.GoType = spec.Type outSchema.DefineViaAlias = true } else if t.Is("string") { - spec := globalState.typeMapping.String.Resolve(f) + // OpenAPI 3.1: `contentMediaType` and `contentEncoding` are the + // JSON-Schema-aligned replacements for the 3.0 `format: binary` + // / `format: byte` idioms used to describe file uploads and + // base64-encoded binary data. When the spec author writes the + // 3.1 form (per learn.openapis.org/upgrading/v3.0-to-v3.1.html + // "Update file upload descriptions"), the schema arrives with + // no `format` set but one of these keywords populated; without + // this synthesis the field would fall through to the default + // `string` mapping and the user would silently lose the + // file/binary typing they had under 3.0. We only synthesize a + // format when the user has not explicitly set one, so any + // explicit `format` (or user-provided `type-mapping` overlay) + // continues to win. + resolvedFormat := f + if globalState.is31 && resolvedFormat == "" { + switch { + case schema.ContentMediaType != "": + // Raw binary content of any media type -- equivalent to + // 3.0 `format: binary`. Routes to openapi_types.File in + // the default mapping. + resolvedFormat = "binary" + case schema.ContentEncoding == "base64": + // Standard padded base64 is the one RFC4648 variant Go's + // encoding/json handles natively for []byte. Map to + // `byte` (-> []byte), matching the 3.0 `format: byte` + // behavior so the wire encoding is honored on + // marshal/unmarshal without user code. + resolvedFormat = "byte" + // Other contentEncoding values (base64url, base32, base16, + // quoted-printable) are intentionally NOT mapped to []byte. + // Go's JSON codec for []byte always emits/expects standard + // padded base64; using it for base64url would silently + // corrupt URL-safe characters (`-`/`_`) on unmarshal and + // re-emit them as standard base64 on marshal. Leaving the + // field as the default `string` keeps the raw declared + // wire encoding in user hands -- they can apply the + // correct codec at the application layer. Users who want + // a typed mapping can override via `type-mapping` (e.g. + // map a custom format to a wrapper type that implements + // encoding-aware MarshalJSON/UnmarshalJSON). + } + } + spec := globalState.typeMapping.String.Resolve(resolvedFormat) outSchema.GoType = spec.Type // Preserve special behaviors for specific types if outSchema.GoType == "[]byte" { @@ -987,7 +1317,7 @@ func additionalPropertiesType(schema Schema) string { if schema.AdditionalPropertiesType.RefType != "" { addPropsType = schema.AdditionalPropertiesType.RefType } - if schema.AdditionalPropertiesType.OAPISchema != nil && schema.AdditionalPropertiesType.OAPISchema.Nullable { + if schemaIsNullable(schema.AdditionalPropertiesType.OAPISchema) { addPropsType = "*" + addPropsType } return addPropsType @@ -1055,8 +1385,68 @@ func generateUnion(outSchema *Schema, elements openapi3.SchemaRefs, discriminato } } + // First pass: count effective (non-null) branches. In OpenAPI 3.1, a + // bare `{"type": "null"}` branch in anyOf/oneOf is a nullability + // marker, not a real union variant -- there's no Go type that + // corresponds to "only the JSON value null". The parent schema's + // nullability is captured by schemaIsNullable, which inspects + // anyOf/oneOf for the same idiom and wraps the result in a pointer + // at the call site. + effectiveCount := 0 + hadNullBranch := false + var soleEffective *openapi3.SchemaRef + for _, e := range elements { + if e != nil && isNullTypeSchema(e.Value) { + hadNullBranch = true + continue + } + effectiveCount++ + if soleEffective == nil { + soleEffective = e + } + } + + // Collapse: if filtering out null branches leaves exactly one + // effective branch and there is no discriminator, the schema is + // semantically equivalent to that single branch (made nullable by + // the original null branch). Produce the same Go shape the + // type-array idiom would: `anyOf: [{type: string}, {type: "null"}]` + // must generate the same `*string` field as `type: ["string", + // "null"]`. Without this, the single remaining branch would be + // wrapped in a one-variant union type, exposing a needless + // `FromX`/`AsX` accessor API. + // + // We do not collapse when there was no null branch (`anyOf: [{type: + // X}]` alone) to avoid changing behavior for existing single-branch + // union specs that may rely on the wrapper shape. The narrow + // condition keeps this change scoped to the bug fix. + if effectiveCount == 1 && hadNullBranch && discriminator == nil { + elementSchema, err := GenerateGoSchema(soleEffective, path) + if err != nil { + return err + } + // Inherit the single branch's underlying representation. The + // caller will apply nullability (schemaIsNullable returns true + // because the original anyOf/oneOf contained a null branch). + outSchema.GoType = elementSchema.GoType + outSchema.RefType = elementSchema.RefType + outSchema.DefineViaAlias = elementSchema.DefineViaAlias + outSchema.Properties = elementSchema.Properties + outSchema.HasAdditionalProperties = elementSchema.HasAdditionalProperties + outSchema.AdditionalPropertiesType = elementSchema.AdditionalPropertiesType + outSchema.ArrayType = elementSchema.ArrayType + outSchema.SkipOptionalPointer = elementSchema.SkipOptionalPointer + outSchema.AdditionalTypes = append(outSchema.AdditionalTypes, elementSchema.AdditionalTypes...) + return nil + } + refToGoTypeMap := make(map[string]string) for i, element := range elements { + // Skip null-only branches: nullability marker, not a real + // union variant. See the collapse comment above for context. + if element != nil && isNullTypeSchema(element.Value) { + continue + } elementPath := append(path, fmt.Sprint(i)) elementSchema, err := GenerateGoSchema(element, elementPath) if err != nil { @@ -1098,7 +1488,14 @@ func generateUnion(outSchema *Schema, elements openapi3.SchemaRefs, discriminato outSchema.UnionElements = append(outSchema.UnionElements, UnionElement(elementSchema.GoType)) } - if (outSchema.Discriminator != nil) && len(outSchema.Discriminator.Mapping) < len(elements) { + // Compare against effectiveCount (non-null branches actually + // processed) rather than len(elements). For a nullable + // discriminated union (`oneOf: [Cat, Dog, {type: "null"}]`), the + // null-branch skip above leaves the discriminator with one fewer + // mapping than the raw element count, and we must not flag that as + // incomplete -- the null branch is a nullability marker, not a real + // variant that needs a mapping. + if (outSchema.Discriminator != nil) && len(outSchema.Discriminator.Mapping) < effectiveCount { return errors.New("discriminator: not all schemas were mapped") } diff --git a/pkg/codegen/templates/callback-initiator.tmpl b/pkg/codegen/templates/callback-initiator.tmpl new file mode 100644 index 0000000000..908695bf37 --- /dev/null +++ b/pkg/codegen/templates/callback-initiator.tmpl @@ -0,0 +1,236 @@ +// CallbackInitiator sends OpenAPI callback requests to target URLs. +// Modeled on the generated Client, but with no stored Server -- the full +// target URL is provided per-call by the caller (typically discovered +// from a callback expression on the parent operation's request body, +// e.g. `{$request.body#/callbackUrl}`). +type CallbackInitiator struct { + // Doer for performing requests, typically a *http.Client with any + // customized settings, such as certificate chains. + Client HttpRequestDoer + + // A list of callbacks for modifying requests which are generated before sending over + // the network. + RequestEditors []RequestEditorFn +} + +// CallbackInitiatorOption allows setting custom parameters during construction. +type CallbackInitiatorOption func(*CallbackInitiator) error + +// NewCallbackInitiator creates a new CallbackInitiator with reasonable defaults. +func NewCallbackInitiator(opts ...CallbackInitiatorOption) (*CallbackInitiator, error) { + initiator := CallbackInitiator{} + for _, o := range opts { + if err := o(&initiator); err != nil { + return nil, err + } + } + if initiator.Client == nil { + initiator.Client = &http.Client{} + } + return &initiator, nil +} + +// WithCallbackHTTPClient allows overriding the default Doer, which is +// automatically created using http.Client. This is useful for tests. +func WithCallbackHTTPClient(doer HttpRequestDoer) CallbackInitiatorOption { + return func(p *CallbackInitiator) error { + p.Client = doer + return nil + } +} + +// WithCallbackRequestEditorFn allows setting up a callback function, which +// will be called right before sending the callback request. This can be +// used to mutate the request, e.g. to add signature headers. +func WithCallbackRequestEditorFn(fn RequestEditorFn) CallbackInitiatorOption { + return func(p *CallbackInitiator) error { + p.RequestEditors = append(p.RequestEditors, fn) + return nil + } +} + +func (p *CallbackInitiator) applyCallbackEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error { + for _, r := range p.RequestEditors { + if err := r(ctx, req); err != nil { + return err + } + } + for _, r := range additionalEditors { + if err := r(ctx, req); err != nil { + return err + } + } + return nil +} + +// CallbackInitiatorInterface is the interface specification for the callback initiator. +type CallbackInitiatorInterface interface { +{{range . -}} +{{$hasParams := .RequiresParamObject -}} +{{$opid := .OperationId -}} + // {{$opid}}{{if .HasBody}}WithBody{{end}} fires the {{.CallbackName}} callback{{if .HasBody}} with any body{{end}} + {{$opid}}{{if .HasBody}}WithBody{{end}}(ctx context.Context, targetURL string{{if $hasParams}}, params *{{$opid}}Params{{end}}{{if .HasBody}}, contentType string, body io.Reader{{end}}, reqEditors... RequestEditorFn) (*http.Response, error) +{{range .Bodies}} + {{if .IsSupportedByClient -}} + {{$opid}}{{.Suffix}}(ctx context.Context, targetURL string{{if $hasParams}}, params *{{$opid}}Params{{end}}, body {{$opid}}{{.NameTag}}RequestBody, reqEditors... RequestEditorFn) (*http.Response, error) + {{end -}} +{{end}}{{/* range .Bodies */}} +{{end}}{{/* range . */}} +} + + +{{/* Generate callback initiator methods */}} +{{range . -}} +{{$hasParams := .RequiresParamObject -}} +{{$opid := .OperationId -}} + +func (p *CallbackInitiator) {{$opid}}{{if .HasBody}}WithBody{{end}}(ctx context.Context, targetURL string{{if $hasParams}}, params *{{$opid}}Params{{end}}{{if .HasBody}}, contentType string, body io.Reader{{end}}, reqEditors... RequestEditorFn) (*http.Response, error) { + req, err := New{{$opid}}CallbackRequest{{if .HasBody}}WithBody{{end}}(targetURL{{if $hasParams}}, params{{end}}{{if .HasBody}}, contentType, body{{end}}) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := p.applyCallbackEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return p.Client.Do(req) +} + +{{range .Bodies}} +{{if .IsSupportedByClient -}} +func (p *CallbackInitiator) {{$opid}}{{.Suffix}}(ctx context.Context, targetURL string{{if $hasParams}}, params *{{$opid}}Params{{end}}, body {{$opid}}{{.NameTag}}RequestBody, reqEditors... RequestEditorFn) (*http.Response, error) { + req, err := New{{$opid}}CallbackRequest{{.Suffix}}(targetURL{{if $hasParams}}, params{{end}}, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := p.applyCallbackEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return p.Client.Do(req) +} +{{end -}} +{{end}}{{/* range .Bodies */}} +{{end}}{{/* range . */}} + +{{/* Generate callback request builders */}} +{{range . -}} +{{$hasParams := .RequiresParamObject -}} +{{$opid := .OperationId -}} +{{$method := .Method -}} +{{$callbackName := .CallbackName -}} + +{{range .Bodies}} +{{if .IsSupportedByClient -}} +// New{{$opid}}CallbackRequest{{.Suffix}} builds a {{.ContentType}} {{$method}} request for the {{$callbackName}} callback +func New{{$opid}}CallbackRequest{{.Suffix}}(targetURL string{{if $hasParams}}, params *{{$opid}}Params{{end}}, body {{$opid}}{{.NameTag}}RequestBody) (*http.Request, error) { + var bodyReader io.Reader + {{if .IsJSON -}} + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + {{else if eq .NameTag "Formdata" -}} + bodyStr, err := runtime.MarshalForm(body, nil) + if err != nil { + return nil, err + } + bodyReader = strings.NewReader(bodyStr.Encode()) + {{else if eq .NameTag "Text" -}} + if stringer, ok := interface{}(body).(fmt.Stringer); ok { + bodyReader = strings.NewReader(stringer.String()) + } else { + {{if .Schema.IsPrimitive -}} + bodyReader = strings.NewReader(fmt.Sprint(body)) + {{else -}} + return nil, fmt.Errorf("text/plain is not supported for complex types, define a String() method on {{.Schema.TypeDecl}} to marshal it as text") + {{end -}} + } + {{end -}} + return New{{$opid}}CallbackRequestWithBody(targetURL{{if $hasParams}}, params{{end}}, "{{.ContentType}}", bodyReader) +} +{{end -}} +{{end}} + +// New{{$opid}}CallbackRequest{{if .HasBody}}WithBody{{end}} builds a {{.Method}} request for the {{.CallbackName}} callback{{if .HasBody}} with any body{{end}} +func New{{$opid}}CallbackRequest{{if .HasBody}}WithBody{{end}}(targetURL string{{if $hasParams}}, params *{{$opid}}Params{{end}}{{if .HasBody}}, contentType string, body io.Reader{{end}}) (*http.Request, error) { + var err error + _ = err + + reqURL, err := url.Parse(targetURL) + if err != nil { + return nil, err + } + +{{if .QueryParams}} + if params != nil { + queryValues := reqURL.Query() + var rawQueryFragments []string + {{range $paramIdx, $param := .QueryParams}} + {{if .RequiresNilCheck}} if params.{{.GoName}} != nil { {{end}} + {{if .IsPassThrough}} + queryValues.Add("{{.ParamName}}", {{if .HasOptionalPointer}}*{{end}}params.{{.GoName}}) + {{end}} + {{if .IsJson}} + if queryParamBuf, err := json.Marshal({{if .HasOptionalPointer}}*{{end}}params.{{.GoName}}); err != nil { + return nil, err + } else { + queryValues.Add("{{.ParamName}}", string(queryParamBuf)) + } + {{end}} + {{if .IsStyled}} + if queryFrag, err := runtime.StyleParamWithOptions("{{.Style}}", {{.Explode}}, "{{.ParamName}}", {{if and .RequiresNilCheck .HasOptionalPointer}}*{{end}}params.{{.GoName}}, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "{{.SchemaType}}", Format: "{{.SchemaFormat}}"}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + {{end}} + {{if .RequiresNilCheck}}}{{end}} + {{end}} + if encoded := queryValues.Encode(); encoded != "" { + rawQueryFragments = append(rawQueryFragments, encoded) + } + reqURL.RawQuery = strings.Join(rawQueryFragments, "&") + } +{{end}}{{/* if .QueryParams */}} + + req, err := http.NewRequest({{.Method | httpMethodConstant}}, reqURL.String(), {{if .HasBody}}body{{else}}nil{{end}}) + if err != nil { + return nil, err + } + + {{if .HasBody}}req.Header.Add("Content-Type", contentType){{end}} +{{ if .HeaderParams }} + if params != nil { + {{range $paramIdx, $param := .HeaderParams}} + {{if .RequiresNilCheck}} if params.{{.GoName}} != nil { {{end}} + var headerParam{{$paramIdx}} string + {{if .IsPassThrough}} + headerParam{{$paramIdx}} = {{if .HasOptionalPointer}}*{{end}}params.{{.GoName}} + {{end}} + {{if .IsJson}} + var headerParamBuf{{$paramIdx}} []byte + headerParamBuf{{$paramIdx}}, err = json.Marshal({{if .HasOptionalPointer}}*{{end}}params.{{.GoName}}) + if err != nil { + return nil, err + } + headerParam{{$paramIdx}} = string(headerParamBuf{{$paramIdx}}) + {{end}} + {{if .IsStyled}} + headerParam{{$paramIdx}}, err = runtime.StyleParamWithOptions("{{.Style}}", {{.Explode}}, "{{.ParamName}}", {{if .HasOptionalPointer}}*{{end}}params.{{.GoName}}, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationHeader, Type: "{{.SchemaType}}", Format: "{{.SchemaFormat}}"}) + if err != nil { + return nil, err + } + {{end}} + req.Header.Set("{{.ParamName}}", headerParam{{$paramIdx}}) + {{if .RequiresNilCheck}}}{{end}} + {{end}} + } +{{- end }}{{/* if .HeaderParams */}} + return req, nil +} + +{{end}}{{/* Range */}} diff --git a/pkg/codegen/templates/chi/chi-receiver.tmpl b/pkg/codegen/templates/chi/chi-receiver.tmpl new file mode 100644 index 0000000000..3089faea4e --- /dev/null +++ b/pkg/codegen/templates/chi/chi-receiver.tmpl @@ -0,0 +1,116 @@ +// {{.Prefix}}ReceiverInterface represents handlers for receiving inbound +// {{.PrefixLower}} requests. Each {{.PrefixLower}} becomes a Handle*{{.Prefix}} +// method that the implementation fills in. The caller mounts the per- +// {{.PrefixLower}} http.Handler returned by {Op}{{.Prefix}}Handler at +// whatever URL path they advertise to senders. +type {{.Prefix}}ReceiverInterface interface { +{{range .Operations -}} +{{.SummaryAsComment ""}} +// Handle{{.OperationId}}{{$.Prefix}} handles the {{.Method}} {{$.PrefixLower}} for {{.SourceName}}. +Handle{{.OperationId}}{{$.Prefix}}(w http.ResponseWriter, r *http.Request{{if .RequiresParamObject}}, params {{.OperationId}}Params{{end}}) +{{end}} +} + +// {{.Prefix}}ReceiverMiddlewareFunc wraps an http.Handler with cross- +// cutting behavior (signature verification, logging, rate limiting, ...). +type {{.Prefix}}ReceiverMiddlewareFunc func(http.Handler) http.Handler + +{{range .Operations -}} +{{$opid := .OperationId -}} +{{$srcName := .SourceName -}} +// {{$opid}}{{$.Prefix}}Handler returns the http.Handler for the {{$srcName}} {{$.PrefixLower}}. +// Mount this at the URL path advertised to {{$.PrefixLower}} senders. errHandler +// may be nil; if so, parameter-binding errors return 400 with the error +// message. Middlewares are applied in the order provided -- the last +// argument becomes the outermost wrapper. +func {{$opid}}{{$.Prefix}}Handler(si {{$.Prefix}}ReceiverInterface, errHandler func(w http.ResponseWriter, r *http.Request, err error), middlewares ...{{$.Prefix}}ReceiverMiddlewareFunc) http.Handler { + if errHandler == nil { + errHandler = func(w http.ResponseWriter, r *http.Request, err error) { + http.Error(w, err.Error(), http.StatusBadRequest) + } + } + var h http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { +{{- if .RequiresParamObject}} + var err error + _ = err + + // Parameter object where we will unmarshal all parameters from the request. + var params {{$opid}}Params +{{range $paramIdx, $param := .QueryParams}} + // ------------- {{if .Required}}Required{{else}}Optional{{end}} query parameter "{{.ParamName}}" ------------- + {{if (or .IsPassThrough .IsJson)}} + if paramValue := r.URL.Query().Get("{{.ParamName}}"); paramValue != "" { + {{if .IsPassThrough}} + params.{{.GoName}} = {{if .HasOptionalPointer}}&{{end}}paramValue + {{end}} + {{if .IsJson}} + var value {{.TypeDef}} + err = json.Unmarshal([]byte(paramValue), &value) + if err != nil { + errHandler(w, r, &UnmarshalingParamError{ParamName: "{{.ParamName}}", Err: err}) + return + } + params.{{.GoName}} = {{if .HasOptionalPointer}}&{{end}}value + {{end}} + }{{if .Required}} else { + errHandler(w, r, &RequiredParamError{ParamName: "{{.ParamName}}"}) + return + }{{end}} + {{end}} + {{if .IsStyled}} + err = runtime.BindQueryParameterWithOptions("{{.Style}}", {{.Explode}}, {{.Required}}, "{{.ParamName}}", r.URL.Query(), ¶ms.{{.GoName}}, runtime.BindQueryParameterOptions{Type: "{{.SchemaType}}", Format: "{{.SchemaFormat}}"}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + errHandler(w, r, &RequiredParamError{ParamName: "{{.ParamName}}"}) + } else { + errHandler(w, r, &InvalidParamFormatError{ParamName: "{{.ParamName}}", Err: err}) + } + return + } + {{end}} +{{end}} +{{range $paramIdx, $param := .HeaderParams}} + // ------------- {{if .Required}}Required{{else}}Optional{{end}} header parameter "{{.ParamName}}" ------------- + if valueList, found := r.Header[http.CanonicalHeaderKey("{{.ParamName}}")]; found { + var {{.GoName}} {{.TypeDef}} + n := len(valueList) + if n != 1 { + errHandler(w, r, &TooManyValuesForParamError{ParamName: "{{.ParamName}}", Count: n}) + return + } + {{if .IsPassThrough}} + params.{{.GoName}} = {{if .HasOptionalPointer}}&{{end}}valueList[0] + {{end}} + {{if .IsJson}} + err = json.Unmarshal([]byte(valueList[0]), &{{.GoName}}) + if err != nil { + errHandler(w, r, &UnmarshalingParamError{ParamName: "{{.ParamName}}", Err: err}) + return + } + params.{{.GoName}} = {{if .HasOptionalPointer}}&{{end}}{{.GoName}} + {{end}} + {{if .IsStyled}} + err = runtime.BindStyledParameterWithOptions("{{.Style}}", "{{.ParamName}}", valueList[0], &{{.GoName}}, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationHeader, Explode: {{.Explode}}, Required: {{.Required}}, Type: "{{.SchemaType}}", Format: "{{.SchemaFormat}}"}) + if err != nil { + errHandler(w, r, &InvalidParamFormatError{ParamName: "{{.ParamName}}", Err: err}) + return + } + params.{{.GoName}} = {{if .HasOptionalPointer}}&{{end}}{{.GoName}} + {{end}} + }{{if .Required}} else { + err := fmt.Errorf("Header parameter {{.ParamName}} is required, but not found") + errHandler(w, r, &RequiredHeaderError{ParamName: "{{.ParamName}}", Err: err}) + return + }{{end}} +{{end}} +{{- end}} + si.Handle{{$opid}}{{$.Prefix}}(w, r{{if .RequiresParamObject}}, params{{end}}) + }) + for _, mw := range middlewares { + h = mw(h) + } + return h +} + +{{end}} diff --git a/pkg/codegen/templates/echo/echo-receiver.tmpl b/pkg/codegen/templates/echo/echo-receiver.tmpl new file mode 100644 index 0000000000..a90d03f834 --- /dev/null +++ b/pkg/codegen/templates/echo/echo-receiver.tmpl @@ -0,0 +1,93 @@ +// {{.Prefix}}ReceiverInterface represents handlers for receiving inbound +// {{.PrefixLower}} requests. Each {{.PrefixLower}} becomes a Handle*{{.Prefix}} +// method that the implementation fills in. The caller mounts the per- +// {{.PrefixLower}} echo.HandlerFunc returned by {Op}{{.Prefix}}Handler at +// whatever URL path they advertise to senders. +type {{.Prefix}}ReceiverInterface interface { +{{range .Operations -}} +{{.SummaryAsComment ""}} +// Handle{{.OperationId}}{{$.Prefix}} handles the {{.Method}} {{$.PrefixLower}} for {{.SourceName}}. +Handle{{.OperationId}}{{$.Prefix}}(ctx echo.Context{{if .RequiresParamObject}}, params {{.OperationId}}Params{{end}}) error +{{end}} +} + +{{range .Operations -}} +{{$opid := .OperationId -}} +{{$srcName := .SourceName -}} +// {{$opid}}{{$.Prefix}}Handler returns the echo.HandlerFunc for the {{$srcName}} {{$.PrefixLower}}. +// Mount this at the URL path advertised to {{$.PrefixLower}} senders. Errors +// during parameter binding are returned via echo.NewHTTPError so echo's +// error-handling chain reports them as 400. Middlewares are applied in +// the order provided -- the last argument becomes the outermost wrapper. +func {{$opid}}{{$.Prefix}}Handler(si {{$.Prefix}}ReceiverInterface, middlewares ...echo.MiddlewareFunc) echo.HandlerFunc { + h := echo.HandlerFunc(func(ctx echo.Context) error { +{{- if .RequiresParamObject}} + var err error + _ = err + + // Parameter object where we will unmarshal all parameters from the context. + var params {{$opid}}Params +{{range $paramIdx, $param := .QueryParams}} + // ------------- {{if .Required}}Required{{else}}Optional{{end}} query parameter "{{.ParamName}}" ------------- + {{if .IsStyled}} + err = runtime.BindQueryParameterWithOptions("{{.Style}}", {{.Explode}}, {{.Required}}, "{{.ParamName}}", ctx.QueryParams(), ¶ms.{{.GoName}}, runtime.BindQueryParameterOptions{Type: "{{.SchemaType}}", Format: "{{.SchemaFormat}}"}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter {{.ParamName}}: %s", err)) + } + {{else}} + if paramValue := ctx.QueryParam("{{.ParamName}}"); paramValue != "" { + {{if .IsPassThrough}} + params.{{.GoName}} = {{if .HasOptionalPointer}}&{{end}}paramValue + {{end}} + {{if .IsJson}} + var value {{.TypeDef}} + err = json.Unmarshal([]byte(paramValue), &value) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Error unmarshaling parameter '{{.ParamName}}' as JSON") + } + params.{{.GoName}} = {{if .HasOptionalPointer}}&{{end}}value + {{end}} + }{{if .Required}} else { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Query argument {{.ParamName}} is required, but not found")) + }{{end}} + {{end}} +{{end}} +{{range $paramIdx, $param := .HeaderParams}} + // ------------- {{if .Required}}Required{{else}}Optional{{end}} header parameter "{{.ParamName}}" ------------- + if valueList, found := ctx.Request().Header[http.CanonicalHeaderKey("{{.ParamName}}")]; found { + var {{.GoName}} {{.TypeDef}} + n := len(valueList) + if n != 1 { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Expected one value for {{.ParamName}}, got %d", n)) + } + {{if .IsPassThrough}} + params.{{.GoName}} = {{if .HasOptionalPointer}}&{{end}}valueList[0] + {{end}} + {{if .IsJson}} + err = json.Unmarshal([]byte(valueList[0]), &{{.GoName}}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Error unmarshaling parameter '{{.ParamName}}' as JSON") + } + params.{{.GoName}} = {{if .HasOptionalPointer}}&{{end}}{{.GoName}} + {{end}} + {{if .IsStyled}} + err = runtime.BindStyledParameterWithOptions("{{.Style}}", "{{.ParamName}}", valueList[0], &{{.GoName}}, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationHeader, Explode: {{.Explode}}, Required: {{.Required}}, Type: "{{.SchemaType}}", Format: "{{.SchemaFormat}}"}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter {{.ParamName}}: %s", err)) + } + params.{{.GoName}} = {{if .HasOptionalPointer}}&{{end}}{{.GoName}} + {{end}} + }{{if .Required}} else { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Header parameter {{.ParamName}} is required, but not found")) + }{{end}} +{{end}} +{{- end}} + return si.Handle{{$opid}}{{$.Prefix}}(ctx{{if .RequiresParamObject}}, params{{end}}) + }) + for _, mw := range middlewares { + h = mw(h) + } + return h +} + +{{end}} diff --git a/pkg/codegen/templates/echo/v5/echo-receiver.tmpl b/pkg/codegen/templates/echo/v5/echo-receiver.tmpl new file mode 100644 index 0000000000..b0f10d5b1a --- /dev/null +++ b/pkg/codegen/templates/echo/v5/echo-receiver.tmpl @@ -0,0 +1,93 @@ +// {{.Prefix}}ReceiverInterface represents handlers for receiving inbound +// {{.PrefixLower}} requests. Each {{.PrefixLower}} becomes a Handle*{{.Prefix}} +// method that the implementation fills in. The caller mounts the per- +// {{.PrefixLower}} echo.HandlerFunc returned by {Op}{{.Prefix}}Handler at +// whatever URL path they advertise to senders. +type {{.Prefix}}ReceiverInterface interface { +{{range .Operations -}} +{{.SummaryAsComment ""}} +// Handle{{.OperationId}}{{$.Prefix}} handles the {{.Method}} {{$.PrefixLower}} for {{.SourceName}}. +Handle{{.OperationId}}{{$.Prefix}}(ctx *echo.Context{{if .RequiresParamObject}}, params {{.OperationId}}Params{{end}}) error +{{end}} +} + +{{range .Operations -}} +{{$opid := .OperationId -}} +{{$srcName := .SourceName -}} +// {{$opid}}{{$.Prefix}}Handler returns the echo.HandlerFunc for the {{$srcName}} {{$.PrefixLower}}. +// Mount this at the URL path advertised to {{$.PrefixLower}} senders. Errors +// during parameter binding are returned via echo.NewHTTPError so echo's +// error-handling chain reports them as 400. Middlewares are applied in +// the order provided -- the last argument becomes the outermost wrapper. +func {{$opid}}{{$.Prefix}}Handler(si {{$.Prefix}}ReceiverInterface, middlewares ...echo.MiddlewareFunc) echo.HandlerFunc { + h := echo.HandlerFunc(func(ctx *echo.Context) error { +{{- if .RequiresParamObject}} + var err error + _ = err + + // Parameter object where we will unmarshal all parameters from the context. + var params {{$opid}}Params +{{range $paramIdx, $param := .QueryParams}} + // ------------- {{if .Required}}Required{{else}}Optional{{end}} query parameter "{{.ParamName}}" ------------- + {{if .IsStyled}} + err = runtime.BindQueryParameterWithOptions("{{.Style}}", {{.Explode}}, {{.Required}}, "{{.ParamName}}", ctx.QueryParams(), ¶ms.{{.GoName}}, runtime.BindQueryParameterOptions{Type: "{{.SchemaType}}", Format: "{{.SchemaFormat}}"}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter {{.ParamName}}: %s", err)) + } + {{else}} + if paramValue := ctx.QueryParam("{{.ParamName}}"); paramValue != "" { + {{if .IsPassThrough}} + params.{{.GoName}} = {{if .HasOptionalPointer}}&{{end}}paramValue + {{end}} + {{if .IsJson}} + var value {{.TypeDef}} + err = json.Unmarshal([]byte(paramValue), &value) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Error unmarshaling parameter '{{.ParamName}}' as JSON") + } + params.{{.GoName}} = {{if .HasOptionalPointer}}&{{end}}value + {{end}} + }{{if .Required}} else { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Query argument {{.ParamName}} is required, but not found")) + }{{end}} + {{end}} +{{end}} +{{range $paramIdx, $param := .HeaderParams}} + // ------------- {{if .Required}}Required{{else}}Optional{{end}} header parameter "{{.ParamName}}" ------------- + if valueList, found := ctx.Request().Header[http.CanonicalHeaderKey("{{.ParamName}}")]; found { + var {{.GoName}} {{.TypeDef}} + n := len(valueList) + if n != 1 { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Expected one value for {{.ParamName}}, got %d", n)) + } + {{if .IsPassThrough}} + params.{{.GoName}} = {{if .HasOptionalPointer}}&{{end}}valueList[0] + {{end}} + {{if .IsJson}} + err = json.Unmarshal([]byte(valueList[0]), &{{.GoName}}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Error unmarshaling parameter '{{.ParamName}}' as JSON") + } + params.{{.GoName}} = {{if .HasOptionalPointer}}&{{end}}{{.GoName}} + {{end}} + {{if .IsStyled}} + err = runtime.BindStyledParameterWithOptions("{{.Style}}", "{{.ParamName}}", valueList[0], &{{.GoName}}, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationHeader, Explode: {{.Explode}}, Required: {{.Required}}, Type: "{{.SchemaType}}", Format: "{{.SchemaFormat}}"}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter {{.ParamName}}: %s", err)) + } + params.{{.GoName}} = {{if .HasOptionalPointer}}&{{end}}{{.GoName}} + {{end}} + }{{if .Required}} else { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Header parameter {{.ParamName}} is required, but not found")) + }{{end}} +{{end}} +{{- end}} + return si.Handle{{$opid}}{{$.Prefix}}(ctx{{if .RequiresParamObject}}, params{{end}}) + }) + for _, mw := range middlewares { + h = mw(h) + } + return h +} + +{{end}} diff --git a/pkg/codegen/templates/fiber/fiber-receiver.tmpl b/pkg/codegen/templates/fiber/fiber-receiver.tmpl new file mode 100644 index 0000000000..ea0b4e6470 --- /dev/null +++ b/pkg/codegen/templates/fiber/fiber-receiver.tmpl @@ -0,0 +1,99 @@ +// {{.Prefix}}ReceiverInterface represents handlers for receiving inbound +// {{.PrefixLower}} requests. Each {{.PrefixLower}} becomes a Handle*{{.Prefix}} +// method that the implementation fills in. The caller mounts the per- +// {{.PrefixLower}} fiber.Handler returned by {Op}{{.Prefix}}Handler at +// whatever URL path they advertise to senders. +type {{.Prefix}}ReceiverInterface interface { +{{range .Operations -}} +{{.SummaryAsComment ""}} +// Handle{{.OperationId}}{{$.Prefix}} handles the {{.Method}} {{$.PrefixLower}} for {{.SourceName}}. +Handle{{.OperationId}}{{$.Prefix}}(c *fiber.Ctx{{if .RequiresParamObject}}, params {{.OperationId}}Params{{end}}) error +{{end}} +} + +{{range .Operations -}} +{{$opid := .OperationId -}} +{{$srcName := .SourceName -}} +// {{$opid}}{{$.Prefix}}Handler returns the fiber.Handler for the {{$srcName}} {{$.PrefixLower}}. +// Mount this at the URL path advertised to {{$.PrefixLower}} senders. Errors +// during parameter binding are returned via fiber.NewError so fiber's +// error chain reports them as 400. Per-handler middleware is not +// generated here; use fiber.App.Use() for engine-level middleware. +func {{$opid}}{{$.Prefix}}Handler(si {{$.Prefix}}ReceiverInterface) fiber.Handler { + return func(c *fiber.Ctx) error { +{{- if .RequiresParamObject}} + var err error + _ = err + + // Parameter object where we will unmarshal all parameters from the context. + var params {{$opid}}Params +{{if .QueryParams}} + var query url.Values + query, err = url.ParseQuery(string(c.Request().URI().QueryString())) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, fmt.Errorf("Invalid format for query string: %w", err).Error()) + } +{{end}} +{{range $paramIdx, $param := .QueryParams}} + // ------------- {{if .Required}}Required{{else}}Optional{{end}} query parameter "{{.ParamName}}" ------------- + {{if .IsStyled}} + err = runtime.BindQueryParameterWithOptions("{{.Style}}", {{.Explode}}, {{.Required}}, "{{.ParamName}}", query, ¶ms.{{.GoName}}, runtime.BindQueryParameterOptions{Type: "{{.SchemaType}}", Format: "{{.SchemaFormat}}"}) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, fmt.Errorf("Invalid format for parameter {{.ParamName}}: %w", err).Error()) + } + {{else}} + if paramValue := c.Query("{{.ParamName}}"); paramValue != "" { + {{if .IsPassThrough}} + params.{{.GoName}} = {{if .HasOptionalPointer}}&{{end}}paramValue + {{end}} + {{if .IsJson}} + var value {{.TypeDef}} + err = json.Unmarshal([]byte(paramValue), &value) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, fmt.Errorf("Error unmarshaling parameter '{{.ParamName}}' as JSON: %w", err).Error()) + } + params.{{.GoName}} = {{if .HasOptionalPointer}}&{{end}}value + {{end}} + }{{if .Required}} else { + return fiber.NewError(fiber.StatusBadRequest, "Query argument {{.ParamName}} is required, but not found") + }{{end}} + {{end}} +{{end}} +{{if .HeaderParams}} + headers := c.GetReqHeaders() +{{range $paramIdx, $param := .HeaderParams}} + // ------------- {{if .Required}}Required{{else}}Optional{{end}} header parameter "{{.ParamName}}" ------------- + if valueList, found := headers[http.CanonicalHeaderKey("{{.ParamName}}")]; found { + var {{.GoName}} {{.TypeDef}} + n := len(valueList) + if n != 1 { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Too many values for header {{.ParamName}}, 1 is required, but %d found", n)) + } + {{if .IsPassThrough}} + params.{{.GoName}} = {{if .HasOptionalPointer}}&{{end}}valueList[0] + {{end}} + {{if .IsJson}} + err = json.Unmarshal([]byte(valueList[0]), &{{.GoName}}) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, fmt.Errorf("Error unmarshaling parameter '{{.ParamName}}' as JSON: %w", err).Error()) + } + params.{{.GoName}} = {{if .HasOptionalPointer}}&{{end}}{{.GoName}} + {{end}} + {{if .IsStyled}} + err = runtime.BindStyledParameterWithOptions("{{.Style}}", "{{.ParamName}}", valueList[0], &{{.GoName}}, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationHeader, Explode: {{.Explode}}, Required: {{.Required}}, Type: "{{.SchemaType}}", Format: "{{.SchemaFormat}}"}) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, fmt.Errorf("Invalid format for parameter {{.ParamName}}: %w", err).Error()) + } + params.{{.GoName}} = {{if .HasOptionalPointer}}&{{end}}{{.GoName}} + {{end}} + }{{if .Required}} else { + return fiber.NewError(fiber.StatusBadRequest, "Header parameter {{.ParamName}} is required, but not found") + }{{end}} +{{end}} +{{end}} +{{- end}} + return si.Handle{{$opid}}{{$.Prefix}}(c{{if .RequiresParamObject}}, params{{end}}) + } +} + +{{end}} diff --git a/pkg/codegen/templates/gin/gin-receiver.tmpl b/pkg/codegen/templates/gin/gin-receiver.tmpl new file mode 100644 index 0000000000..db0faf78fc --- /dev/null +++ b/pkg/codegen/templates/gin/gin-receiver.tmpl @@ -0,0 +1,97 @@ +// {{.Prefix}}ReceiverInterface represents handlers for receiving inbound +// {{.PrefixLower}} requests. Each {{.PrefixLower}} becomes a Handle*{{.Prefix}} +// method that the implementation fills in. The caller mounts the per- +// {{.PrefixLower}} gin.HandlerFunc returned by {Op}{{.Prefix}}Handler at +// whatever URL path they advertise to senders. +type {{.Prefix}}ReceiverInterface interface { +{{range .Operations -}} +{{.SummaryAsComment ""}} +// Handle{{.OperationId}}{{$.Prefix}} handles the {{.Method}} {{$.PrefixLower}} for {{.SourceName}}. +Handle{{.OperationId}}{{$.Prefix}}(c *gin.Context{{if .RequiresParamObject}}, params {{.OperationId}}Params{{end}}) +{{end}} +} + +{{range .Operations -}} +{{$opid := .OperationId -}} +{{$srcName := .SourceName -}} +// {{$opid}}{{$.Prefix}}Handler returns the gin.HandlerFunc for the {{$srcName}} {{$.PrefixLower}}. +// Mount this at the URL path advertised to {{$.PrefixLower}} senders. +// Parameter-binding errors abort the request with 400 and a JSON body +// of the form {"error": "..."}. Engine-level middleware can be applied +// via gin.Engine.Use(); per-handler middleware is not generated here +// (gin's idiom prefers route-group / engine .Use composition). +func {{$opid}}{{$.Prefix}}Handler(si {{$.Prefix}}ReceiverInterface) gin.HandlerFunc { + return func(c *gin.Context) { +{{- if .RequiresParamObject}} + var err error + _ = err + + // Parameter object where we will unmarshal all parameters from the context. + var params {{$opid}}Params +{{range $paramIdx, $param := .QueryParams}} + // ------------- {{if .Required}}Required{{else}}Optional{{end}} query parameter "{{.ParamName}}" ------------- + {{if .IsStyled}} + err = runtime.BindQueryParameterWithOptions("{{.Style}}", {{.Explode}}, {{.Required}}, "{{.ParamName}}", c.Request.URL.Query(), ¶ms.{{.GoName}}, runtime.BindQueryParameterOptions{Type: "{{.SchemaType}}", Format: "{{.SchemaFormat}}"}) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid format for parameter {{.ParamName}}: %s", err)}) + return + } + {{else}} + if paramValue := c.Query("{{.ParamName}}"); paramValue != "" { + {{if .IsPassThrough}} + params.{{.GoName}} = {{if .HasOptionalPointer}}&{{end}}paramValue + {{end}} + {{if .IsJson}} + var value {{.TypeDef}} + err = json.Unmarshal([]byte(paramValue), &value) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid format for parameter {{.ParamName}}: %s", err)}) + return + } + params.{{.GoName}} = {{if .HasOptionalPointer}}&{{end}}value + {{end}} + }{{if .Required}} else { + c.JSON(http.StatusBadRequest, gin.H{"error": "Query parameter {{.ParamName}} is required, but not found"}) + return + }{{end}} + {{end}} +{{end}} +{{range $paramIdx, $param := .HeaderParams}} + // ------------- {{if .Required}}Required{{else}}Optional{{end}} header parameter "{{.ParamName}}" ------------- + if valueList, found := c.Request.Header[http.CanonicalHeaderKey("{{.ParamName}}")]; found { + var {{.GoName}} {{.TypeDef}} + n := len(valueList) + if n != 1 { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Expected one value for {{.ParamName}}, got %d", n)}) + return + } + {{if .IsPassThrough}} + params.{{.GoName}} = {{if .HasOptionalPointer}}&{{end}}valueList[0] + {{end}} + {{if .IsJson}} + err = json.Unmarshal([]byte(valueList[0]), &{{.GoName}}) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid format for parameter {{.ParamName}}: %s", err)}) + return + } + params.{{.GoName}} = {{if .HasOptionalPointer}}&{{end}}{{.GoName}} + {{end}} + {{if .IsStyled}} + err = runtime.BindStyledParameterWithOptions("{{.Style}}", "{{.ParamName}}", valueList[0], &{{.GoName}}, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationHeader, Explode: {{.Explode}}, Required: {{.Required}}, Type: "{{.SchemaType}}", Format: "{{.SchemaFormat}}"}) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid format for parameter {{.ParamName}}: %s", err)}) + return + } + params.{{.GoName}} = {{if .HasOptionalPointer}}&{{end}}{{.GoName}} + {{end}} + }{{if .Required}} else { + c.JSON(http.StatusBadRequest, gin.H{"error": "Header parameter {{.ParamName}} is required, but not found"}) + return + }{{end}} +{{end}} +{{- end}} + si.Handle{{$opid}}{{$.Prefix}}(c{{if .RequiresParamObject}}, params{{end}}) + } +} + +{{end}} diff --git a/pkg/codegen/templates/gorilla/gorilla-receiver.tmpl b/pkg/codegen/templates/gorilla/gorilla-receiver.tmpl new file mode 100644 index 0000000000..3089faea4e --- /dev/null +++ b/pkg/codegen/templates/gorilla/gorilla-receiver.tmpl @@ -0,0 +1,116 @@ +// {{.Prefix}}ReceiverInterface represents handlers for receiving inbound +// {{.PrefixLower}} requests. Each {{.PrefixLower}} becomes a Handle*{{.Prefix}} +// method that the implementation fills in. The caller mounts the per- +// {{.PrefixLower}} http.Handler returned by {Op}{{.Prefix}}Handler at +// whatever URL path they advertise to senders. +type {{.Prefix}}ReceiverInterface interface { +{{range .Operations -}} +{{.SummaryAsComment ""}} +// Handle{{.OperationId}}{{$.Prefix}} handles the {{.Method}} {{$.PrefixLower}} for {{.SourceName}}. +Handle{{.OperationId}}{{$.Prefix}}(w http.ResponseWriter, r *http.Request{{if .RequiresParamObject}}, params {{.OperationId}}Params{{end}}) +{{end}} +} + +// {{.Prefix}}ReceiverMiddlewareFunc wraps an http.Handler with cross- +// cutting behavior (signature verification, logging, rate limiting, ...). +type {{.Prefix}}ReceiverMiddlewareFunc func(http.Handler) http.Handler + +{{range .Operations -}} +{{$opid := .OperationId -}} +{{$srcName := .SourceName -}} +// {{$opid}}{{$.Prefix}}Handler returns the http.Handler for the {{$srcName}} {{$.PrefixLower}}. +// Mount this at the URL path advertised to {{$.PrefixLower}} senders. errHandler +// may be nil; if so, parameter-binding errors return 400 with the error +// message. Middlewares are applied in the order provided -- the last +// argument becomes the outermost wrapper. +func {{$opid}}{{$.Prefix}}Handler(si {{$.Prefix}}ReceiverInterface, errHandler func(w http.ResponseWriter, r *http.Request, err error), middlewares ...{{$.Prefix}}ReceiverMiddlewareFunc) http.Handler { + if errHandler == nil { + errHandler = func(w http.ResponseWriter, r *http.Request, err error) { + http.Error(w, err.Error(), http.StatusBadRequest) + } + } + var h http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { +{{- if .RequiresParamObject}} + var err error + _ = err + + // Parameter object where we will unmarshal all parameters from the request. + var params {{$opid}}Params +{{range $paramIdx, $param := .QueryParams}} + // ------------- {{if .Required}}Required{{else}}Optional{{end}} query parameter "{{.ParamName}}" ------------- + {{if (or .IsPassThrough .IsJson)}} + if paramValue := r.URL.Query().Get("{{.ParamName}}"); paramValue != "" { + {{if .IsPassThrough}} + params.{{.GoName}} = {{if .HasOptionalPointer}}&{{end}}paramValue + {{end}} + {{if .IsJson}} + var value {{.TypeDef}} + err = json.Unmarshal([]byte(paramValue), &value) + if err != nil { + errHandler(w, r, &UnmarshalingParamError{ParamName: "{{.ParamName}}", Err: err}) + return + } + params.{{.GoName}} = {{if .HasOptionalPointer}}&{{end}}value + {{end}} + }{{if .Required}} else { + errHandler(w, r, &RequiredParamError{ParamName: "{{.ParamName}}"}) + return + }{{end}} + {{end}} + {{if .IsStyled}} + err = runtime.BindQueryParameterWithOptions("{{.Style}}", {{.Explode}}, {{.Required}}, "{{.ParamName}}", r.URL.Query(), ¶ms.{{.GoName}}, runtime.BindQueryParameterOptions{Type: "{{.SchemaType}}", Format: "{{.SchemaFormat}}"}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + errHandler(w, r, &RequiredParamError{ParamName: "{{.ParamName}}"}) + } else { + errHandler(w, r, &InvalidParamFormatError{ParamName: "{{.ParamName}}", Err: err}) + } + return + } + {{end}} +{{end}} +{{range $paramIdx, $param := .HeaderParams}} + // ------------- {{if .Required}}Required{{else}}Optional{{end}} header parameter "{{.ParamName}}" ------------- + if valueList, found := r.Header[http.CanonicalHeaderKey("{{.ParamName}}")]; found { + var {{.GoName}} {{.TypeDef}} + n := len(valueList) + if n != 1 { + errHandler(w, r, &TooManyValuesForParamError{ParamName: "{{.ParamName}}", Count: n}) + return + } + {{if .IsPassThrough}} + params.{{.GoName}} = {{if .HasOptionalPointer}}&{{end}}valueList[0] + {{end}} + {{if .IsJson}} + err = json.Unmarshal([]byte(valueList[0]), &{{.GoName}}) + if err != nil { + errHandler(w, r, &UnmarshalingParamError{ParamName: "{{.ParamName}}", Err: err}) + return + } + params.{{.GoName}} = {{if .HasOptionalPointer}}&{{end}}{{.GoName}} + {{end}} + {{if .IsStyled}} + err = runtime.BindStyledParameterWithOptions("{{.Style}}", "{{.ParamName}}", valueList[0], &{{.GoName}}, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationHeader, Explode: {{.Explode}}, Required: {{.Required}}, Type: "{{.SchemaType}}", Format: "{{.SchemaFormat}}"}) + if err != nil { + errHandler(w, r, &InvalidParamFormatError{ParamName: "{{.ParamName}}", Err: err}) + return + } + params.{{.GoName}} = {{if .HasOptionalPointer}}&{{end}}{{.GoName}} + {{end}} + }{{if .Required}} else { + err := fmt.Errorf("Header parameter {{.ParamName}} is required, but not found") + errHandler(w, r, &RequiredHeaderError{ParamName: "{{.ParamName}}", Err: err}) + return + }{{end}} +{{end}} +{{- end}} + si.Handle{{$opid}}{{$.Prefix}}(w, r{{if .RequiresParamObject}}, params{{end}}) + }) + for _, mw := range middlewares { + h = mw(h) + } + return h +} + +{{end}} diff --git a/pkg/codegen/templates/iris/iris-receiver.tmpl b/pkg/codegen/templates/iris/iris-receiver.tmpl new file mode 100644 index 0000000000..324d98c42d --- /dev/null +++ b/pkg/codegen/templates/iris/iris-receiver.tmpl @@ -0,0 +1,103 @@ +// {{.Prefix}}ReceiverInterface represents handlers for receiving inbound +// {{.PrefixLower}} requests. Each {{.PrefixLower}} becomes a Handle*{{.Prefix}} +// method that the implementation fills in. The caller mounts the per- +// {{.PrefixLower}} iris.Handler returned by {Op}{{.Prefix}}Handler at +// whatever URL path they advertise to senders. +type {{.Prefix}}ReceiverInterface interface { +{{range .Operations -}} +{{.SummaryAsComment ""}} +// Handle{{.OperationId}}{{$.Prefix}} handles the {{.Method}} {{$.PrefixLower}} for {{.SourceName}}. +Handle{{.OperationId}}{{$.Prefix}}(ctx iris.Context{{if .RequiresParamObject}}, params {{.OperationId}}Params{{end}}) +{{end}} +} + +{{range .Operations -}} +{{$opid := .OperationId -}} +{{$srcName := .SourceName -}} +// {{$opid}}{{$.Prefix}}Handler returns the iris.Handler for the {{$srcName}} {{$.PrefixLower}}. +// Mount this at the URL path advertised to {{$.PrefixLower}} senders. +// Parameter-binding errors set status 400 with a plain-text reason. +// Per-handler middleware is not generated; iris's idiom prefers +// app.Use() / Party-level middleware. +func {{$opid}}{{$.Prefix}}Handler(si {{$.Prefix}}ReceiverInterface) iris.Handler { + return func(ctx iris.Context) { +{{- if .RequiresParamObject}} + var err error + _ = err + + // Parameter object where we will unmarshal all parameters from the context. + var params {{$opid}}Params +{{range $paramIdx, $param := .QueryParams}} + // ------------- {{if .Required}}Required{{else}}Optional{{end}} query parameter "{{.ParamName}}" ------------- + {{if .IsStyled}} + err = runtime.BindQueryParameterWithOptions("{{.Style}}", {{.Explode}}, {{.Required}}, "{{.ParamName}}", ctx.Request().URL.Query(), ¶ms.{{.GoName}}, runtime.BindQueryParameterOptions{Type: "{{.SchemaType}}", Format: "{{.SchemaFormat}}"}) + if err != nil { + ctx.StatusCode(http.StatusBadRequest) + _, _ = ctx.WriteString(fmt.Sprintf("Invalid format for parameter {{.ParamName}}: %s", err)) + return + } + {{else}} + if paramValue := ctx.URLParam("{{.ParamName}}"); paramValue != "" { + {{if .IsPassThrough}} + params.{{.GoName}} = {{if .HasOptionalPointer}}&{{end}}paramValue + {{end}} + {{if .IsJson}} + var value {{.TypeDef}} + err = json.Unmarshal([]byte(paramValue), &value) + if err != nil { + ctx.StatusCode(http.StatusBadRequest) + _, _ = ctx.WriteString(fmt.Sprintf("Invalid format for parameter {{.ParamName}}: %s", err)) + return + } + params.{{.GoName}} = {{if .HasOptionalPointer}}&{{end}}value + {{end}} + }{{if .Required}} else { + ctx.StatusCode(http.StatusBadRequest) + _, _ = ctx.WriteString("Query parameter {{.ParamName}} is required, but not found") + return + }{{end}} + {{end}} +{{end}} +{{range $paramIdx, $param := .HeaderParams}} + // ------------- {{if .Required}}Required{{else}}Optional{{end}} header parameter "{{.ParamName}}" ------------- + if valueList, found := ctx.Request().Header[http.CanonicalHeaderKey("{{.ParamName}}")]; found { + var {{.GoName}} {{.TypeDef}} + n := len(valueList) + if n != 1 { + ctx.StatusCode(http.StatusBadRequest) + _, _ = ctx.WriteString(fmt.Sprintf("Expected one value for {{.ParamName}}, got %d", n)) + return + } + {{if .IsPassThrough}} + params.{{.GoName}} = {{if .HasOptionalPointer}}&{{end}}valueList[0] + {{end}} + {{if .IsJson}} + err = json.Unmarshal([]byte(valueList[0]), &{{.GoName}}) + if err != nil { + ctx.StatusCode(http.StatusBadRequest) + _, _ = ctx.WriteString(fmt.Sprintf("Invalid format for parameter {{.ParamName}}: %s", err)) + return + } + params.{{.GoName}} = {{if .HasOptionalPointer}}&{{end}}{{.GoName}} + {{end}} + {{if .IsStyled}} + err = runtime.BindStyledParameterWithOptions("{{.Style}}", "{{.ParamName}}", valueList[0], &{{.GoName}}, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationHeader, Explode: {{.Explode}}, Required: {{.Required}}, Type: "{{.SchemaType}}", Format: "{{.SchemaFormat}}"}) + if err != nil { + ctx.StatusCode(http.StatusBadRequest) + _, _ = ctx.WriteString(fmt.Sprintf("Invalid format for parameter {{.ParamName}}: %s", err)) + return + } + params.{{.GoName}} = {{if .HasOptionalPointer}}&{{end}}{{.GoName}} + {{end}} + }{{if .Required}} else { + ctx.StatusCode(http.StatusBadRequest) + _, _ = ctx.WriteString("Header parameter {{.ParamName}} is required, but not found") + return + }{{end}} +{{end}} +{{- end}} + si.Handle{{$opid}}{{$.Prefix}}(ctx{{if .RequiresParamObject}}, params{{end}}) + } +} + +{{end}} diff --git a/pkg/codegen/templates/stdhttp/std-http-receiver.tmpl b/pkg/codegen/templates/stdhttp/std-http-receiver.tmpl new file mode 100644 index 0000000000..3089faea4e --- /dev/null +++ b/pkg/codegen/templates/stdhttp/std-http-receiver.tmpl @@ -0,0 +1,116 @@ +// {{.Prefix}}ReceiverInterface represents handlers for receiving inbound +// {{.PrefixLower}} requests. Each {{.PrefixLower}} becomes a Handle*{{.Prefix}} +// method that the implementation fills in. The caller mounts the per- +// {{.PrefixLower}} http.Handler returned by {Op}{{.Prefix}}Handler at +// whatever URL path they advertise to senders. +type {{.Prefix}}ReceiverInterface interface { +{{range .Operations -}} +{{.SummaryAsComment ""}} +// Handle{{.OperationId}}{{$.Prefix}} handles the {{.Method}} {{$.PrefixLower}} for {{.SourceName}}. +Handle{{.OperationId}}{{$.Prefix}}(w http.ResponseWriter, r *http.Request{{if .RequiresParamObject}}, params {{.OperationId}}Params{{end}}) +{{end}} +} + +// {{.Prefix}}ReceiverMiddlewareFunc wraps an http.Handler with cross- +// cutting behavior (signature verification, logging, rate limiting, ...). +type {{.Prefix}}ReceiverMiddlewareFunc func(http.Handler) http.Handler + +{{range .Operations -}} +{{$opid := .OperationId -}} +{{$srcName := .SourceName -}} +// {{$opid}}{{$.Prefix}}Handler returns the http.Handler for the {{$srcName}} {{$.PrefixLower}}. +// Mount this at the URL path advertised to {{$.PrefixLower}} senders. errHandler +// may be nil; if so, parameter-binding errors return 400 with the error +// message. Middlewares are applied in the order provided -- the last +// argument becomes the outermost wrapper. +func {{$opid}}{{$.Prefix}}Handler(si {{$.Prefix}}ReceiverInterface, errHandler func(w http.ResponseWriter, r *http.Request, err error), middlewares ...{{$.Prefix}}ReceiverMiddlewareFunc) http.Handler { + if errHandler == nil { + errHandler = func(w http.ResponseWriter, r *http.Request, err error) { + http.Error(w, err.Error(), http.StatusBadRequest) + } + } + var h http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { +{{- if .RequiresParamObject}} + var err error + _ = err + + // Parameter object where we will unmarshal all parameters from the request. + var params {{$opid}}Params +{{range $paramIdx, $param := .QueryParams}} + // ------------- {{if .Required}}Required{{else}}Optional{{end}} query parameter "{{.ParamName}}" ------------- + {{if (or .IsPassThrough .IsJson)}} + if paramValue := r.URL.Query().Get("{{.ParamName}}"); paramValue != "" { + {{if .IsPassThrough}} + params.{{.GoName}} = {{if .HasOptionalPointer}}&{{end}}paramValue + {{end}} + {{if .IsJson}} + var value {{.TypeDef}} + err = json.Unmarshal([]byte(paramValue), &value) + if err != nil { + errHandler(w, r, &UnmarshalingParamError{ParamName: "{{.ParamName}}", Err: err}) + return + } + params.{{.GoName}} = {{if .HasOptionalPointer}}&{{end}}value + {{end}} + }{{if .Required}} else { + errHandler(w, r, &RequiredParamError{ParamName: "{{.ParamName}}"}) + return + }{{end}} + {{end}} + {{if .IsStyled}} + err = runtime.BindQueryParameterWithOptions("{{.Style}}", {{.Explode}}, {{.Required}}, "{{.ParamName}}", r.URL.Query(), ¶ms.{{.GoName}}, runtime.BindQueryParameterOptions{Type: "{{.SchemaType}}", Format: "{{.SchemaFormat}}"}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + errHandler(w, r, &RequiredParamError{ParamName: "{{.ParamName}}"}) + } else { + errHandler(w, r, &InvalidParamFormatError{ParamName: "{{.ParamName}}", Err: err}) + } + return + } + {{end}} +{{end}} +{{range $paramIdx, $param := .HeaderParams}} + // ------------- {{if .Required}}Required{{else}}Optional{{end}} header parameter "{{.ParamName}}" ------------- + if valueList, found := r.Header[http.CanonicalHeaderKey("{{.ParamName}}")]; found { + var {{.GoName}} {{.TypeDef}} + n := len(valueList) + if n != 1 { + errHandler(w, r, &TooManyValuesForParamError{ParamName: "{{.ParamName}}", Count: n}) + return + } + {{if .IsPassThrough}} + params.{{.GoName}} = {{if .HasOptionalPointer}}&{{end}}valueList[0] + {{end}} + {{if .IsJson}} + err = json.Unmarshal([]byte(valueList[0]), &{{.GoName}}) + if err != nil { + errHandler(w, r, &UnmarshalingParamError{ParamName: "{{.ParamName}}", Err: err}) + return + } + params.{{.GoName}} = {{if .HasOptionalPointer}}&{{end}}{{.GoName}} + {{end}} + {{if .IsStyled}} + err = runtime.BindStyledParameterWithOptions("{{.Style}}", "{{.ParamName}}", valueList[0], &{{.GoName}}, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationHeader, Explode: {{.Explode}}, Required: {{.Required}}, Type: "{{.SchemaType}}", Format: "{{.SchemaFormat}}"}) + if err != nil { + errHandler(w, r, &InvalidParamFormatError{ParamName: "{{.ParamName}}", Err: err}) + return + } + params.{{.GoName}} = {{if .HasOptionalPointer}}&{{end}}{{.GoName}} + {{end}} + }{{if .Required}} else { + err := fmt.Errorf("Header parameter {{.ParamName}} is required, but not found") + errHandler(w, r, &RequiredHeaderError{ParamName: "{{.ParamName}}", Err: err}) + return + }{{end}} +{{end}} +{{- end}} + si.Handle{{$opid}}{{$.Prefix}}(w, r{{if .RequiresParamObject}}, params{{end}}) + }) + for _, mw := range middlewares { + h = mw(h) + } + return h +} + +{{end}} diff --git a/pkg/codegen/templates/webhook-initiator.tmpl b/pkg/codegen/templates/webhook-initiator.tmpl new file mode 100644 index 0000000000..032e3933ed --- /dev/null +++ b/pkg/codegen/templates/webhook-initiator.tmpl @@ -0,0 +1,235 @@ +// WebhookInitiator sends OpenAPI 3.1 webhook requests to target URLs. +// Modeled on the generated Client, but with no stored Server -- the full +// target URL is provided per-call by the caller (typically discovered +// from a subscription registration). +type WebhookInitiator struct { + // Doer for performing requests, typically a *http.Client with any + // customized settings, such as certificate chains. + Client HttpRequestDoer + + // A list of callbacks for modifying requests which are generated before sending over + // the network. + RequestEditors []RequestEditorFn +} + +// WebhookInitiatorOption allows setting custom parameters during construction. +type WebhookInitiatorOption func(*WebhookInitiator) error + +// NewWebhookInitiator creates a new WebhookInitiator with reasonable defaults. +func NewWebhookInitiator(opts ...WebhookInitiatorOption) (*WebhookInitiator, error) { + initiator := WebhookInitiator{} + for _, o := range opts { + if err := o(&initiator); err != nil { + return nil, err + } + } + if initiator.Client == nil { + initiator.Client = &http.Client{} + } + return &initiator, nil +} + +// WithWebhookHTTPClient allows overriding the default Doer, which is +// automatically created using http.Client. This is useful for tests. +func WithWebhookHTTPClient(doer HttpRequestDoer) WebhookInitiatorOption { + return func(p *WebhookInitiator) error { + p.Client = doer + return nil + } +} + +// WithWebhookRequestEditorFn allows setting up a callback function, which +// will be called right before sending the webhook request. This can be +// used to mutate the request, e.g. to add signature headers. +func WithWebhookRequestEditorFn(fn RequestEditorFn) WebhookInitiatorOption { + return func(p *WebhookInitiator) error { + p.RequestEditors = append(p.RequestEditors, fn) + return nil + } +} + +func (p *WebhookInitiator) applyWebhookEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error { + for _, r := range p.RequestEditors { + if err := r(ctx, req); err != nil { + return err + } + } + for _, r := range additionalEditors { + if err := r(ctx, req); err != nil { + return err + } + } + return nil +} + +// WebhookInitiatorInterface is the interface specification for the webhook initiator. +type WebhookInitiatorInterface interface { +{{range . -}} +{{$hasParams := .RequiresParamObject -}} +{{$opid := .OperationId -}} + // {{$opid}}{{if .HasBody}}WithBody{{end}} fires the {{.WebhookName}} webhook{{if .HasBody}} with any body{{end}} + {{$opid}}{{if .HasBody}}WithBody{{end}}(ctx context.Context, targetURL string{{if $hasParams}}, params *{{$opid}}Params{{end}}{{if .HasBody}}, contentType string, body io.Reader{{end}}, reqEditors... RequestEditorFn) (*http.Response, error) +{{range .Bodies}} + {{if .IsSupportedByClient -}} + {{$opid}}{{.Suffix}}(ctx context.Context, targetURL string{{if $hasParams}}, params *{{$opid}}Params{{end}}, body {{$opid}}{{.NameTag}}RequestBody, reqEditors... RequestEditorFn) (*http.Response, error) + {{end -}} +{{end}}{{/* range .Bodies */}} +{{end}}{{/* range . */}} +} + + +{{/* Generate webhook initiator methods */}} +{{range . -}} +{{$hasParams := .RequiresParamObject -}} +{{$opid := .OperationId -}} + +func (p *WebhookInitiator) {{$opid}}{{if .HasBody}}WithBody{{end}}(ctx context.Context, targetURL string{{if $hasParams}}, params *{{$opid}}Params{{end}}{{if .HasBody}}, contentType string, body io.Reader{{end}}, reqEditors... RequestEditorFn) (*http.Response, error) { + req, err := New{{$opid}}WebhookRequest{{if .HasBody}}WithBody{{end}}(targetURL{{if $hasParams}}, params{{end}}{{if .HasBody}}, contentType, body{{end}}) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := p.applyWebhookEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return p.Client.Do(req) +} + +{{range .Bodies}} +{{if .IsSupportedByClient -}} +func (p *WebhookInitiator) {{$opid}}{{.Suffix}}(ctx context.Context, targetURL string{{if $hasParams}}, params *{{$opid}}Params{{end}}, body {{$opid}}{{.NameTag}}RequestBody, reqEditors... RequestEditorFn) (*http.Response, error) { + req, err := New{{$opid}}WebhookRequest{{.Suffix}}(targetURL{{if $hasParams}}, params{{end}}, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := p.applyWebhookEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return p.Client.Do(req) +} +{{end -}} +{{end}}{{/* range .Bodies */}} +{{end}}{{/* range . */}} + +{{/* Generate webhook request builders */}} +{{range . -}} +{{$hasParams := .RequiresParamObject -}} +{{$opid := .OperationId -}} +{{$method := .Method -}} +{{$webhookName := .WebhookName -}} + +{{range .Bodies}} +{{if .IsSupportedByClient -}} +// New{{$opid}}WebhookRequest{{.Suffix}} builds a {{.ContentType}} {{$method}} request for the {{$webhookName}} webhook +func New{{$opid}}WebhookRequest{{.Suffix}}(targetURL string{{if $hasParams}}, params *{{$opid}}Params{{end}}, body {{$opid}}{{.NameTag}}RequestBody) (*http.Request, error) { + var bodyReader io.Reader + {{if .IsJSON -}} + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + {{else if eq .NameTag "Formdata" -}} + bodyStr, err := runtime.MarshalForm(body, nil) + if err != nil { + return nil, err + } + bodyReader = strings.NewReader(bodyStr.Encode()) + {{else if eq .NameTag "Text" -}} + if stringer, ok := interface{}(body).(fmt.Stringer); ok { + bodyReader = strings.NewReader(stringer.String()) + } else { + {{if .Schema.IsPrimitive -}} + bodyReader = strings.NewReader(fmt.Sprint(body)) + {{else -}} + return nil, fmt.Errorf("text/plain is not supported for complex types, define a String() method on {{.Schema.TypeDecl}} to marshal it as text") + {{end -}} + } + {{end -}} + return New{{$opid}}WebhookRequestWithBody(targetURL{{if $hasParams}}, params{{end}}, "{{.ContentType}}", bodyReader) +} +{{end -}} +{{end}} + +// New{{$opid}}WebhookRequest{{if .HasBody}}WithBody{{end}} builds a {{.Method}} request for the {{.WebhookName}} webhook{{if .HasBody}} with any body{{end}} +func New{{$opid}}WebhookRequest{{if .HasBody}}WithBody{{end}}(targetURL string{{if $hasParams}}, params *{{$opid}}Params{{end}}{{if .HasBody}}, contentType string, body io.Reader{{end}}) (*http.Request, error) { + var err error + _ = err + + reqURL, err := url.Parse(targetURL) + if err != nil { + return nil, err + } + +{{if .QueryParams}} + if params != nil { + queryValues := reqURL.Query() + var rawQueryFragments []string + {{range $paramIdx, $param := .QueryParams}} + {{if .RequiresNilCheck}} if params.{{.GoName}} != nil { {{end}} + {{if .IsPassThrough}} + queryValues.Add("{{.ParamName}}", {{if .HasOptionalPointer}}*{{end}}params.{{.GoName}}) + {{end}} + {{if .IsJson}} + if queryParamBuf, err := json.Marshal({{if .HasOptionalPointer}}*{{end}}params.{{.GoName}}); err != nil { + return nil, err + } else { + queryValues.Add("{{.ParamName}}", string(queryParamBuf)) + } + {{end}} + {{if .IsStyled}} + if queryFrag, err := runtime.StyleParamWithOptions("{{.Style}}", {{.Explode}}, "{{.ParamName}}", {{if and .RequiresNilCheck .HasOptionalPointer}}*{{end}}params.{{.GoName}}, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "{{.SchemaType}}", Format: "{{.SchemaFormat}}"}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + {{end}} + {{if .RequiresNilCheck}}}{{end}} + {{end}} + if encoded := queryValues.Encode(); encoded != "" { + rawQueryFragments = append(rawQueryFragments, encoded) + } + reqURL.RawQuery = strings.Join(rawQueryFragments, "&") + } +{{end}}{{/* if .QueryParams */}} + + req, err := http.NewRequest({{.Method | httpMethodConstant}}, reqURL.String(), {{if .HasBody}}body{{else}}nil{{end}}) + if err != nil { + return nil, err + } + + {{if .HasBody}}req.Header.Add("Content-Type", contentType){{end}} +{{ if .HeaderParams }} + if params != nil { + {{range $paramIdx, $param := .HeaderParams}} + {{if .RequiresNilCheck}} if params.{{.GoName}} != nil { {{end}} + var headerParam{{$paramIdx}} string + {{if .IsPassThrough}} + headerParam{{$paramIdx}} = {{if .HasOptionalPointer}}*{{end}}params.{{.GoName}} + {{end}} + {{if .IsJson}} + var headerParamBuf{{$paramIdx}} []byte + headerParamBuf{{$paramIdx}}, err = json.Marshal({{if .HasOptionalPointer}}*{{end}}params.{{.GoName}}) + if err != nil { + return nil, err + } + headerParam{{$paramIdx}} = string(headerParamBuf{{$paramIdx}}) + {{end}} + {{if .IsStyled}} + headerParam{{$paramIdx}}, err = runtime.StyleParamWithOptions("{{.Style}}", {{.Explode}}, "{{.ParamName}}", {{if .HasOptionalPointer}}*{{end}}params.{{.GoName}}, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationHeader, Type: "{{.SchemaType}}", Format: "{{.SchemaFormat}}"}) + if err != nil { + return nil, err + } + {{end}} + req.Header.Set("{{.ParamName}}", headerParam{{$paramIdx}}) + {{if .RequiresNilCheck}}}{{end}} + {{end}} + } +{{- end }}{{/* if .HeaderParams */}} + return req, nil +} + +{{end}}{{/* Range */}} diff --git a/pkg/codegen/test_specs/x-go-type-import-pet.yaml b/pkg/codegen/test_specs/x-go-type-import-pet.yaml index b236564bb5..0ce960633f 100644 --- a/pkg/codegen/test_specs/x-go-type-import-pet.yaml +++ b/pkg/codegen/test_specs/x-go-type-import-pet.yaml @@ -97,6 +97,7 @@ paths: path: github.com/mailru/easyjson - name: path in: path + required: true schema: x-go-type: pgtype.Color x-go-type-import: @@ -187,6 +188,7 @@ components: VersionPath: name: versionPath in: path + required: true schema: x-go-type: swag.Swag x-go-type-import: