Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,6 @@ coverage/
.vscode/

# AI assistant data
.claude/
.claude/

references/*
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ r := spec.NewRouter(

**Tag options:** `TagSummary`, `TagDescription`, `TagExternalDocs`, `TagParent` (3.2.0), `TagKind` (3.2.0).

**Server options:** `ServerDescription`, `ServerVariables`.
**Server options:** `ServerDescription`, `ServerVariables`, `ServerName` (3.2.0).

---

Expand Down Expand Up @@ -283,6 +283,7 @@ api.Get("/users/{id}",
| --- | --- |
| `ContentType(contentType)` | Set media type; default is `application/json`. |
| `ContentDescription(description)` | Set request/response description. |
| `ContentSummary(summary)` | Set request/response summary (OpenAPI `3.2.0`). |
| `ContentDefault(isDefault...)` | Mark response as `default`. |
| `ContentEncoding(prop, enc)` | Add media type encoding metadata for a property. |
| `ContentExample(value)` | Set media type `example`. |
Expand Down Expand Up @@ -483,10 +484,13 @@ Selecting `openapi.Version320` enables the following additional features:
- Custom HTTP methods via `Add`, emitted as `additionalOperations`.
- `querystring` parameter tags.
- Root `$self` field.
- Server `name` field.
- Response `summary` field.
- Tag `parent` and `kind` fields.
- Security scheme metadata and deprecation fields.
- `components.mediaTypes`.
- Media type and encoding fields: `itemSchema`, `prefixEncoding`, `itemEncoding`.
- Discriminator `defaultMapping`.
- Example `dataValue` and `serializedValue` fields.
- XML `nodeType`.

Expand Down
56 changes: 54 additions & 2 deletions errors.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,26 @@
package spec

import "strings"
import (
"errors"
"strings"

"github.com/oaswrap/spec/internal/validate"
)

// Severity represents the severity level of a validation error.
type Severity = validate.Severity

const (
// SeverityError indicates a strict validation failure.
SeverityError = validate.SeverityError
// SeverityWarning indicates a validation warning that doesn't necessarily invalidate the document.
SeverityWarning = validate.SeverityWarning
// SeverityInfo indicates informational validation feedback.
SeverityInfo = validate.SeverityInfo
)

// ValidationError represents a validation error with an associated severity level.
type ValidationError = validate.Error

// ValidationErrors is a collection of validation errors that can be returned by the Validate method of various structs in the spec package. It implements the error interface and can be used to aggregate multiple validation errors into a single error value.
type ValidationErrors struct { //nolint:errname // ValidationErrors is a better name than ErrorsError or ValidationError here
Expand All @@ -21,6 +41,34 @@ func (e ValidationErrors) Unwrap() []error {
return e.Errors
}

// HasSeverity returns true if the collection contains at least one error with the given severity.
func (e ValidationErrors) HasSeverity(s Severity) bool {
for _, err := range e.Errors {
if err == nil {
continue
}
var valErr validate.Error
var valErrPtr *validate.Error
if errors.As(err, &valErrPtr) {
if valErrPtr.Severity == s {
return true
}
continue
}
if errors.As(err, &valErr) {
if valErr.Severity == s {
return true
}
continue
}
if s == SeverityError {
// Standard errors are treated as SeverityError
return true
}
}
return false
}

func joinErrors(errs []error) error {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 | Confidence: High

The joinErrors function now returns nil when the collection contains no error with SeverityError. Previously any non-nil validation error (including those now reclassified as Warning or Info) caused a non-nil return. This change alters the contract of all Validate() calls: adapters (e.g., adapter/muxopenapi/router.go) and the generator's MarshalYAML (via router.go) will now silently accept spec generation even when best-practice issues like missing operationId, summary, or description are present. Consumers that relied on Validate() to catch any validation issue will no longer be notified of these recommendations, potentially producing suboptimal specifications. This is a deliberate design choice to align with severity levels, but it is a breaking behavioral change for existing callers.

var filtered []error
for _, err := range errs {
Expand All @@ -31,5 +79,9 @@ func joinErrors(errs []error) error {
if len(filtered) == 0 {
return nil
}
return ValidationErrors{Errors: filtered}
vErrs := ValidationErrors{Errors: filtered}
if !vErrs.HasSeverity(SeverityError) {
return nil
}
return vErrs
}
69 changes: 69 additions & 0 deletions errors_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package spec_test

import (
"errors"
"testing"

"github.com/oaswrap/spec"
"github.com/oaswrap/spec/internal/validate"
)

func TestValidationErrorAlias(t *testing.T) {
// Simulate an error returned from internal/validate
inner := errors.New("validation failed")
err := error(validate.Error{
Err: inner,
Severity: validate.SeverityWarning,
})

// Verify we can use the public alias and constants
var valErr spec.ValidationError
if !errors.As(err, &valErr) {
t.Fatal("expected error to be spec.ValidationError")
}

if valErr.Severity != spec.SeverityWarning {
t.Errorf("expected SeverityWarning, got %v", valErr.Severity)
}

if !errors.Is(valErr, inner) {
t.Error("expected ValidationError to wrap inner error")
}
}

func TestValidationErrors_HasSeverity(t *testing.T) {
vErrs := spec.ValidationErrors{
Errors: []error{
spec.ValidationError{Err: errors.New("err"), Severity: spec.SeverityError},
spec.ValidationError{Err: errors.New("warn"), Severity: spec.SeverityWarning},
},
}

if !vErrs.HasSeverity(spec.SeverityError) {
t.Error("expected to have SeverityError")
}
if !vErrs.HasSeverity(spec.SeverityWarning) {
t.Error("expected to have SeverityWarning")
}
if vErrs.HasSeverity(spec.SeverityInfo) {
t.Error("expected NOT to have SeverityInfo")
}

vWarnsOnly := spec.ValidationErrors{
Errors: []error{
spec.ValidationError{Err: errors.New("warn"), Severity: spec.SeverityWarning},
},
}
if vWarnsOnly.HasSeverity(spec.SeverityError) {
t.Error("expected NOT to have SeverityError in warnings-only collection")
}

vPtrWarnsOnly := spec.ValidationErrors{
Errors: []error{
&validate.Error{Err: errors.New("warn"), Severity: spec.SeverityWarning},
},
}
if vPtrWarnsOnly.HasSeverity(spec.SeverityError) {
t.Error("expected NOT to have SeverityError in pointer warnings-only collection")
}
}
8 changes: 4 additions & 4 deletions internal/builder/builder.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
package builder

import (
"fmt"
"regexp"
"strings"

"github.com/oaswrap/spec/internal/reflect"
"github.com/oaswrap/spec/internal/validate"
"github.com/oaswrap/spec/openapi"
"github.com/oaswrap/spec/option"
)
Expand All @@ -32,7 +32,7 @@ func (b *Builder) AddOperation(method, path string, opts []option.OperationOptio

func (b *Builder) AddWebhookOperation(method, name string, opts []option.OperationOption) error {
if reflect.IsOpenAPI30(b.Config.OpenAPIVersion) {
return fmt.Errorf("webhooks require OpenAPI 3.1.x or 3.2.0")
return validate.Errorf("webhooks require OpenAPI 3.1.x or 3.2.0")
}
if b.Doc.Webhooks == nil {
b.Doc.Webhooks = map[string]*openapi.PathItem{}
Expand All @@ -55,7 +55,7 @@ func (b *Builder) AddOperationTo(

method = strings.ToUpper(method)
if method == "QUERY" && b.Config.OpenAPIVersion != openapi.Version320 {
return fmt.Errorf("method QUERY requires OpenAPI 3.2.0")
return validate.Errorf("method QUERY requires OpenAPI 3.2.0")
}

op := &openapi.Operation{Responses: map[string]*openapi.Response{}}
Expand All @@ -77,7 +77,7 @@ func (b *Builder) AddOperationTo(
}
for _, resp := range MergeResponses(cfg.Responses) {
if err := b.AddResponse(op, resp); err != nil {
return fmt.Errorf("%s %s response: %w", method, target, err)
return validate.Errorf("%s %s response: %w", method, target, err)
}
}
for _, customize := range cfg.Customizers {
Expand Down
7 changes: 5 additions & 2 deletions internal/builder/operation.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
package builder

import (
"fmt"
"strconv"

"github.com/oaswrap/spec/internal/reflect"
"github.com/oaswrap/spec/internal/validate"
"github.com/oaswrap/spec/openapi"
)

Expand Down Expand Up @@ -51,14 +51,17 @@ func (b *Builder) AddResponse(op *openapi.Operation, cu *openapi.ContentUnit) er
if cu.IsDefault {
key = "default"
} else if cu.HTTPStatus == 0 {
return fmt.Errorf("HTTP status is required unless ContentDefault is set")
return validate.Errorf("HTTP status is required unless ContentDefault is set")
}

response := op.Responses[key]
if response == nil {
response = &openapi.Response{Description: ResponseDescription(cu)}
op.Responses[key] = response
}
if cu.Summary != "" && b.Config.OpenAPIVersion == openapi.Version320 {
response.Summary = cu.Summary
}
if response.Content == nil {
response.Content = map[string]openapi.MediaType{}
}
Expand Down
23 changes: 12 additions & 11 deletions internal/builder/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,65 +4,66 @@ import (
"fmt"
"net/http"

"github.com/oaswrap/spec/internal/validate"
"github.com/oaswrap/spec/openapi"
)

func SetOperation(item *openapi.PathItem, method string, op *openapi.Operation, version string) error {
switch method {
case http.MethodGet:
if item.Get != nil {
return fmt.Errorf("duplicate GET operation")
return validate.Errorf("duplicate GET operation")
}
item.Get = op
case http.MethodPut:
if item.Put != nil {
return fmt.Errorf("duplicate PUT operation")
return validate.Errorf("duplicate PUT operation")
}
item.Put = op
case http.MethodPost:
if item.Post != nil {
return fmt.Errorf("duplicate POST operation")
return validate.Errorf("duplicate POST operation")
}
item.Post = op
case http.MethodDelete:
if item.Delete != nil {
return fmt.Errorf("duplicate DELETE operation")
return validate.Errorf("duplicate DELETE operation")
}
item.Delete = op
case http.MethodOptions:
if item.Options != nil {
return fmt.Errorf("duplicate OPTIONS operation")
return validate.Errorf("duplicate OPTIONS operation")
}
item.Options = op
case http.MethodHead:
if item.Head != nil {
return fmt.Errorf("duplicate HEAD operation")
return validate.Errorf("duplicate HEAD operation")
}
item.Head = op
case http.MethodPatch:
if item.Patch != nil {
return fmt.Errorf("duplicate PATCH operation")
return validate.Errorf("duplicate PATCH operation")
}
item.Patch = op
case http.MethodTrace:
if item.Trace != nil {
return fmt.Errorf("duplicate TRACE operation")
return validate.Errorf("duplicate TRACE operation")
}
item.Trace = op
case "QUERY":
if item.Query != nil {
return fmt.Errorf("duplicate QUERY operation")
return validate.Errorf("duplicate QUERY operation")
}
item.Query = op
default:
if version != openapi.Version320 {
return fmt.Errorf("unsupported HTTP method %q", method)
return validate.Errorf("unsupported HTTP method %q", method)
}
if item.AdditionalOperations == nil {
item.AdditionalOperations = map[string]*openapi.Operation{}
}
if _, exists := item.AdditionalOperations[method]; exists {
return fmt.Errorf("duplicate %s operation", method)
return validate.Errorf("duplicate %s operation", method)
}
item.AdditionalOperations[method] = op
}
Expand Down
28 changes: 18 additions & 10 deletions internal/reflect/tags.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,20 +137,28 @@ func (r *Reflector) ApplyXMLTags(schema *openapi.Schema, tag reflect.StructTag)
schema.XML.Name = xmlName
schema.XML.Namespace = xmlNamespace
schema.XML.Prefix = xmlPrefix
if xmlAttribute != "" && (r.Config.OpenAPIVersion != openapi.Version320 || xmlNodeType == "") {
schema.XML.Attribute = BoolTag(xmlAttribute)
}
if xmlWrapped != "" && (r.Config.OpenAPIVersion != openapi.Version320 || xmlNodeType == "") {
schema.XML.Wrapped = BoolTag(xmlWrapped)
}
if xmlNodeType != "" && r.Config.OpenAPIVersion == openapi.Version320 {
if schema.XML.Extra == nil {
schema.XML.Extra = map[string]any{}

if r.Config.OpenAPIVersion == openapi.Version320 {
switch {
case xmlNodeType != "":
schema.XML.NodeType = xmlNodeType
case xmlAttribute != "" && BoolTag(xmlAttribute):
schema.XML.NodeType = "attribute"
case xmlWrapped != "" && BoolTag(xmlWrapped):
schema.XML.NodeType = "element"
}
} else {
if xmlAttribute != "" {
schema.XML.Attribute = BoolTag(xmlAttribute)
}
if xmlWrapped != "" {
schema.XML.Wrapped = BoolTag(xmlWrapped)
}
schema.XML.Extra["nodeType"] = xmlNodeType
}

if schema.XML.Name == "" && schema.XML.Namespace == "" && schema.XML.Prefix == "" && !schema.XML.Attribute &&
!schema.XML.Wrapped &&
schema.XML.NodeType == "" &&
len(schema.XML.Extra) == 0 {
schema.XML = nil
}
Expand Down
4 changes: 2 additions & 2 deletions internal/reflect/tags_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import (
)

type XMLType struct {
Value string `xmlName:"val" xmlNamespace:"ns" xmlPrefix:"p" xmlAttribute:"true"`
Value string `xmlName:"val" xmlNamespace:"https://example.com/ns" xmlPrefix:"p" xmlAttribute:"true"`
}

func TestTags_OpenAPI304(t *testing.T) {
Expand Down Expand Up @@ -75,7 +75,7 @@ func TestTags_XML(t *testing.T) {
doc := r.Document()
schema := doc.Components.Schemas["XMLNode"].Properties["attr"]
if assert.NotNil(t, schema.XML) {
assert.Equal(t, "attribute", schema.XML.Extra["nodeType"])
assert.Equal(t, "attribute", schema.XML.NodeType)
}
})
}
Expand Down
Loading
Loading