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
23 changes: 22 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ Given any OpenAPI 3.1 (or 3.0) spec, it outputs a complete, idiomatic Go client
- **Response headers**: status and headers captured through the context, with declared headers parsed per operation
- **Streaming**: `text/event-stream` responses get a typed `EventStream[T]` read as the server writes it
- **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`
- **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`, RFC 9457 problem details via `Problem()`
- **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`
- **Middleware** — composable request/response middleware chain
Expand Down Expand Up @@ -289,6 +289,27 @@ response renders `detail` when it has one and `title` when it does not. A body
with no such property, or with one holding something other than text, keeps the
raw output.

### Problem details

A response sent as `application/problem+json` is read as RFC 9457 problem
details, whatever the operation's error schema says. `APIError.Problem()`
returns its `Type`, `Title`, `Status`, `Detail` and `Instance`, with every other
member, such as an error code, in `Extensions` as raw JSON. `Error()` renders its
`detail`, or its `title` when it has none, so a typed wrapper whose schema doesn't
fit the problem still shows the message rather than the body:

```go
var apiErr *client.APIError
if errors.As(err, &apiErr) {
if p := apiErr.Problem(); p != nil {
var code string
_ = json.Unmarshal(p.Extensions["code"], &code)
}
}
```

Any other media type is not parsed, and `ContentType` holds what the server sent.

### Webhooks and callbacks

A spec's inbound half, `webhooks` and an operation's `callbacks`, describes
Expand Down
165 changes: 165 additions & 0 deletions internal/generator/e2e_problem_details_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
package generator

import "testing"

const problemDetailsSpec = `openapi: "3.1.0"
info:
title: Probs
version: 1.0.0
paths:
/widgets/{id}:
get:
operationId: getWidget
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
"200":
description: ok
content:
application/json:
schema:
$ref: "#/components/schemas/Widget"
default:
description: unexpected error
content:
application/json:
schema:
$ref: "#/components/schemas/Failure"
components:
schemas:
Widget:
type: object
properties:
id:
type: string
Failure:
type: object
required:
- message
properties:
message:
type: string
x-ms-primary-error-message: true
errors:
type: array
items:
type: string
`

// TestE2E_ProblemDetails verifies that an RFC 9457 problem details body is
// read as one: Error() shows its detail (or title), Problem() exposes its
// members and extensions, and a typed error wrapper whose schema doesn't
// match the problem still shows the detail instead of the raw body.
func TestE2E_ProblemDetails(t *testing.T) {
files, _ := generateFromSpec(t, problemDetailsSpec, "probs")

runtimeTest := `package probs

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

const validation = ` + "`" + `{"type":"/problems/validation","title":"Invalid request","status":422,"detail":"Some fields aren't valid.","code":"validation","errors":[{"pointer":"#/name","detail":"Must be at most 8 characters."}]}` + "`" + `

func problemError(body string) *APIError {
return &APIError{StatusCode: 422, Status: "422 Unprocessable Entity", ContentType: "application/problem+json; charset=utf-8", Body: []byte(body)}
}

func TestErrorShowsDetail(t *testing.T) {
if got := problemError(validation).Error(); got != "API error 422 Unprocessable Entity: Some fields aren't valid." {
t.Fatalf("Error() = %q", got)
}
}

func TestErrorFallsBackToTitle(t *testing.T) {
err := problemError(` + "`" + `{"type":"about:blank","title":"Not Found","status":404}` + "`" + `)
if got := err.Error(); got != "API error 422 Unprocessable Entity: Not Found" {
t.Fatalf("Error() = %q", got)
}
}

func TestProblemMembersAndExtensions(t *testing.T) {
p := problemError(validation).Problem()
if p == nil {
t.Fatal("Problem() = nil")
}
if p.Type != "/problems/validation" || p.Title != "Invalid request" || p.Status != 422 || p.Detail != "Some fields aren't valid." {
t.Errorf("members = %+v", p)
}
if string(p.Extensions["code"]) != ` + "`" + `"validation"` + "`" + ` || p.Extensions["errors"] == nil {
t.Errorf("extensions = %v", p.Extensions)
}
if _, ok := p.Extensions["detail"]; ok {
t.Error("a standard member is repeated in Extensions")
}
}

func TestNotAProblemWithoutItsMediaType(t *testing.T) {
err := &APIError{StatusCode: 400, Status: "400 Bad Request", ContentType: "application/json", Body: []byte(` + "`" + `{"detail":"x"}` + "`" + `)}
if err.Problem() != nil {
t.Error("Problem() parsed an application/json body")
}
if got := err.Error(); got != ` + "`" + `API error 400 Bad Request: {"detail":"x"}` + "`" + ` {
t.Fatalf("Error() = %q", got)
}
}

func TestMalformedProblemKeepsBody(t *testing.T) {
err := problemError(` + "`" + `{"status":"bad"}` + "`" + `)
if err.Problem() != nil {
t.Error("Problem() accepted a status that isn't a number")
}
if got := err.Error(); got != ` + "`" + `API error 422 Unprocessable Entity: {"status":"bad"}` + "`" + ` {
t.Fatalf("Error() = %q", got)
}
}

// The typed wrapper's schema expects errors as strings; a problem's are
// objects, so it can't be parsed into it, and the problem's detail shows.
func TestWrapperThatDoesNotFitShowsDetail(t *testing.T) {
err := parseFailureResponse(problemError(validation))
if got := err.Error(); got != "API error 422 Unprocessable Entity: Some fields aren't valid." {
t.Fatalf("Error() = %q", got)
}
}

// A problem without the wrapper's message still parses into it; the wrapper
// then defers to the problem's detail.
func TestWrapperWithoutMessageShowsDetail(t *testing.T) {
err := parseFailureResponse(problemError(` + "`" + `{"title":"Not Found","status":404,"detail":"There's no widget named a."}` + "`" + `))
if got := err.Error(); got != "API error 422 Unprocessable Entity: There's no widget named a." {
t.Fatalf("Error() = %q", got)
}
}

func TestClientRecordsContentType(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/problem+json")
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(` + "`" + `{"type":"about:blank","title":"Not Found","status":404,"detail":"There's no widget named a.","code":"not_found"}` + "`" + `))
}))
defer srv.Close()

_, err := NewClient(srv.URL).GetWidget(context.Background(), "a")
var apiErr *APIError
if !errors.As(err, &apiErr) {
t.Fatalf("err = %v, want an *APIError", err)
}
if p := apiErr.Problem(); p == nil || p.Detail != "There's no widget named a." || string(p.Extensions["code"]) != ` + "`" + `"not_found"` + "`" + ` {
t.Fatalf("Problem() = %+v", p)
}
if !errors.Is(err, ErrNotFound) {
t.Error("errors.Is(err, ErrNotFound) = false")
}
}
`
runGeneratedWireTest(t, files, "probs", runtimeTest)
}
2 changes: 1 addition & 1 deletion internal/templates/client.go.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ func (c *Client) send(ctx context.Context, method string, path string, body any,
if readErr != nil {
return nil, fmt.Errorf("reading response body: %w", readErr)
}
return nil, &APIError{StatusCode: resp.StatusCode, Status: resp.Status, Body: respBody}
return nil, &APIError{StatusCode: resp.StatusCode, Status: resp.Status, ContentType: resp.Header.Get("Content-Type"), Body: respBody}
}

return resp, nil
Expand Down
49 changes: 45 additions & 4 deletions internal/templates/errors.go.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@
package {{ .Name }}

import (
{{- if uniqueErrorTypes . }}
"encoding/json"
{{- if uniqueErrorTypes . }}
"errors"
{{- end }}
"fmt"
"mime"
"net/http"
)

Expand All @@ -29,12 +30,21 @@ var (

// APIError represents a non-2xx HTTP response.
type APIError struct {
StatusCode int
Status string
Body []byte
StatusCode int
Status string
ContentType string
Body []byte
}

func (e *APIError) Error() string {
if p := e.Problem(); p != nil {
if p.Detail != "" {
return fmt.Sprintf("API error %s: %s", e.statusLabel(), p.Detail)
}
if p.Title != "" {
return fmt.Sprintf("API error %s: %s", e.statusLabel(), p.Title)
}
}
if len(e.Body) > 0 {
return fmt.Sprintf("API error %s: %s", e.statusLabel(), e.Body)
}
Expand All @@ -54,6 +64,37 @@ func (e *APIError) statusLabel() string {
return fmt.Sprintf("%d", e.StatusCode)
}

// ProblemDetails is an RFC 9457 problem details object.
type ProblemDetails struct {
Type string `json:"type,omitempty"`
Title string `json:"title,omitempty"`
Status int `json:"status,omitempty"`
Detail string `json:"detail,omitempty"`
Instance string `json:"instance,omitempty"`
// Extensions holds every other member, such as an error code, by name.
Extensions map[string]json.RawMessage `json:"-"`
}

// Problem returns the body as RFC 9457 problem details when the response is
// application/problem+json, and nil otherwise.
func (e *APIError) Problem() *ProblemDetails {
if mediaType, _, _ := mime.ParseMediaType(e.ContentType); mediaType != "application/problem+json" {
return nil
}
var p ProblemDetails
var members map[string]json.RawMessage
if json.Unmarshal(e.Body, &p) != nil || json.Unmarshal(e.Body, &members) != nil {
return nil
}
for _, name := range []string{"type", "title", "status", "detail", "instance"} {
delete(members, name)
}
if len(members) > 0 {
p.Extensions = members
}
return &p
}

// Is supports errors.Is by matching on status code.
func (e *APIError) Is(target error) bool {
t, ok := target.(*APIError)
Expand Down
1 change: 1 addition & 0 deletions internal/templates/reserved.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ var ReservedIdentifiers = []string{
"Middleware",
"NewClient",
"PageIterator",
"ProblemDetails",
"ParseCallback",
"ParseWebhook",
"ResponseMeta",
Expand Down
Loading