Skip to content
Open
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
22 changes: 21 additions & 1 deletion internal/analyzer/operations.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"fmt"
"slices"
"strings"
"unicode"
"unicode/utf8"

highbase "github.com/pb33f/libopenapi/datamodel/high/base"
v3high "github.com/pb33f/libopenapi/datamodel/high/v3"
Expand Down Expand Up @@ -475,20 +477,38 @@ func (a *Analyzer) convertResponses(responses *v3high.Responses, opDef *ir.Opera
}
} else if isErrorCode(code) {
rd.IsError = true
rd.ErrorWrapper = a.errorWrapperName(rd.TypeName, hint)
opDef.ErrorResponses = append(opDef.ErrorResponses, rd)
}
}
}

// Handle the default response.
if responses.Default != nil {
rd := a.convertSingleResponse("default", responses.Default, opName+"DefaultResponse")
hint := opName + "DefaultResponse"
rd := a.convertSingleResponse("default", responses.Default, hint)
rd.IsError = true
rd.ErrorWrapper = a.errorWrapperName(rd.TypeName, hint)
opDef.Responses = append(opDef.Responses, rd)
opDef.ErrorResponses = append(opDef.ErrorResponses, rd)
}
}

// errorWrapperName returns the type name for the wrapper that carries an error
// body parsed into Detail. A body whose Go type is a map, a slice, or a builtin
// has no name an identifier can be built from, so the wrapper takes the
// operation's instead of pasting the type expression into the declaration.
func (a *Analyzer) errorWrapperName(typeName, hint string) string {
if typeName == "" {
return ""
}
named := ir.NamedType(typeName)
if r, _ := utf8.DecodeRuneInString(named); unicode.IsUpper(r) {
return named + "Response"
}
return a.namer.Unique(naming.Exported(hint) + "Error")
}

// convertSingleResponse converts one response code/definition to an ir.ResponseDef.
func (a *Analyzer) convertSingleResponse(code string, resp *v3high.Response, nameHint string) *ir.ResponseDef {
rd := &ir.ResponseDef{
Expand Down
121 changes: 121 additions & 0 deletions internal/generator/e2e_error_wrapper_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package generator

import "testing"

const unnamedErrorBodySpec = `openapi: 3.1.0
info: { title: errbody, version: "1" }
paths:
/t:
get:
operationId: getT
responses:
"200": { description: ok, content: { application/json: { schema: { type: string } } } }
"404":
description: missing
content:
application/json:
schema: { type: array, items: { type: string } }
default:
description: fallback
content:
application/json:
schema: { type: object }
/u:
get:
operationId: getU
responses:
"200": { description: ok, content: { application/json: { schema: { type: string } } } }
"422":
description: invalid
content:
application/json:
schema: { $ref: "#/components/schemas/ValidationErrors" }
"500":
description: oops
content:
text/plain:
schema: { type: string }
components:
schemas:
ValidationErrors:
type: array
items: { $ref: "#/components/schemas/ValidationError" }
ValidationError:
type: object
properties:
field: { type: string }
message: { type: string }
`

// TestE2E_UnnamedErrorBodyCompiles covers error bodies whose Go type is an
// expression rather than a name: a map, a slice, and a builtin. Pasting the type
// into the wrapper's declaration put `type map[string]anyResponse` in the output,
// which is not Go.
func TestE2E_UnnamedErrorBodyCompiles(t *testing.T) {
files, _ := generateFromSpec(t, unnamedErrorBodySpec, "errbody")

runGeneratedWireTest(t, files, "errbody", `package errbody

import (
"errors"
"testing"
)

func TestMapBodyParsesIntoTheWrapper(t *testing.T) {
err := parseGetTDefaultResponseError(&APIError{StatusCode: 500, Status: "500 Internal Server Error", Body: []byte(`+"`"+`{"reason":"upstream"}`+"`"+`)})

var wrapped *GetTDefaultResponseError
if !errors.As(err, &wrapped) {
t.Fatalf("errors.As did not match the wrapper: %T", err)
}
if wrapped.Detail["reason"] != "upstream" {
t.Errorf("Detail = %v, want the parsed body", wrapped.Detail)
}

var apiErr *APIError
if !errors.As(err, &apiErr) || apiErr.StatusCode != 500 {
t.Error("the wrapper stopped unwrapping to APIError")
}
}

func TestSliceBodyParsesIntoTheWrapper(t *testing.T) {
err := parseGetTResponse404Error(&APIError{StatusCode: 404, Status: "404 Not Found", Body: []byte(`+"`"+`["gone","really gone"]`+"`"+`)})

var wrapped *GetTResponse404Error
if !errors.As(err, &wrapped) {
t.Fatalf("errors.As did not match the wrapper: %T", err)
}
if len(wrapped.Detail) != 2 || wrapped.Detail[0] != "gone" {
t.Errorf("Detail = %v, want the parsed array", wrapped.Detail)
}
}

// A body whose Go type is a builtin has no name to build an identifier from
// either, and the wrapper it lands in has to stay exported for errors.As.
func TestBuiltinBodyWrapperIsExported(t *testing.T) {
err := parseGetUResponse500Error(&APIError{StatusCode: 500, Status: "500 Internal Server Error", Body: []byte(`+"`"+`"plain text"`+"`"+`)})

var wrapped *GetUResponse500Error
if !errors.As(err, &wrapped) {
t.Fatalf("errors.As did not match the wrapper: %T", err)
}
if wrapped.Detail != "plain text" {
t.Errorf("Detail = %q, want the parsed body", wrapped.Detail)
}
}

// A named body keeps the wrapper name it already had, so upgrading does not
// rename types callers match on.
func TestNamedBodyKeepsItsWrapperName(t *testing.T) {
err := parseValidationErrorsResponse(&APIError{StatusCode: 422, Status: "422 Unprocessable Entity", Body: []byte(`+"`"+`[{"field":"name","message":"required"}]`+"`"+`)})

var wrapped *ValidationErrorsResponse
if !errors.As(err, &wrapped) {
t.Fatalf("errors.As did not match the wrapper: %T", err)
}
if len(wrapped.Detail) != 1 || wrapped.Detail[0].Field == nil || *wrapped.Detail[0].Field != "name" {
t.Errorf("Detail = %v, want the parsed body", wrapped.Detail)
}
}
`)
}
24 changes: 16 additions & 8 deletions internal/generator/funcmap.go
Original file line number Diff line number Diff line change
Expand Up @@ -612,18 +612,26 @@ func paginationCursorField(op *ir.OperationDef) string {
}

// uniqueErrorTypes returns deduplicated error response type names from all operations.
func uniqueErrorTypes(pkg *ir.Package) []string {
func uniqueErrorTypes(pkg *ir.Package) []ErrorWrapper {
seen := map[string]bool{}
var types []string
var wrappers []ErrorWrapper
for _, op := range pkg.Operations {
for _, resp := range op.ErrorResponses {
if resp.TypeName != "" && !seen[resp.TypeName] {
seen[resp.TypeName] = true
types = append(types, resp.TypeName)
if resp.ErrorWrapper == "" || seen[resp.ErrorWrapper] {
continue
}
seen[resp.ErrorWrapper] = true
wrappers = append(wrappers, ErrorWrapper{Name: resp.ErrorWrapper, Detail: resp.TypeName})
}
}
return types
return wrappers
}

// ErrorWrapper is one generated error type: the name it declares and the type of
// the body it parses into.
type ErrorWrapper struct {
Name string
Detail string
}

// errorMessageField returns the error type's string field annotated with
Expand All @@ -645,8 +653,8 @@ func errorMessageField(pkg *ir.Package, typeName string) *ir.Field {
// errorType returns the error response type name for an operation, or "".
func errorType(op *ir.OperationDef) string {
for _, resp := range op.ErrorResponses {
if resp.TypeName != "" {
return resp.TypeName
if resp.ErrorWrapper != "" {
return resp.ErrorWrapper
}
}
return ""
Expand Down
13 changes: 7 additions & 6 deletions internal/ir/operations.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,13 @@ type RequestBodyDef struct {

// ResponseDef describes one response.
type ResponseDef struct {
StatusCode string // "200", "404", "default", etc.
Description string
ContentType string
TypeName string // Go type for the response body (empty if no body)
IsError bool // Whether this is an error response (4xx/5xx)
Headers []*ResponseHeaderDef
StatusCode string // "200", "404", "default", etc.
Description string
ContentType string
TypeName string // Go type for the response body (empty if no body)
ErrorWrapper string // Go type name of the generated wrapper carrying the parsed body
IsError bool // Whether this is an error response (4xx/5xx)
Headers []*ResponseHeaderDef
}

// ResponseHeaderDef describes a response header.
Expand Down
16 changes: 8 additions & 8 deletions internal/templates/errors.go.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -63,14 +63,14 @@ func (e *APIError) Is(target error) bool {
return e.StatusCode == t.StatusCode
}
{{ range uniqueErrorTypes . }}
// {{ . }}Response wraps an APIError with a parsed {{ . }} body.
type {{ . }}Response struct {
// {{ .Name }} wraps an APIError with a parsed {{ .Detail }} body.
type {{ .Name }} struct {
*APIError
Detail {{ . }}
Detail {{ .Detail }}
}

func (e *{{ . }}Response) Error() string {
{{- with errorMessageField $ . }}
func (e *{{ .Name }}) Error() string {
{{- with errorMessageField $ .Detail }}
{{- if .IsPointer }}
if e.Detail.{{ .Name }} != nil && *e.Detail.{{ .Name }} != "" {
return fmt.Sprintf("API error %s: %s", e.statusLabel(), *e.Detail.{{ .Name }})
Expand All @@ -84,16 +84,16 @@ func (e *{{ . }}Response) Error() string {
return e.APIError.Error()
}

func (e *{{ . }}Response) Unwrap() error {
func (e *{{ .Name }}) Unwrap() error {
return e.APIError
}

func parse{{ . }}Response(err error) error {
func parse{{ .Name }}(err error) error {
var apiErr *APIError
if !errors.As(err, &apiErr) || len(apiErr.Body) == 0 {
return err
}
resp := &{{ . }}Response{APIError: apiErr}
resp := &{{ .Name }}{APIError: apiErr}
if json.Unmarshal(apiErr.Body, &resp.Detail) == nil {
return resp
}
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 @@ -46,11 +46,11 @@ type {{ .Name }}Params struct {
{{- $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 {
return nil, {{ if $errType }}parse{{ $errType }}Response(err){{ else }}err{{ end }}
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 {
return {{ if $errType }}parse{{ $errType }}Response(err){{ else }}err{{ end }}
return {{ if $errType }}parse{{ $errType }}(err){{ else }}err{{ end }}
}
return nil
{{ end }}}
Expand Down