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
66 changes: 66 additions & 0 deletions .github/workflows/example-regen.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1228,6 +1228,72 @@ jobs:
[[ $fail -eq 0 ]] || exit 1
echo "==> 428 rules fire on declarations and prose, and stay silent on clean writes and byte sizes."

# A rule name written as `error.code` (authoring#163). `Error.code` is the
# HTTP status since compatibility.md §54, and prose survives a schema
# migration. Asserted in both directions, plus the gate: the same prose
# under a STRING `code` is the pre-§54 shape and must stay silent.
- name: Rule names belong in error.reason, not error.code (regression)
run: |
# See the note on the earlier fixture steps: `bash -e` would abort on
# the fixtures' expected error-severity findings.
set +e
set -uo pipefail
REPORT="${RUNNER_TEMP}/error-reason-prose.txt"
GATE="${RUNNER_TEMP}/error-reason-prose-string-code.txt"

spectral lint \
--ruleset schemas/spectral/specfuse-openapi.yaml \
schemas/spectral/fixtures/error-reason-prose.yaml \
>"$REPORT" 2>&1
STATUS=$?
cat "$REPORT"
if [[ $STATUS -ge 2 ]]; then
echo "::error::Spectral crashed on the error-reason-prose fixture (exit ${STATUS})." >&2
exit 1
fi

spectral lint \
--ruleset schemas/spectral/specfuse-openapi.yaml \
schemas/spectral/fixtures/error-reason-prose-string-code.yaml \
>"$GATE" 2>&1
STATUS=$?
if [[ $STATUS -ge 2 ]]; then
cat "$GATE"
echo "::error::Spectral crashed on the error-reason-prose-string-code fixture (exit ${STATUS})." >&2
exit 1
fi

fail=0
RULE=specfuse-error-rule-name-in-code
fired() { grep "$RULE" "$REPORT" | grep -q "$1"; }

for at in \
"paths./assign.patch.description" \
"paths./compare.get.summary" \
"paths./inResponse.delete.responses\[409\].description" \
"paths./jsonish.post.x-notes.description"; do
if ! fired "$at"; then
echo "::error::${RULE} did not fire at ${at}." >&2
fail=1
fi
done

for at in "paths./clean" "paths./status" "paths./otherCodes"; do
if fired "$at"; then
echo "::error::${RULE} fired under ${at}, which names no rule in code." >&2
fail=1
fi
done

if grep -q "$RULE" "$GATE"; then
grep "$RULE" "$GATE"
echo "::error::${RULE} fired on a STRING Error.code. The integer gate is gone." >&2
fail=1
fi

[[ $fail -eq 0 ]] || exit 1
echo "==> Rule names in error.code warn under an integer code; reason, status codes and string-code projects stay silent."

# Conditional GET. Each violation mirrors a generator 0.12.0 ERROR
# (ConditionalReadDeclarationValidationRule), except /stale412, which is
# kit-only. The silent cases matter as much as the firings: the jar reads
Expand Down
11 changes: 9 additions & 2 deletions compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -1388,7 +1388,7 @@ The C# server prefixes every route with the path part of the server URL; the cli
- The §1.1 table gains `DELETE_SOFT_CLASSIFIED_NO_RETENTION`.

**Generator follow-up, not filed:** a structured `x-compliance.retention` (`period`, `from` as a date property, `action: delete | clear`, allowed per property) that the generator could turn into a sweep. Then `DELETE_SOFT_CLASSIFIED_NO_RETENTION` could accept either retention. The consumer has not asked for it yet. File when a second project needs it.
2. **`Error.code` vs the HTTP status.** A consumer's `Error.code` was `type: integer` (the status), while its copy of `API_Handbook.md` §2.1 called `code` a snake_case rule name. As a result, 17 operation descriptions named codes the wire cannot carry. The consumer added a string `reason` field. The kit's own `Error` types `code` as a snake_case string and carries the status in a separate integer `status`, and §2.1 matches. **Corrected at the `0.16.0` pin bump (§52):** the conclusion drawn here, that the contradiction was the consumer's alone, is wrong. The generator's C# `domainExceptions` set `Error.Code` to an integer, so the consumer's integer `code` was the generator's shape, and the kit's `Error` does not compile with that artifact. The proposed generator rule (warn on `error.code = <name>` prose when `code` is an integer) is still not forwarded. **Resolved in §54:** the kit adopts the consumer's shape.
2. **`Error.code` vs the HTTP status.** A consumer's `Error.code` was `type: integer` (the status), while its copy of `API_Handbook.md` §2.1 called `code` a snake_case rule name. As a result, 17 operation descriptions named codes the wire cannot carry. The consumer added a string `reason` field. The kit's own `Error` types `code` as a snake_case string and carries the status in a separate integer `status`, and §2.1 matches. **Corrected at the `0.16.0` pin bump (§52):** the conclusion drawn here, that the contradiction was the consumer's alone, is wrong. The generator's C# `domainExceptions` set `Error.Code` to an integer, so the consumer's integer `code` was the generator's shape, and the kit's `Error` does not compile with that artifact. The proposed rule (warn on `error.code = <name>` prose when `code` is an integer) was never forwarded to the generator; it shipped as the kit Spectral rule `specfuse-error-rule-name-in-code` (§54, authoring#163). **Resolved in §54:** the kit adopts the consumer's shape.

### 52. Generator `0.16.0` pin bump (kit `0.25.0`)

Expand Down Expand Up @@ -1497,14 +1497,21 @@ The full Domain set does not build from `hello-orders` alone for unrelated reaso

**Migration for a project on the old shape** (any project scaffolded by kit `≤ 0.26.0`). Schema and docs only: the generated C# exceptions never compiled against the old shape, so no generated code depends on it.

1. Move each rule name from `code` to `reason`: in `Error` examples, in response `description`s that list conflict codes, and in prose (`error.code = x` → `error.reason = x`).
1. Move each rule name from `code` to `reason`: in `Error` examples, in response `description`s that list conflict codes, and in prose (`error.code = x` → `error.reason = x`). Once step 2 types `code` as an integer, `specfuse-error-rule-name-in-code` lists the prose still left behind.
2. Retype `code` as `type: integer`, the HTTP status, and delete `status`.
3. Drop `format: uuid` from `id`.
4. Add `type` and `ErrorType` if the project registers, or will register, `domainExceptions`.
5. Clients: branch on `reason` where they branched on `code`, and read the status from `code` where they read `status`. This is a breaking wire change for any client already deployed against the old shape.

**Generator follow-up (`#2671`):** `(string reason, string message)` constructors on the six exceptions a rule can refuse with, and `Id` following the schema's `format`. Until then, `reason` is set by building the `Error` and passing it to the `(Error error)` constructor.

**Prose lint (authoring#163):** `specfuse-error-rule-name-in-code` (`warn`, `functions/openapiErrorReasonProse.js`) flags a `description` or `summary` anywhere in the document that assigns or compares a snake_case rule name to `code` (`error.code = template_in_use`, `errors[0].code == 'x_y'`, `"code": "x_y"`). Two choices keep it quiet on correct prose:

- **It runs only when `components.schemas.Error.properties.code` is `type: integer`.** A project still on the old shape is described correctly by that prose, so it gets the migration above, not a list of warnings.
- **The value must contain an underscore.** That separates a rule name from `code: integer` or `code = 409`. A single-word rule name is missed. `\b` keeps `status_code` out.

Response examples need no new rule: `spectral:oas` example validation already rejects a string `code`. Fixtures `error-reason-prose.yaml` (4 firings, 3 silences) and `error-reason-prose-string-code.yaml` (the gate) are asserted in `example-regen.yml`. `hello-orders` and the scaffold lint with no new findings.

## Outstanding kit-side work

These are gaps in the kit itself, separate from the generator-side follow-ups above. Each represents an enforcement promise the handbooks make that has no implementation today.
Expand Down
2 changes: 1 addition & 1 deletion handbooks/API_Handbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -1133,7 +1133,7 @@ Content-Type: application/json

**Required fields**:
- `code`: the HTTP status, an integer (`409`).
- `reason`: the snake_case name of the rule that failed (`template_in_use`). Clients branch on it. A rule name never goes in `code`: prose, examples and `409` descriptions say `error.reason = template_in_use`, never `error.code = template_in_use`.
- `reason`: the snake_case name of the rule that failed (`template_in_use`). Clients branch on it. A rule name never goes in `code`: prose, examples and `409` descriptions say `error.reason = template_in_use`, never `error.code = template_in_use`. Spectral's `specfuse-error-rule-name-in-code` warns on the second form.
- `type`: the broad category, from the `ErrorType` enum. The generated C# `ConflictException` sets `resourceConflict`.
- `message`: Human-readable explanation
- `retryable`: Usually `false` for business rule violations
Expand Down
86 changes: 86 additions & 0 deletions schemas/spectral/fixtures/error-reason-prose-string-code.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# =============================================================================
# Regression fixture (gate): the same prose under a STRING `code`
# =============================================================================
# Companion to `error-reason-prose.yaml`. Same paths, but `Error.code` is the
# pre-§54 string rule name, so `error.code = template_in_use` is correct here
# and `specfuse-error-rule-name-in-code` must not fire anywhere in this file.
# A project that has not migrated yet gets §54's migration note, not a wall of
# warnings. Asserted by .github/workflows/example-regen.yml.
#
# Not a Specfuse-conformant example — do not copy.
# =============================================================================
openapi: 3.0.3
info:
title: error-reason-prose (string code) fixture
version: "1.0.0"
components:
schemas:
Error:
type: object
required: [code, message]
properties:
code: { type: string }
reason: { type: string }
message: { type: string }
paths:

# SILENT here (fires in error-reason-prose.yaml): assignment in an operation description.
/assign:
patch:
summary: Update the template
description: Returns 409 with error.code = template_in_use while a menu uses it.
responses:
'200': { description: OK }

# SILENT here (fires in error-reason-prose.yaml): comparison in an operation summary.
/compare:
get:
summary: "List items; clients check errors[0].code == 'page_size_over_limit'"
responses:
'200': { description: OK }

# SILENT here (fires in error-reason-prose.yaml): backticked, in a nested response description.
/inResponse:
delete:
summary: Delete the booking
responses:
'204': { description: No Content }
'409':
description: 'The slot is taken (`code`: `slot_already_booked`).'

# SILENT here (fires in error-reason-prose.yaml): JSON-ish, in an `x-*` block's description.
/jsonish:
post:
summary: Archive the thing
x-notes:
description: 'Fails with {"code": "delete_not_allowed"} once archived.'
responses:
'200': { description: OK }

# SILENT: the rule name is in `reason`.
/clean:
patch:
summary: Update the template
description: Returns 409 with error.reason = template_in_use while a menu uses it.
responses:
'200': { description: OK }

# SILENT: `code` named with the status, and described as a field.
/status:
patch:
summary: Update with a precondition
description: >-
A conflict returns code = 409 and a stale ETag code: 412. The field is
declared as `code: integer`.
responses:
'200': { description: OK }

# SILENT: other words ending in "code".
/otherCodes:
get:
summary: Look up an address
description: >-
Filters on status_code = not_found and reason_code: out_of_area; the
postal code is free text.
responses:
'200': { description: OK }
102 changes: 102 additions & 0 deletions schemas/spectral/fixtures/error-reason-prose.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# =============================================================================
# Regression fixture: a rule name written as `error.code` (authoring#163)
# =============================================================================
# `Error.code` is the integer HTTP status and the rule name is `reason`
# (API Handbook §11, compatibility.md §54). Pins
# `specfuse-error-rule-name-in-code` in BOTH directions. Its companion,
# `error-reason-prose-string-code.yaml`, carries the same prose under a
# string `code` and must stay silent: the gate.
#
# Expected findings (asserted by .github/workflows/example-regen.yml):
#
# /assign patch operation description error.code = template_in_use
# /compare get operation summary errors[0].code == 'page_size_over_limit'
# /inResponse delete response description `code`: `slot_already_booked`
# /jsonish post x-* block description "code": "delete_not_allowed"
#
# /clean patch SILENT <- error.reason = template_in_use
# /status patch SILENT <- code = 409, `code` is an integer
# /otherCodes get SILENT <- status_code / reason_code / postal code
#
# The silent cases are the load-bearing ones. A value without an underscore
# (`code: integer`, `code = 409`) is how prose describes the field itself, and
# `\b` keeps `status_code = foo_bar` out. A rule that fires on those is a rule
# a consumer switches off.
#
# Not a Specfuse-conformant example — do not copy.
# =============================================================================
openapi: 3.0.3
info:
title: error-reason-prose fixture
version: "1.0.0"
components:
schemas:
Error:
type: object
required: [code, message]
properties:
code: { type: integer }
reason: { type: string }
message: { type: string }
paths:

# VIOLATION: assignment in an operation description.
/assign:
patch:
summary: Update the template
description: Returns 409 with error.code = template_in_use while a menu uses it.
responses:
'200': { description: OK }

# VIOLATION: comparison in an operation summary.
/compare:
get:
summary: "List items; clients check errors[0].code == 'page_size_over_limit'"
responses:
'200': { description: OK }

# VIOLATION: backticked, in a nested response description.
/inResponse:
delete:
summary: Delete the booking
responses:
'204': { description: No Content }
'409':
description: 'The slot is taken (`code`: `slot_already_booked`).'

# VIOLATION: JSON-ish, in an `x-*` block's description.
/jsonish:
post:
summary: Archive the thing
x-notes:
description: 'Fails with {"code": "delete_not_allowed"} once archived.'
responses:
'200': { description: OK }

# SILENT: the rule name is in `reason`.
/clean:
patch:
summary: Update the template
description: Returns 409 with error.reason = template_in_use while a menu uses it.
responses:
'200': { description: OK }

# SILENT: `code` named with the status, and described as a field.
/status:
patch:
summary: Update with a precondition
description: >-
A conflict returns code = 409 and a stale ETag code: 412. The field is
declared as `code: integer`.
responses:
'200': { description: OK }

# SILENT: other words ending in "code".
/otherCodes:
get:
summary: Look up an address
description: >-
Filters on status_code = not_found and reason_code: out_of_area; the
postal code is free text.
responses:
'200': { description: OK }
81 changes: 81 additions & 0 deletions schemas/spectral/functions/openapiErrorReasonProse.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"use strict";

// specfuse-error-rule-name-in-code — prose names a rule in `code`, which
// carries the HTTP status.
//
// Since authoring#159 (compatibility.md §54) the kit's `Error` carries the
// HTTP status in an integer `code` and the snake_case rule name in `reason`.
// A description that says `error.code = template_in_use` passes `validate` and
// `generate` and still sends every client that reads it to branch on a field
// that never holds that value. Response examples are covered elsewhere:
// `spectral:oas` example validation rejects a string `code`. Nothing checks prose.
//
// Gate: the rule speaks only when `components.schemas.Error.properties.code`
// is typed `integer`. A project still on the old shape (string `code` = rule
// name) is described correctly by that prose, and flagging it would
// push the migration through a lint warning instead of §54's note.
//
// Targets: every `description` and `summary` string in the document, at any
// depth. Operations, responses, parameters, schemas and `x-*` blocks all carry
// prose a client reads.
//
// Matches (the key may be written `code`, `error.code`, `errors[0].code`,
// quoted or in backticks, so the match starts at `code` itself):
// code = foo_bar code == 'foo_bar' code: foo_bar "code": "foo_bar"
// The value must contain an underscore. That is what separates a rule name
// (`template_in_use`, `slot_already_booked`) from prose such as `code: integer`
// or `code = status`, and a rule that fires on those is a rule a consumer
// switches off. A single-word rule name is not caught; the trade is deliberate.
// `\b` before `code` keeps `status_code` and `reason_code` out.
//
// Given: $ (resolved, so a multi-file bundle's `Error` and descriptions are
// visible; Spectral maps each finding back to its source file and drops the
// duplicates a shared `$ref` produces).

const RULE_IN_CODE =
/\bcode[`"']?\s*(?:===?|:|=)\s*[`"']?([a-z][a-z0-9]*_[a-z0-9_]*)\b/g;

function isObj(v) {
return v !== null && typeof v === "object" && !Array.isArray(v);
}

function codeIsInteger(doc) {
const error =
isObj(doc.components) && isObj(doc.components.schemas) ? doc.components.schemas.Error : undefined;
if (!isObj(error) || !isObj(error.properties)) return false;
const code = error.properties.code;
if (!isObj(code)) return false;
const t = code.type;
return t === "integer" || (Array.isArray(t) && t.includes("integer"));
}

function walk(node, path, out, seen) {
if (node === null || typeof node !== "object" || seen.has(node)) return;
seen.add(node);
const entries = Array.isArray(node) ? node.map((v, i) => [i, v]) : Object.entries(node);
for (const [key, value] of entries) {
if ((key === "description" || key === "summary") && typeof value === "string") {
const names = [];
for (const m of value.matchAll(RULE_IN_CODE)) names.push(m[1]);
if (names.length > 0) out.push({ path: path.concat([key]), names });
} else {
walk(value, path.concat([key]), out, seen);
}
}
seen.delete(node);
}

module.exports = function openapiErrorReasonProse(targetVal) {
if (!isObj(targetVal) || !codeIsInteger(targetVal)) return;
const found = [];
walk(targetVal, [], found, new Set());
return found.map(({ path, names }) => {
const first = names[0];
return {
message:
`Prose names the rule ${names.map((n) => `'${n}'`).join(", ")} in \`code\`, but Error.code is the HTTP status. ` +
`Name the rule in \`reason\` (error.reason = ${first}). API Handbook §11.`,
path,
};
});
};
Loading
Loading