Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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 validation/audit.go
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,7 @@ func auditAPISpec(apiYmlPath, constructDir string, opts AuditOptions,
checkRule17, checkRule19, checkRule21, checkRule23,
checkRule24, checkRule25, checkRule26, checkRule27,
checkRule28, checkRule30, checkRule31, checkRule35,
checkRule37,
checkRule37, checkRule47,
}

for _, check := range ruleChecks {
Expand Down
48 changes: 48 additions & 0 deletions validation/rules_design.go
Original file line number Diff line number Diff line change
Expand Up @@ -646,3 +646,51 @@ func collectParamNames(item *openapi3.PathItem, op *openapi3.Operation) map[stri
}
return names
}

// --- Rule 47: 2xx responses (except 204) must have content and schema ---
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

Rule 47 should also exclude the 205 Reset Content status code. According to RFC 9110, a 205 response MUST NOT include a content part, similar to 204. I've updated the comment to reflect this.

Suggested change
// --- Rule 47: 2xx responses (except 204) must have content and schema ---
// --- Rule 47: 2xx responses (except 204 and 205) must have content and schema ---


func checkRule47(filePath string, doc *openapi3.T, opts AuditOptions) []Violation {
if doc == nil || doc.Paths == nil {
return nil
}
var out []Violation
for path, item := range doc.Paths.Map() {
for _, method := range httpMethods {
op := getOperation(item, method)
if op == nil || op.Responses == nil {
continue
}
label := fmt.Sprintf("%s %s", strings.ToUpper(method), path)
for code, respRef := range op.Responses.Map() {
// Only interested in 2xx responses, excluding 204 (No Content).
if !strings.HasPrefix(code, "2") || code == "204" {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

The 205 Reset Content status code is defined by RFC 9110 as a response that must not contain a body. It should be excluded from this validation rule to prevent false positives when auditing valid API specifications.

Suggested change
if !strings.HasPrefix(code, "2") || code == "204" {
if !strings.HasPrefix(code, "2") || code == "204" || code == "205" {

continue
}
if respRef == nil || respRef.Value == nil {
continue
}
resp := respRef.Value
if len(resp.Content) == 0 {
out = append(out, Violation{
File: filePath,
Message: fmt.Sprintf("%s — response %s is missing a `content` block. All 2xx responses (except 204) must declare a content-type.", label, code),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

Updating the violation message to include 205 in the list of excluded status codes for clarity.

Suggested change
Message: fmt.Sprintf("%s — response %s is missing a `content` block. All 2xx responses (except 204) must declare a content-type.", label, code),
Message: fmt.Sprintf("%s — response %s is missing a content block. All 2xx responses (except 204 and 205) must declare a content-type.", label, code),

Severity: classifyDesignIssue(opts),
RuleNumber: 47,
})
continue
}
for contentType, media := range resp.Content {
if media.Schema == nil {
out = append(out, Violation{
File: filePath,
Message: fmt.Sprintf("%s — response %s (%s) is missing a `schema`. Every response content block must define a schema to ensure valid client generation.", label, code, contentType),
Severity: classifyDesignIssue(opts),
RuleNumber: 47,
})
}
}
}
}
}
return out
}
97 changes: 97 additions & 0 deletions validation/rules_design_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -775,3 +775,100 @@ func TestCheckRule28_DeleteCollectionPathIgnored(t *testing.T) {
t.Errorf("expected 0 violations for collection DELETE, got %d", len(vs))
}
}
// ---------------------------------------------------------------------------
// Rule 47: 2xx responses (except 204) must have content and schema
// ---------------------------------------------------------------------------

func TestCheckRule47_MissingContent(t *testing.T) {
doc := &openapi3.T{
OpenAPI: "3.0.0",
Info: &openapi3.Info{Title: "Test", Version: "v1"},
Paths: openapi3.NewPaths(),
}
responses := openapi3.NewResponses()
desc := "Success"
responses.Set("200", &openapi3.ResponseRef{Value: &openapi3.Response{Description: &desc}})
doc.Paths.Set("/api/test", &openapi3.PathItem{
Get: &openapi3.Operation{Responses: responses},
})

vs := checkRule47("api.yml", doc, AuditOptions{})
if len(vs) != 1 {
t.Fatalf("expected 1 violation for missing content on 200, got %d", len(vs))
}
if !strings.Contains(vs[0].Message, "missing a `content` block") {
t.Errorf("expected message about missing content, got %q", vs[0].Message)
}
}

func TestCheckRule47_MissingSchema(t *testing.T) {
doc := &openapi3.T{
OpenAPI: "3.0.0",
Info: &openapi3.Info{Title: "Test", Version: "v1"},
Paths: openapi3.NewPaths(),
}
responses := openapi3.NewResponses()
desc := "Success"
responses.Set("201", &openapi3.ResponseRef{Value: &openapi3.Response{
Description: &desc,
Content: openapi3.Content{
"application/json": &openapi3.MediaType{}, // Missing Schema
},
}})
doc.Paths.Set("/api/test", &openapi3.PathItem{
Post: &openapi3.Operation{Responses: responses},
})

vs := checkRule47("api.yml", doc, AuditOptions{})
if len(vs) != 1 {
t.Fatalf("expected 1 violation for missing schema, got %d", len(vs))
}
if !strings.Contains(vs[0].Message, "missing a `schema`") {
t.Errorf("expected message about missing schema, got %q", vs[0].Message)
}
}

func TestCheckRule47_Valid204(t *testing.T) {
doc := &openapi3.T{
OpenAPI: "3.0.0",
Info: &openapi3.Info{Title: "Test", Version: "v1"},
Paths: openapi3.NewPaths(),
}
responses := openapi3.NewResponses()
desc := "No Content"
responses.Set("204", &openapi3.ResponseRef{Value: &openapi3.Response{Description: &desc}})
doc.Paths.Set("/api/test", &openapi3.PathItem{
Delete: &openapi3.Operation{Responses: responses},
})

vs := checkRule47("api.yml", doc, AuditOptions{})
if len(vs) != 0 {
t.Errorf("expected 0 violations for 204 response without content, got %d", len(vs))
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

Adding a test case for the 205 Reset Content status code to ensure it is correctly handled as an exception to Rule 47.

}

func TestCheckRule47_Valid205(t *testing.T) {
	doc := &openapi3.T{
		OpenAPI: "3.0.0",
		Info:    &openapi3.Info{Title: "Test", Version: "v1"},
		Paths:   openapi3.NewPaths(),
	}
	responses := openapi3.NewResponses()
	desc := "Reset Content"
	responses.Set("205", &openapi3.ResponseRef{Value: &openapi3.Response{Description: &desc}})
	doc.Paths.Set("/api/test", &openapi3.PathItem{
		Post: &openapi3.Operation{Responses: responses},
	})

	vs := checkRule47("api.yml", doc, AuditOptions{})
	if len(vs) != 0 {
		t.Errorf("expected 0 violations for 205 response without content, got %d", len(vs))
	}
}


func TestCheckRule47_Valid200(t *testing.T) {
doc := &openapi3.T{
OpenAPI: "3.0.0",
Info: &openapi3.Info{Title: "Test", Version: "v1"},
Paths: openapi3.NewPaths(),
}
responses := openapi3.NewResponses()
desc := "Success"
responses.Set("200", &openapi3.ResponseRef{Value: &openapi3.Response{
Description: &desc,
Content: openapi3.Content{
"application/json": &openapi3.MediaType{
Schema: &openapi3.SchemaRef{Value: openapi3.NewObjectSchema()},
},
},
}})
doc.Paths.Set("/api/test", &openapi3.PathItem{
Get: &openapi3.Operation{Responses: responses},
})

vs := checkRule47("api.yml", doc, AuditOptions{})
if len(vs) != 0 {
t.Errorf("expected 0 violations for valid 200 response, got %d", len(vs))
}
}