Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ Given any OpenAPI 3.1 (or 3.0) spec, it outputs a complete, idiomatic Go client
- **Webhooks and callbacks**: typed payloads and a dispatcher for the requests the API sends you
- **Server URLs**: `DefaultBaseURL` from the spec, with a builder for templated servers
- **Response headers**: status and headers captured through the context, with declared headers parsed per operation
- **Authentication**`AuthProvider` interface with built-in Bearer, API key, and Basic auth
- **Authentication**: `AuthProvider` interface with built-in Bearer, API key, and Basic auth, skipped for operations the spec marks as needing none
- **Error handling** — `APIError` with sentinel errors (`errors.Is`), typed error wrappers with parsed response bodies (`errors.As`), readable messages via `x-ms-primary-error-message`
- **Pagination**: auto-detected cursor, offset, and page pagination with a generic `PageIterator[T]`
- **Retries**: configurable exponential backoff with jitter, honoring `Retry-After` in both forms and declining a wait past `MaxDelay`
Expand Down
3 changes: 3 additions & 0 deletions internal/analyzer/operations.go
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,9 @@ func (a *Analyzer) convertOperation(httpMethod, path string, pathItem *v3high.Pa
Path: path,
Tags: op.Tags,
Deprecated: op.Deprecated != nil && *op.Deprecated,
// An absent security field inherits the document's; an empty one
// overrides it to say this operation needs no credential.
NoAuth: op.Security != nil && len(op.Security) == 0,
}

// Merge path-level and operation-level parameters.
Expand Down
47 changes: 47 additions & 0 deletions internal/analyzer/security_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -149,3 +149,50 @@ func TestSecuritySchemes_UnsupportedDegradeToAWarning(t *testing.T) {
t.Errorf("warnings = %v, want one each for legacy, mtls, and weird", pkg.Warnings)
}
}

const operationSecuritySpec = `openapi: 3.1.0
info: { title: t, version: "1" }
security:
- bearer: []
paths:
/inherits:
get:
operationId: inherits
responses: { "204": { description: ok } }
/public:
get:
operationId: optsOut
security: []
responses: { "204": { description: ok } }
/picks:
get:
operationId: picks
security:
- bearer: []
responses: { "204": { description: ok } }
components:
securitySchemes:
bearer: { type: http, scheme: bearer }
`

// An absent security field inherits the document's; an empty one overrides it to
// say the operation takes no credential. The two are different declarations and
// only the second one opts out.
func TestOperationSecurity_EmptyRequirementOptsOut(t *testing.T) {
pkg, _ := analyzeSpec(t, operationSecuritySpec)

byName := make(map[string]*ir.OperationDef, len(pkg.Operations))
for _, op := range pkg.Operations {
byName[op.Name] = op
}

if op := byName["Inherits"]; op == nil || op.NoAuth {
t.Errorf("Inherits NoAuth = %v, want false: it declares nothing and inherits", op)
}
if op := byName["OptsOut"]; op == nil || !op.NoAuth {
t.Errorf("OptsOut NoAuth = %v, want true", op)
}
if op := byName["Picks"]; op == nil || op.NoAuth {
t.Errorf("Picks NoAuth = %v, want false: it names a scheme", op)
}
}
116 changes: 116 additions & 0 deletions internal/generator/e2e_operation_security_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
package generator

import "testing"

const operationSecurityAPISpec = `openapi: 3.1.0
info: { title: sec, version: "1" }
security:
- bearer: []
paths:
/private:
get:
operationId: getPrivate
responses: { "204": { description: ok } }
/public:
get:
operationId: getPublic
security: []
responses: { "204": { description: ok } }
/public-body:
post:
operationId: postPublic
security: []
requestBody:
required: true
content:
application/json: { schema: { type: string } }
responses: { "204": { description: ok } }
components:
securitySchemes:
bearer: { type: http, scheme: bearer }
`

// TestE2E_OperationSecurityOptOut covers an operation declaring security: [].
// Auth was applied for every request the client made, so an endpoint the spec
// documents as needing no credential received one anyway.
func TestE2E_OperationSecurityOptOut(t *testing.T) {
files, _ := generateFromSpec(t, operationSecurityAPISpec, "secapi")

runGeneratedWireTest(t, files, "operationsecurity", `package secapi

import (
"net/http"
"net/http/httptest"
"testing"
)

func authSeen(t *testing.T) (*Client, *[]string) {
t.Helper()
var seen []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seen = append(seen, r.Header.Get("Authorization"))
w.WriteHeader(http.StatusNoContent)
}))
t.Cleanup(srv.Close)
return NewClient(srv.URL, WithAuth(&BearerAuth{Token: "s3cr3t"})), &seen
}

func TestPublicOperationSendsNoCredential(t *testing.T) {
client, seen := authSeen(t)

if err := client.GetPrivate(t.Context()); err != nil {
t.Fatalf("GetPrivate: %v", err)
}
if err := client.GetPublic(t.Context()); err != nil {
t.Fatalf("GetPublic: %v", err)
}

if (*seen)[0] != "Bearer s3cr3t" {
t.Errorf("the operation inheriting the document's security got %q", (*seen)[0])
}
if (*seen)[1] != "" {
t.Errorf("the operation declaring security: [] got %q", (*seen)[1])
}
}

// Opting out of auth changes nothing else about the request.
func TestPublicOperationStillSendsItsBody(t *testing.T) {
var payload, auth string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
buf := make([]byte, r.ContentLength)
r.Body.Read(buf)
payload, auth = string(buf), r.Header.Get("Authorization")
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()

client := NewClient(srv.URL, WithAuth(&BearerAuth{Token: "s3cr3t"}))
if err := client.PostPublic(t.Context(), "hello"); err != nil {
t.Fatalf("PostPublic: %v", err)
}
if payload != `+"`"+`"hello"`+"`"+` {
t.Errorf("payload = %q", payload)
}
if auth != "" {
t.Errorf("Authorization = %q, want none", auth)
}
}

// A client with no provider is unaffected either way.
func TestNoProviderIsUnaffected(t *testing.T) {
var seen []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seen = append(seen, r.Header.Get("Authorization"))
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()

if err := NewClient(srv.URL).GetPrivate(t.Context()); err != nil {
t.Fatalf("GetPrivate: %v", err)
}
if seen[0] != "" {
t.Errorf("Authorization = %q, want none", seen[0])
}
}
`)
}
7 changes: 5 additions & 2 deletions internal/ir/operations.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,11 @@ type OperationDef struct {
SuccessResponse *ResponseDef // The primary 2xx response
ErrorResponses []*ResponseDef // 4xx/5xx responses
SecurityReqs [][]SecurityReq // OR of (AND of scheme refs)
Deprecated bool
Pagination *PaginationDef // nil if not paginated
// NoAuth records that the operation declares an empty security requirement,
// which overrides the document's to say it takes no credential.
NoAuth bool
Deprecated bool
Pagination *PaginationDef // nil if not paginated
}

// ParamDef represents an operation parameter.
Expand Down
16 changes: 2 additions & 14 deletions internal/parser/multifile_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ components:
`)

// Deliberately not the spec's directory: this is the CI case.
chdir(t, t.TempDir())
t.Chdir(t.TempDir())

result, err := Parse(filepath.Join(dir, "api.yaml"), Config{})
if err != nil {
Expand Down Expand Up @@ -81,7 +81,7 @@ components:
shared: { $ref: "../common.yaml#/components/schemas/Shared" }
`)

chdir(t, t.TempDir())
t.Chdir(t.TempDir())

if _, err := Parse(filepath.Join(specDir, "api.yaml"), Config{}); err != nil {
t.Fatalf("Parse: %v", err)
Expand All @@ -94,15 +94,3 @@ func writeSpecFile(t *testing.T, dir, name, content string) {
t.Fatalf("writing %s: %v", name, err)
}
}

func chdir(t *testing.T, dir string) {
t.Helper()
previous, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
if err := os.Chdir(dir); err != nil {
t.Fatalf("chdir: %v", err)
}
t.Cleanup(func() { os.Chdir(previous) })
}
8 changes: 5 additions & 3 deletions internal/templates/client.go.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,10 @@ func NewClient(baseURL string, opts ...ClientOption) *Client {
}

// do executes an HTTP request and decodes the response. contentType selects the
// request body encoding and is sent as the Content-Type header.
func (c *Client) do(ctx context.Context, method string, path string, body any, contentType string, result any, accept string, headers ...http.Header) error {
// request body encoding and is sent as the Content-Type header. An operation
// whose spec declares an empty security requirement passes authenticated false,
// since it says it takes no credential.
func (c *Client) do(ctx context.Context, method string, path string, body any, contentType string, result any, accept string, authenticated bool, headers ...http.Header) error {
fullURL := c.baseURL + path

var payload []byte
Expand Down Expand Up @@ -130,7 +132,7 @@ func (c *Client) do(ctx context.Context, method string, path string, body any, c
}
}

{{ if .AuthSchemes }} if c.auth != nil {
{{ if .AuthSchemes }} if c.auth != nil && authenticated {
if err := c.auth.Apply(req); err != nil {
return fmt.Errorf("applying auth: %w", err)
}
Expand Down
4 changes: 2 additions & 2 deletions internal/templates/operations.go.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,11 @@ type {{ .Name }}Params struct {
{{ end }}{{ end }}
{{- $errType := errorType . -}}
{{ if successType . }} var result {{ successType . }}
if err := c.do(ctx, "{{ .HTTPMethod }}", path, {{ if hasBody . }}body{{ else }}nil{{ end }}, {{ printf "%q" (requestContentType .) }}, &result, {{ printf "%q" (successContentType .) }}{{ if $needHeaders }}, headers{{ end }}); err != nil {
if err := c.do(ctx, "{{ .HTTPMethod }}", path, {{ if hasBody . }}body{{ else }}nil{{ end }}, {{ printf "%q" (requestContentType .) }}, &result, {{ printf "%q" (successContentType .) }}, {{ not .NoAuth }}{{ if $needHeaders }}, headers{{ end }}); err != nil {
return nil, {{ if $errType }}parse{{ $errType }}(err){{ else }}err{{ end }}
}
return &result, nil
{{ else }} if err := c.do(ctx, "{{ .HTTPMethod }}", path, {{ if hasBody . }}body{{ else }}nil{{ end }}, {{ printf "%q" (requestContentType .) }}, nil, {{ printf "%q" (successContentType .) }}{{ if $needHeaders }}, headers{{ end }}); err != nil {
{{ else }} if err := c.do(ctx, "{{ .HTTPMethod }}", path, {{ if hasBody . }}body{{ else }}nil{{ end }}, {{ printf "%q" (requestContentType .) }}, nil, {{ printf "%q" (successContentType .) }}, {{ not .NoAuth }}{{ if $needHeaders }}, headers{{ end }}); err != nil {
return {{ if $errType }}parse{{ $errType }}(err){{ else }}err{{ end }}
}
return nil
Expand Down