From e6dacd5e4a9300ede237090759155f951639f701 Mon Sep 17 00:00:00 2001 From: Michael McQuade Date: Fri, 2 Oct 2026 21:55:23 -0500 Subject: [PATCH] feat(generator): RFC 9457 problem details are read as such, so errors show their detail instead of a JSON body --- README.md | 23 ++- .../generator/e2e_problem_details_test.go | 165 ++++++++++++++++++ internal/templates/client.go.tmpl | 2 +- internal/templates/errors.go.tmpl | 49 +++++- internal/templates/reserved.go | 1 + 5 files changed, 234 insertions(+), 6 deletions(-) create mode 100644 internal/generator/e2e_problem_details_test.go diff --git a/README.md b/README.md index 5ea9c81..e5520a1 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/internal/generator/e2e_problem_details_test.go b/internal/generator/e2e_problem_details_test.go new file mode 100644 index 0000000..c4b9158 --- /dev/null +++ b/internal/generator/e2e_problem_details_test.go @@ -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) +} diff --git a/internal/templates/client.go.tmpl b/internal/templates/client.go.tmpl index 67342cd..879663b 100644 --- a/internal/templates/client.go.tmpl +++ b/internal/templates/client.go.tmpl @@ -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 diff --git a/internal/templates/errors.go.tmpl b/internal/templates/errors.go.tmpl index 8f91de1..d154744 100644 --- a/internal/templates/errors.go.tmpl +++ b/internal/templates/errors.go.tmpl @@ -3,11 +3,12 @@ package {{ .Name }} import ( -{{- if uniqueErrorTypes . }} "encoding/json" +{{- if uniqueErrorTypes . }} "errors" {{- end }} "fmt" + "mime" "net/http" ) @@ -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) } @@ -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) diff --git a/internal/templates/reserved.go b/internal/templates/reserved.go index d175389..1b3bbf6 100644 --- a/internal/templates/reserved.go +++ b/internal/templates/reserved.go @@ -32,6 +32,7 @@ var ReservedIdentifiers = []string{ "Middleware", "NewClient", "PageIterator", + "ProblemDetails", "ParseCallback", "ParseWebhook", "ResponseMeta",