backend-skeleton 1.2.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +100 -1
- package/bin/bskel.mjs +687 -6
- package/contracts/csv.mjs +100 -0
- package/handles/providers/java-spring/rules.mjs +143 -0
- package/handles/providers/java-spring/templates/EnforceRules.java.tmpl +44 -0
- package/handles/providers/java-spring/templates/RuleCheck.java.tmpl +215 -0
- package/handles/providers/java-spring/templates/RuleEnforcementAspect.java.tmpl +132 -0
- package/handles/providers/java-spring/templates/RuleSetLoader.java.tmpl +138 -0
- package/handles/providers/python-fastapi/rules.mjs +133 -0
- package/handles/providers/python-fastapi/templates/enforce_rules.py.tmpl +128 -0
- package/handles/providers/python-fastapi/templates/rule_check.py.tmpl +178 -0
- package/handles/providers/python-fastapi/templates/rule_set.py.tmpl +59 -0
- package/handles/providers/typescript-express/rules.mjs +129 -0
- package/handles/providers/typescript-express/templates/enforceRules.ts.tmpl +93 -0
- package/handles/providers/typescript-express/templates/ruleCheck.ts.tmpl +209 -0
- package/handles/providers/typescript-express/templates/ruleSet.ts.tmpl +61 -0
- package/lib/cli.mjs +99 -1
- package/lib/doctor.mjs +11 -10
- package/lib/gate-definitions.mjs +27 -1
- package/lib/workflow.mjs +15 -0
- package/new/index.mjs +20 -0
- package/package.json +6 -2
- package/patterns/schema.sql +18 -0
- package/patterns/store.mjs +122 -0
- package/rules/compile.mjs +433 -0
- package/rules/derived.mjs +87 -0
- package/rules/diagnostics.mjs +147 -0
- package/rules/store.mjs +141 -0
- package/rules/vocabulary.mjs +172 -0
- package/scanners/adapters/_express-shared.mjs +7 -9
- package/scanners/adapters/java-spring.mjs +7 -9
- package/scanners/adapters/python-fastapi.mjs +7 -9
- package/scanners/db/erd.mjs +0 -0
- package/scanners/index.mjs +70 -17
- package/scanners/render.mjs +12 -3
- package/scanners/text-util.mjs +22 -0
- package/schemas/feature-rules.schema.json +139 -0
- package/schemas/pattern-record.schema.json +19 -0
- package/schemas/scan-report.schema.json +6 -2
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// D-contract-csv: a spreadsheet-shaped projection of a feature contract -- one row per operation,
|
|
2
|
+
// opened by someone who will never read a JSON Schema. Pure module, no I/O, no gate awareness, no
|
|
3
|
+
// process -- mirrors contracts/export.mjs's own contract (see that file's header comment) exactly,
|
|
4
|
+
// so this file can be unit-tested with hand-built `contract` objects and nothing else.
|
|
5
|
+
//
|
|
6
|
+
// C1: a fixed 14-column header, always -- see C2 in DECISIONS.md for why a column is never
|
|
7
|
+
// dropped just because every operation leaves it blank (a scan-only contract's `summary`/`tags`/
|
|
8
|
+
// `security` columns ARE the finding "nobody stated these", not something to hide by omitting the
|
|
9
|
+
// column). C3: `description` is deliberately NOT a column (measured average 2,442.7 bytes/op,
|
|
10
|
+
// larger than every other copied field combined -- see D-openapi-description).
|
|
11
|
+
export const CSV_COLUMNS = Object.freeze([
|
|
12
|
+
{ name: 'operation_id', extract: (op, operationId) => operationId },
|
|
13
|
+
{ name: 'verb', extract: (op) => op.verb },
|
|
14
|
+
{ name: 'path', extract: (op) => op.path },
|
|
15
|
+
{ name: 'path_params', extract: (op) => sortedKeys(op.pathParams?.properties).join(', ') },
|
|
16
|
+
{ name: 'path_params_unverified', extract: (op) => (op.pathParamsHeuristic ?? []).join(', ') },
|
|
17
|
+
{ name: 'body', extract: (op) => String(op.body) },
|
|
18
|
+
{ name: 'request_body_required', extract: (op) => (op.requestBodyRequired == null ? '' : String(op.requestBodyRequired)) },
|
|
19
|
+
{ name: 'request_body_fields', extract: (op) => sortedKeys(op.requestBodySchema?.properties).join(', ') },
|
|
20
|
+
{ name: 'response_fields', extract: (op) => sortedKeys(op.responseSchema?.properties).join(', ') },
|
|
21
|
+
{ name: 'error_fields', extract: (op) => sortedKeys(op.errorSchema?.properties).join(', ') },
|
|
22
|
+
{ name: 'provenance', extract: (op) => op.provenance },
|
|
23
|
+
{ name: 'summary', extract: (op) => op.sourceSummary ?? '' },
|
|
24
|
+
{ name: 'tags', extract: (op) => (op.sourceTags ?? []).join(', ') },
|
|
25
|
+
{ name: 'security', extract: (op) => extractSecurity(op.sourceSecurity) },
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
function sortedKeys(properties) {
|
|
29
|
+
return properties ? Object.keys(properties).sort() : [];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// `security: []` is a genuine positive claim ("this operation declared no auth requirement") --
|
|
33
|
+
// see schemas/feature-contract.schema.json's own sourceSecurity description. An ABSENT
|
|
34
|
+
// sourceSecurity means the source document said nothing about security for this operation at all.
|
|
35
|
+
// The two must render distinguishably, or a reviewer cannot tell "confirmed open" from "unknown".
|
|
36
|
+
function extractSecurity(sourceSecurity) {
|
|
37
|
+
if (sourceSecurity == null) return '';
|
|
38
|
+
if (sourceSecurity.length === 0) return '(source-declared: none)';
|
|
39
|
+
const schemeNames = new Set();
|
|
40
|
+
for (const requirement of sourceSecurity) {
|
|
41
|
+
for (const scheme of Object.keys(requirement)) schemeNames.add(scheme);
|
|
42
|
+
}
|
|
43
|
+
return [...schemeNames].sort().join(', ');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// C4: RFC 4180, hand-rolled -- quote a field iff it contains a double quote, comma, CR, or LF;
|
|
47
|
+
// an embedded double quote is escaped by doubling it. Deliberately does NOT quote a field that
|
|
48
|
+
// needs none of this (the negative case is what catches an over-eager escaper in tests). No new
|
|
49
|
+
// dependency: this is the entire rule CSV has needed since RFC 4180 (2005).
|
|
50
|
+
export function escapeCsvField(value) {
|
|
51
|
+
const str = String(value);
|
|
52
|
+
if (/["\n\r,]/.test(str)) {
|
|
53
|
+
return `"${str.replaceAll('"', '""')}"`;
|
|
54
|
+
}
|
|
55
|
+
return str;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// C4: LF line terminator (not RFC 4180's CRLF) -- every other artifact this project writes is
|
|
59
|
+
// LF, and these files get committed/diffed. No comment/provenance preamble: line 1 is always the
|
|
60
|
+
// header row, because a leading `#`/comment line breaks `pandas.read_csv`/`csv.DictReader`/every
|
|
61
|
+
// spreadsheet importer's default settings -- provenance goes to stderr/`--json`, never into the
|
|
62
|
+
// file itself.
|
|
63
|
+
export function toCsv(header, rows) {
|
|
64
|
+
const lines = [header, ...rows].map((row) => row.map(escapeCsvField).join(','));
|
|
65
|
+
return `${lines.join('\n')}\n`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// C5: deliberately does NOT check gate state, does NOT read a scan report, does NOT look at
|
|
69
|
+
// path-prefix signals -- this is a pure projection of an already-loaded `contract` object. The
|
|
70
|
+
// caller (bin/bskel.mjs's cmdContractExportCsv) owns every refusal/warning decision; this
|
|
71
|
+
// function only ever succeeds or reports "there's nothing to export" for a genuinely empty
|
|
72
|
+
// contract.
|
|
73
|
+
export function buildContractCsv({ contract }) {
|
|
74
|
+
const operationIds = Object.keys(contract.operations).sort((a, b) => a.localeCompare(b));
|
|
75
|
+
if (operationIds.length === 0) {
|
|
76
|
+
return { ok: false, error: 'contract has zero operations' };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const header = CSV_COLUMNS.map((c) => c.name);
|
|
80
|
+
const rows = operationIds.map((operationId) => {
|
|
81
|
+
const op = contract.operations[operationId];
|
|
82
|
+
return CSV_COLUMNS.map((c) => c.extract(op, operationId));
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// C2: coverage is reported alongside the file, never inferred by a reader staring at blank
|
|
86
|
+
// cells wondering whether the tool is broken.
|
|
87
|
+
const columns = CSV_COLUMNS.map((c, i) => ({
|
|
88
|
+
name: c.name,
|
|
89
|
+
populatedRows: rows.filter((row) => row[i] !== '').length,
|
|
90
|
+
}));
|
|
91
|
+
const emptyColumns = columns.filter((c) => c.populatedRows === 0).map((c) => c.name);
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
ok: true,
|
|
95
|
+
csv: toCsv(header, rows),
|
|
96
|
+
rowCount: rows.length,
|
|
97
|
+
columns,
|
|
98
|
+
emptyColumns,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// D-business-rules (R9): the emit-side half of compiled business rules for java-spring. Its own
|
|
2
|
+
// file, sibling to observe.mjs and emit.mjs, for exactly the reason observe.mjs's own header gives:
|
|
3
|
+
// rules, observe, and handles are orthogonal capabilities that happen to share the same repo-wide
|
|
4
|
+
// "generated infra" pattern, not the same feature.
|
|
5
|
+
//
|
|
6
|
+
// This emitter is deliberately THIN. It renders two fixed infra classes and copies an
|
|
7
|
+
// already-compiled artifact onto the classpath -- it makes no decision about what a rule means.
|
|
8
|
+
// Every such decision was made and verified in JS by rules/compile.mjs at `bskel rules check` time
|
|
9
|
+
// (R3), which is the invariant that lets three languages agree.
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
import { emitUnits, unifiedDiff } from '../../_engine.mjs';
|
|
14
|
+
import { detectJacksonPackage } from './emit.mjs';
|
|
15
|
+
import { renderExprInfix, groupDerivedByResource } from '../../../rules/derived.mjs';
|
|
16
|
+
import { pascalCase } from '../../../rules/vocabulary.mjs';
|
|
17
|
+
|
|
18
|
+
const PROVIDER_ROOT = path.dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
const TEMPLATES_DIR = path.join(PROVIDER_ROOT, 'templates');
|
|
20
|
+
|
|
21
|
+
// Repo-wide, shared across every feature that ever runs `bskel rules emit` -- RuleSetLoader
|
|
22
|
+
// discovers every `bskel/*.rules.json` classpath resource at startup rather than being regenerated
|
|
23
|
+
// per feature, so these files are true infra (create-once-per-repo, all-or-nothing conflict unit),
|
|
24
|
+
// the same treatment observe.mjs's own INFRA_FILES get. EnforceRules/RuleEnforcementAspect (R8) are
|
|
25
|
+
// the automatic field/cross wiring path -- transition rules stay a manual RuleCheck.checkTransitions
|
|
26
|
+
// call, since a transition guard needs the resource's current state, which no annotation can supply
|
|
27
|
+
// generically (see EnforceRules.java.tmpl's own javadoc).
|
|
28
|
+
const INFRA_FILES = [
|
|
29
|
+
{ template: 'RuleSetLoader.java.tmpl', target: 'global/rules/RuleSetLoader.java' },
|
|
30
|
+
{ template: 'RuleCheck.java.tmpl', target: 'global/rules/RuleCheck.java' },
|
|
31
|
+
{ template: 'EnforceRules.java.tmpl', target: 'global/rules/EnforceRules.java' },
|
|
32
|
+
{ template: 'RuleEnforcementAspect.java.tmpl', target: 'global/rules/RuleEnforcementAspect.java' },
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
function render(templatePath, vars) {
|
|
36
|
+
let content = fs.readFileSync(templatePath, 'utf8');
|
|
37
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
38
|
+
content = content.replaceAll(`{{${key}}}`, String(value));
|
|
39
|
+
}
|
|
40
|
+
return content;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function writeUnit(target, content) {
|
|
44
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
45
|
+
fs.writeFileSync(target, content);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// R5/Phase 3: one file per resource, one static method per derived field. A COMPLETE, compiling
|
|
49
|
+
// pure function -- never a stub -- matching R5's own "the call site stays the human's, the
|
|
50
|
+
// function itself does not" framing. Params are always `double`: this vocabulary's operator set
|
|
51
|
+
// (add/sub/mul/div, R3) is numeric-only by construction, so there is no type to infer beyond
|
|
52
|
+
// "a number" -- see rules/vocabulary.mjs's own DERIVED_OPS.
|
|
53
|
+
function renderDerivedClass(resource, rules, featureId, basePackage) {
|
|
54
|
+
const methods = rules.map((rule) => {
|
|
55
|
+
const params = rule.params.map((p) => `double ${p}`).join(', ');
|
|
56
|
+
const formula = renderExprInfix(rule.expr);
|
|
57
|
+
return `\t/** {@code ${rule.field} = ${formula}} -- rule "${rule.id}". */\n\tpublic static double compute${pascalCase(rule.field)}(${params}) {\n\t\treturn ${formula};\n\t}`;
|
|
58
|
+
});
|
|
59
|
+
return `package ${basePackage}.global.rules;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Generated by backend-skeleton ({@code bskel rules emit}) for feature ${featureId}.
|
|
63
|
+
*
|
|
64
|
+
* <p>D-business-rules (R5): a complete, compiling pure function per derived field -- never a
|
|
65
|
+
* stub. Nothing calls these methods; wire the call site yourself wherever a computed field on
|
|
66
|
+
* {@code ${resource}} should actually be set (e.g. before persisting).
|
|
67
|
+
*/
|
|
68
|
+
public final class ${resource}Rules {
|
|
69
|
+
|
|
70
|
+
private ${resource}Rules() {}
|
|
71
|
+
|
|
72
|
+
${methods.join('\n\n')}
|
|
73
|
+
}
|
|
74
|
+
`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* @param {object} args
|
|
79
|
+
* @param {object} args.artifact the already-compiled, already-schema-validated rules artifact
|
|
80
|
+
* (rules/store.mjs's loadRulesArtifact) -- this function never reads
|
|
81
|
+
* specs/ itself, the same split observe.mjs holds for the contract.
|
|
82
|
+
*/
|
|
83
|
+
export function emitRulesJavaSpring({ repoRoot, featureId, artifact, basePackage, force = false, reason = '', dryRun = false, computeDiff = false }) {
|
|
84
|
+
const javaSrcRoot = path.join(repoRoot, 'src', 'main', 'java', ...basePackage.split('.'));
|
|
85
|
+
const jacksonPackage = detectJacksonPackage(repoRoot);
|
|
86
|
+
|
|
87
|
+
const infraUnits = INFRA_FILES.map((f) => ({
|
|
88
|
+
id: f.template,
|
|
89
|
+
templatePath: path.join(TEMPLATES_DIR, f.template),
|
|
90
|
+
targetAbs: path.join(javaSrcRoot, f.target),
|
|
91
|
+
rendered: render(path.join(TEMPLATES_DIR, f.template), { BASE_PACKAGE: basePackage, JACKSON_PACKAGE: jacksonPackage }),
|
|
92
|
+
}));
|
|
93
|
+
|
|
94
|
+
// R5/Phase 3: one <Resource>Rules.java per resource with a derived field, emitted the SAME
|
|
95
|
+
// unconditional way the .rules.json spec resource below is -- a complete, never-hand-edited
|
|
96
|
+
// pure function file, the same category as RuleCheck.java itself, not a resolver stub. See
|
|
97
|
+
// groupDerivedByResource()'s own doc comment for the named cross-feature-collision limitation.
|
|
98
|
+
const derivedGroups = groupDerivedByResource(artifact.derived ?? []);
|
|
99
|
+
const derivedUnits = derivedGroups.map(([resource, rules]) => ({
|
|
100
|
+
resource,
|
|
101
|
+
targetAbs: path.join(javaSrcRoot, 'global', 'rules', `${resource}Rules.java`),
|
|
102
|
+
content: renderDerivedClass(resource, rules, featureId, basePackage),
|
|
103
|
+
}));
|
|
104
|
+
|
|
105
|
+
const result = emitUnits({ repoRoot, featureId, provider: 'java-spring', force, reason, infraUnits, resolverUnits: [], orphanScan: null, dryRun, computeDiff });
|
|
106
|
+
|
|
107
|
+
for (const unit of derivedUnits) {
|
|
108
|
+
const relPath = path.relative(repoRoot, unit.targetAbs);
|
|
109
|
+
const diskContent = fs.existsSync(unit.targetAbs) ? fs.readFileSync(unit.targetAbs, 'utf8') : null;
|
|
110
|
+
const action = diskContent === null ? 'create' : (diskContent === unit.content ? 'unchanged' : 'update');
|
|
111
|
+
if (!dryRun) writeUnit(unit.targetAbs, unit.content);
|
|
112
|
+
result.written.push(relPath);
|
|
113
|
+
const actionEntry = { path: relPath, kind: 'derived', resourceType: unit.resource, action };
|
|
114
|
+
if (computeDiff && action === 'update') actionEntry.diff = unifiedDiff(relPath, diskContent, unit.content);
|
|
115
|
+
result.actions.push(actionEntry);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// The compiled artifact, copied verbatim onto the classpath. Byte-identical to the file
|
|
119
|
+
// `bskel rules check` already wrote and schema-validated -- deliberately NOT re-serialized
|
|
120
|
+
// here, so there is exactly one representation of these rules and no chance of the runtime
|
|
121
|
+
// executing something subtly different from what was audited. `kind: 'spec'` = always
|
|
122
|
+
// regenerated, not conflict-tracked (nobody hand-finishes a generated data file).
|
|
123
|
+
const content = `${JSON.stringify(artifact, null, '\t')}\n`;
|
|
124
|
+
const target = path.join(repoRoot, 'src', 'main', 'resources', 'bskel', `${featureId}.rules.json`);
|
|
125
|
+
const relPath = path.relative(repoRoot, target);
|
|
126
|
+
const diskContent = fs.existsSync(target) ? fs.readFileSync(target, 'utf8') : null;
|
|
127
|
+
const action = diskContent === null ? 'create' : (diskContent === content ? 'unchanged' : 'update');
|
|
128
|
+
if (!dryRun) writeUnit(target, content);
|
|
129
|
+
result.written.push(relPath);
|
|
130
|
+
const actionEntry = { path: relPath, kind: 'spec', action };
|
|
131
|
+
if (computeDiff && action === 'update') actionEntry.diff = unifiedDiff(relPath, diskContent, content);
|
|
132
|
+
result.actions.push(actionEntry);
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
...result,
|
|
136
|
+
postEmitNotes: [
|
|
137
|
+
`field/cross rules are LOADED but not yet ACTIVE: add @EnforceRules(operationId = "<operationId>") to the real controller method to have them checked automatically on every call.`,
|
|
138
|
+
`defaults to OBSERVE (logs to the "bskel.rules.violations" logger, never rejects a request) -- set bskel.rules.mode: enforce in your own application.yml when you're ready for a real violation to reject with HTTP 400. No re-run of \`bskel rules emit\` needed to switch.`,
|
|
139
|
+
`transition rules are NOT checked by @EnforceRules -- they need the resource's CURRENT state, which no annotation can supply generically. Call RuleCheck.checkTransitions(rules, body, Map.of("/status", current.getStatus())) directly wherever your service layer has that state. A transition whose current state is not supplied reports "unchecked", never a silent pass.`,
|
|
140
|
+
...(derivedUnits.length > 0 ? [`derived field(s) compiled to ${derivedUnits.map((u) => `${u.resource}Rules`).join(', ')}: complete, compiling pure functions, but NOTHING calls them -- wire the call site yourself. If another feature also declares a derived field on the same resource, whichever feature's \`rules emit\` runs LAST wins for that file -- not merged.`] : []),
|
|
141
|
+
],
|
|
142
|
+
};
|
|
143
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
package {{BASE_PACKAGE}}.global.rules;
|
|
2
|
+
|
|
3
|
+
import java.lang.annotation.ElementType;
|
|
4
|
+
import java.lang.annotation.Retention;
|
|
5
|
+
import java.lang.annotation.RetentionPolicy;
|
|
6
|
+
import java.lang.annotation.Target;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* D-business-rules (R8/R9): apply this to an EXISTING controller method (never generated onto
|
|
10
|
+
* one -- a human decides which methods are worth checking) to have {@link RuleEnforcementAspect}
|
|
11
|
+
* run this operation's compiled FIELD and CROSS rules against the real request body.
|
|
12
|
+
*
|
|
13
|
+
* <p><b>Does not check TRANSITION rules.</b> A transition guard needs the resource's CURRENT
|
|
14
|
+
* persisted state, which this annotation's own request-scoped context has no way to obtain
|
|
15
|
+
* generically -- that is the one honest gap this automatic path leaves open, matching
|
|
16
|
+
* {@link RuleCheck#checkTransitions}'s own javadoc. Call it directly, with the current state your
|
|
17
|
+
* own service layer already has, wherever a transition needs checking.
|
|
18
|
+
*
|
|
19
|
+
* <p>Whether a violation is logged only, or rejects the request, is a RUNTIME property
|
|
20
|
+
* ({@code bskel.rules.mode: observe|enforce}, default {@code observe}) -- not baked into the
|
|
21
|
+
* generated code, so switching from observation to enforcement never requires re-running
|
|
22
|
+
* {@code bskel rules emit}. See {@link RuleEnforcementAspect} for the full behavior.
|
|
23
|
+
*
|
|
24
|
+
* <p>Must be applied to the CONCRETE implementation method, not an interface method it overrides
|
|
25
|
+
* -- same {@code AopUtils#getMostSpecificMethod} constraint {@code ObserveContract}'s own javadoc
|
|
26
|
+
* already documents.
|
|
27
|
+
*
|
|
28
|
+
* <p>Example:
|
|
29
|
+
* <pre>{@code
|
|
30
|
+
* @EnforceRules(operationId = "updateWidget")
|
|
31
|
+
* public ResponseEntity<WidgetResponse> updateWidget(@PathVariable UUID widgetId, @RequestBody UpdateWidgetRequest request) { ... }
|
|
32
|
+
* }</pre>
|
|
33
|
+
*
|
|
34
|
+
* <p>Generated by backend-skeleton ({@code bskel rules emit}). Requires {@code
|
|
35
|
+
* spring-boot-starter-aop} on the target repo's own classpath -- NOT added automatically, matching
|
|
36
|
+
* this project's established boundary of never auto-editing a target's build file.
|
|
37
|
+
*/
|
|
38
|
+
@Retention(RetentionPolicy.RUNTIME)
|
|
39
|
+
@Target(ElementType.METHOD)
|
|
40
|
+
public @interface EnforceRules {
|
|
41
|
+
|
|
42
|
+
/** Must match a key in the emitted {@code <feature-id>.rules.json}'s own operations map. */
|
|
43
|
+
String operationId();
|
|
44
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
package {{BASE_PACKAGE}}.global.rules;
|
|
2
|
+
|
|
3
|
+
import {{JACKSON_PACKAGE}}.JsonNode;
|
|
4
|
+
import {{BASE_PACKAGE}}.global.rules.RuleSetLoader.CrossRule;
|
|
5
|
+
import {{BASE_PACKAGE}}.global.rules.RuleSetLoader.FieldRule;
|
|
6
|
+
import {{BASE_PACKAGE}}.global.rules.RuleSetLoader.RuleOperation;
|
|
7
|
+
import {{BASE_PACKAGE}}.global.rules.RuleSetLoader.TransitionRule;
|
|
8
|
+
|
|
9
|
+
import java.math.BigDecimal;
|
|
10
|
+
import java.util.ArrayList;
|
|
11
|
+
import java.util.List;
|
|
12
|
+
import java.util.Map;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* D-business-rules (R3/R9): pure, static, non-Spring executor for one feature's compiled business
|
|
16
|
+
* rules. A DUMB executor by design -- every semantic decision (which assertions exist, whether an
|
|
17
|
+
* assertion may apply to a field's type, whether two fields are comparable, whether a transition's
|
|
18
|
+
* states are real) was already made in JS by {@code rules/compile.mjs} at {@code bskel rules check}
|
|
19
|
+
* time. This class never interprets a JSON Schema and never decides what is checkable; it only
|
|
20
|
+
* executes an already-simplified instruction set. That is the same boundary
|
|
21
|
+
* {@code ContractCheck}/{@code handles/observe-schema-projection.mjs} hold, and it is what lets the
|
|
22
|
+
* Java, Python, and TypeScript executors be guaranteed to agree.
|
|
23
|
+
*
|
|
24
|
+
* <p><b>Redaction invariant, inherited from {@code ContractCheck} and equally binding here:</b>
|
|
25
|
+
* {@link Violation#message()} MUST NEVER interpolate an observed payload value -- only the JSON
|
|
26
|
+
* Pointer, the rule id, and the rule's own declared bound. A bound is safe because it came from the
|
|
27
|
+
* contract or from {@code rules.yaml}, i.e. from the developer, never from a request. A real
|
|
28
|
+
* payload value must never leave this process, structurally rather than by convention.
|
|
29
|
+
*
|
|
30
|
+
* <p>Generated by backend-skeleton ({@code bskel rules emit}). Do not hand-edit -- change the
|
|
31
|
+
* source rule and regenerate.
|
|
32
|
+
*/
|
|
33
|
+
public final class RuleCheck {
|
|
34
|
+
|
|
35
|
+
private RuleCheck() {}
|
|
36
|
+
|
|
37
|
+
public record Violation(String ruleId, String pointer, String kind, String message) {}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Runs every FIELD and CROSS rule for one operation against a request body. Transition rules are
|
|
41
|
+
* deliberately NOT run here -- see {@link #checkTransitions} for why they need an argument this
|
|
42
|
+
* method does not have.
|
|
43
|
+
*/
|
|
44
|
+
public static List<Violation> check(RuleOperation rules, JsonNode body) {
|
|
45
|
+
List<Violation> violations = new ArrayList<>();
|
|
46
|
+
if (rules == null || body == null || !body.isObject()) return violations;
|
|
47
|
+
for (FieldRule rule : rules.field()) checkField(rule, body, violations);
|
|
48
|
+
for (CrossRule rule : rules.cross()) checkCross(rule, body, violations);
|
|
49
|
+
return violations;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
private static JsonNode at(JsonNode body, String pointer) {
|
|
53
|
+
// Compiled pointers are always a single top-level "/field" segment (rules/compile.mjs
|
|
54
|
+
// refuses anything deeper), so this is a lookup, not a JSON Pointer implementation.
|
|
55
|
+
JsonNode node = body.get(pointer.substring(1));
|
|
56
|
+
return (node == null || node.isNull() || node.isMissingNode()) ? null : node;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private static void checkField(FieldRule rule, JsonNode body, List<Violation> out) {
|
|
60
|
+
JsonNode value = at(body, rule.pointer());
|
|
61
|
+
// An absent optional field is not a violation -- requiredness is the contract's own
|
|
62
|
+
// statement, enforced by ContractCheck, deliberately not duplicated in this vocabulary.
|
|
63
|
+
if (value == null) return;
|
|
64
|
+
switch (rule.assertion()) {
|
|
65
|
+
case "minLength" -> {
|
|
66
|
+
if (value.isTextual() && value.asText().length() < rule.value().intValue()) {
|
|
67
|
+
out.add(violation(rule.id(), rule.pointer(), "minLength", "shorter than the required minimum length of " + rule.value().intValue()));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
case "maxLength" -> {
|
|
71
|
+
if (value.isTextual() && value.asText().length() > rule.value().intValue()) {
|
|
72
|
+
out.add(violation(rule.id(), rule.pointer(), "maxLength", "longer than the permitted maximum length of " + rule.value().intValue()));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
case "minimum" -> compareNumber(rule, value, out, (actual, bound) -> actual.compareTo(bound) < 0, "is below the permitted minimum of ");
|
|
76
|
+
case "maximum" -> compareNumber(rule, value, out, (actual, bound) -> actual.compareTo(bound) > 0, "is above the permitted maximum of ");
|
|
77
|
+
case "exclusiveMinimum" -> compareNumber(rule, value, out, (actual, bound) -> actual.compareTo(bound) <= 0, "must be strictly greater than ");
|
|
78
|
+
case "exclusiveMaximum" -> compareNumber(rule, value, out, (actual, bound) -> actual.compareTo(bound) >= 0, "must be strictly less than ");
|
|
79
|
+
case "multipleOf" -> {
|
|
80
|
+
if (value.isNumber() && rule.value().decimalValue().signum() != 0) {
|
|
81
|
+
BigDecimal remainder = value.decimalValue().remainder(rule.value().decimalValue());
|
|
82
|
+
if (remainder.signum() != 0) {
|
|
83
|
+
out.add(violation(rule.id(), rule.pointer(), "multipleOf", "is not an exact multiple of " + rule.value().decimalValue().toPlainString()));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
case "enum" -> {
|
|
88
|
+
boolean matched = false;
|
|
89
|
+
for (JsonNode allowed : rule.enumValues()) {
|
|
90
|
+
if (allowed.equals(value)) { matched = true; break; }
|
|
91
|
+
}
|
|
92
|
+
if (!matched) out.add(violation(rule.id(), rule.pointer(), "enum", "is not one of the " + rule.enumValues().size() + " permitted values"));
|
|
93
|
+
}
|
|
94
|
+
// No default branch that passes silently: an assertion name this executor does not know
|
|
95
|
+
// can only mean the artifact was produced by a NEWER bskel than the code generated here.
|
|
96
|
+
// Reported, never ignored -- the same posture ContractCheck's `unsupported` takes.
|
|
97
|
+
default -> out.add(violation(rule.id(), rule.pointer(), "unsupported",
|
|
98
|
+
"rule assertion \"" + rule.assertion() + "\" is not understood by this generated checker -- re-run `bskel rules emit` to regenerate it"));
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private interface NumberPredicate { boolean violates(BigDecimal actual, BigDecimal bound); }
|
|
103
|
+
|
|
104
|
+
private static void compareNumber(FieldRule rule, JsonNode value, List<Violation> out, NumberPredicate predicate, String messagePrefix) {
|
|
105
|
+
if (!value.isNumber()) return;
|
|
106
|
+
BigDecimal bound = rule.value().decimalValue();
|
|
107
|
+
if (predicate.violates(value.decimalValue(), bound)) {
|
|
108
|
+
out.add(violation(rule.id(), rule.pointer(), rule.assertion(), messagePrefix + bound.toPlainString()));
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private static void checkCross(CrossRule rule, JsonNode body, List<Violation> out) {
|
|
113
|
+
List<String> pointers = rule.pointers();
|
|
114
|
+
String first = pointers.get(0);
|
|
115
|
+
JsonNode a = at(body, first);
|
|
116
|
+
|
|
117
|
+
if ("requiredIf".equals(rule.assertion())) {
|
|
118
|
+
if (a != null && at(body, pointers.get(1)) == null) {
|
|
119
|
+
out.add(violation(rule.id(), pointers.get(1), "requiredIf", "is required because " + first + " is present"));
|
|
120
|
+
}
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if ("mutuallyExclusive".equals(rule.assertion())) {
|
|
124
|
+
List<String> present = new ArrayList<>();
|
|
125
|
+
for (String pointer : pointers) if (at(body, pointer) != null) present.add(pointer);
|
|
126
|
+
if (present.size() > 1) {
|
|
127
|
+
out.add(violation(rule.id(), first, "mutuallyExclusive", "at most one of " + String.join(", ", pointers) + " may be present, got " + present.size()));
|
|
128
|
+
}
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
JsonNode b = at(body, pointers.get(1));
|
|
133
|
+
// Either side absent means there is nothing to compare. Deliberately not a violation:
|
|
134
|
+
// requiredness is the contract's statement, and a cross-field rule that also implied
|
|
135
|
+
// requiredness would enforce something the developer never declared.
|
|
136
|
+
if (a == null || b == null) return;
|
|
137
|
+
|
|
138
|
+
int cmp;
|
|
139
|
+
if (a.isNumber() && b.isNumber()) {
|
|
140
|
+
cmp = a.decimalValue().compareTo(b.decimalValue());
|
|
141
|
+
} else if (a.isTextual() && b.isTextual()) {
|
|
142
|
+
cmp = a.asText().compareTo(b.asText());
|
|
143
|
+
} else {
|
|
144
|
+
// rules/compile.mjs proved these two pointers were mutually comparable against the
|
|
145
|
+
// contract's declared types, so reaching here means the real payload disagrees with the
|
|
146
|
+
// contract. Reported as its own kind rather than silently passing.
|
|
147
|
+
out.add(violation(rule.id(), first, "incomparable", "could not be compared with " + pointers.get(1) + " -- the payload types differ from the contract's declared types"));
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
boolean violated = switch (rule.assertion()) {
|
|
152
|
+
case "lt" -> cmp >= 0;
|
|
153
|
+
case "lte" -> cmp > 0;
|
|
154
|
+
case "gt" -> cmp <= 0;
|
|
155
|
+
case "gte" -> cmp < 0;
|
|
156
|
+
case "eq" -> cmp != 0;
|
|
157
|
+
case "neq" -> cmp == 0;
|
|
158
|
+
default -> false;
|
|
159
|
+
};
|
|
160
|
+
if (violated) {
|
|
161
|
+
out.add(violation(rule.id(), first, rule.assertion(), "must be " + symbolFor(rule.assertion()) + " " + pointers.get(1)));
|
|
162
|
+
} else if (!List.of("lt", "lte", "gt", "gte", "eq", "neq").contains(rule.assertion())) {
|
|
163
|
+
out.add(violation(rule.id(), first, "unsupported",
|
|
164
|
+
"rule assertion \"" + rule.assertion() + "\" is not understood by this generated checker -- re-run `bskel rules emit` to regenerate it"));
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private static String symbolFor(String assertion) {
|
|
169
|
+
return switch (assertion) {
|
|
170
|
+
case "lt" -> "<"; case "lte" -> "<="; case "gt" -> ">"; case "gte" -> ">=";
|
|
171
|
+
case "eq" -> "equal to"; case "neq" -> "different from"; default -> assertion;
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Runs TRANSITION rules. Separate from {@link #check} because a transition guard is inherently a
|
|
177
|
+
* statement about a change, and a request body only carries the DESTINATION state -- the current
|
|
178
|
+
* state lives in the application's own datastore, which this class has no access to and must
|
|
179
|
+
* never acquire.
|
|
180
|
+
*
|
|
181
|
+
* <p>{@code currentStates} maps a rule's pointer (e.g. {@code "/status"}) to that resource's
|
|
182
|
+
* CURRENT persisted value, supplied by the caller. This is the one place a human must wire
|
|
183
|
+
* something -- the same honest boundary {@code ResourceResolver#patchField} draws.
|
|
184
|
+
*
|
|
185
|
+
* <p><b>Fail-closed:</b> a pointer with no entry in {@code currentStates} produces an
|
|
186
|
+
* {@code unchecked} violation, never a silent pass. A transition guard that quietly does nothing
|
|
187
|
+
* because nobody supplied the current state is exactly the "looks enforced, never fires" failure
|
|
188
|
+
* this whole item refuses elsewhere (see {@code RULE_TRANSITION_NOT_ENUM} in
|
|
189
|
+
* {@code rules/diagnostics.mjs}).
|
|
190
|
+
*/
|
|
191
|
+
public static List<Violation> checkTransitions(RuleOperation rules, JsonNode body, Map<String, String> currentStates) {
|
|
192
|
+
List<Violation> violations = new ArrayList<>();
|
|
193
|
+
if (rules == null || body == null || !body.isObject()) return violations;
|
|
194
|
+
for (TransitionRule rule : rules.transition()) {
|
|
195
|
+
JsonNode target = at(body, rule.pointer());
|
|
196
|
+
if (target == null || !target.isTextual()) continue; // not changing this field
|
|
197
|
+
if (currentStates == null || !currentStates.containsKey(rule.pointer())) {
|
|
198
|
+
violations.add(violation(rule.id(), rule.pointer(), "unchecked",
|
|
199
|
+
"a transition guard is declared but the current state was not supplied, so it could not be evaluated -- pass it via checkTransitions(..., currentStates)"));
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
String from = currentStates.get(rule.pointer());
|
|
203
|
+
String to = target.asText();
|
|
204
|
+
if (from == null || from.equals(to)) continue; // no transition is occurring
|
|
205
|
+
if (rule.from().contains(from) && rule.to().contains(to)) continue; // explicitly allowed
|
|
206
|
+
violations.add(violation(rule.id(), rule.pointer(), "transition",
|
|
207
|
+
"this state change is not permitted -- allowed transitions are from {" + String.join(", ", rule.from()) + "} to {" + String.join(", ", rule.to()) + "}"));
|
|
208
|
+
}
|
|
209
|
+
return violations;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
private static Violation violation(String ruleId, String pointer, String kind, String message) {
|
|
213
|
+
return new Violation(ruleId, pointer, kind, message);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
package {{BASE_PACKAGE}}.global.rules;
|
|
2
|
+
|
|
3
|
+
import {{JACKSON_PACKAGE}}.JsonNode;
|
|
4
|
+
import {{JACKSON_PACKAGE}}.ObjectMapper;
|
|
5
|
+
import {{BASE_PACKAGE}}.global.rules.RuleSetLoader.RuleOperation;
|
|
6
|
+
import lombok.RequiredArgsConstructor;
|
|
7
|
+
import lombok.extern.slf4j.Slf4j;
|
|
8
|
+
import org.aspectj.lang.ProceedingJoinPoint;
|
|
9
|
+
import org.aspectj.lang.annotation.Around;
|
|
10
|
+
import org.aspectj.lang.annotation.Aspect;
|
|
11
|
+
import org.springframework.beans.factory.annotation.Value;
|
|
12
|
+
import org.springframework.http.HttpStatus;
|
|
13
|
+
import org.springframework.stereotype.Component;
|
|
14
|
+
import org.springframework.web.bind.annotation.RequestBody;
|
|
15
|
+
import org.springframework.web.server.ResponseStatusException;
|
|
16
|
+
|
|
17
|
+
import java.lang.annotation.Annotation;
|
|
18
|
+
import java.lang.reflect.Method;
|
|
19
|
+
import java.util.List;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* D-business-rules (R8/R9): the opt-in AUTOMATIC half of business-rule checking -- exists only for
|
|
23
|
+
* methods a human has chosen to mark with {@link EnforceRules}. Checks FIELD and CROSS rules only;
|
|
24
|
+
* see {@link EnforceRules}'s own javadoc for why TRANSITION rules are excluded from this automatic
|
|
25
|
+
* path.
|
|
26
|
+
*
|
|
27
|
+
* <p><b>The observe/enforce split is a RUNTIME property, read once per call from {@code
|
|
28
|
+
* bskel.rules.mode} (default {@code "observe"}):</b>
|
|
29
|
+
* <ul>
|
|
30
|
+
* <li>{@code observe} (default): every violation is logged (see {@code bskel.rules.violations}
|
|
31
|
+
* logger), the wrapped call always proceeds. The same "measure before you enforce" posture
|
|
32
|
+
* {@code ContractObservationAspect} already established for contract shape checking -- see
|
|
33
|
+
* DECISIONS.md's D-openapi-request-schema for why turning validation on in a brownfield app
|
|
34
|
+
* is a real behavior change (a request that used to succeed can start failing), not a free
|
|
35
|
+
* safety improvement.</li>
|
|
36
|
+
* <li>{@code enforce}: a violation rejects the call with HTTP 400 BEFORE it reaches the wrapped
|
|
37
|
+
* method -- {@code joinPoint.proceed()} is never called. The rejection message may name a
|
|
38
|
+
* rule id, a JSON Pointer, and a rule's own declared bound; it NEVER contains an observed
|
|
39
|
+
* request value -- the same redaction invariant {@link RuleCheck#Violation} carries.</li>
|
|
40
|
+
* </ul>
|
|
41
|
+
*
|
|
42
|
+
* <p><b>Fail-open on an INTERNAL error, fail-closed on a REAL violation.</b> If this aspect itself
|
|
43
|
+
* cannot compute violations (a malformed request body, a loader problem), that is logged and the
|
|
44
|
+
* wrapped call proceeds unaffected, in BOTH modes -- an unrelated bug in this aspect must never
|
|
45
|
+
* become an outage for every request through an enforced endpoint. Only an actual, successfully
|
|
46
|
+
* computed rule violation can reject a call, and only in enforce mode.
|
|
47
|
+
*
|
|
48
|
+
* <p>Generated by backend-skeleton ({@code bskel rules emit}). Requires {@code
|
|
49
|
+
* spring-boot-starter-aop} on the classpath -- see {@link EnforceRules}'s own javadoc.
|
|
50
|
+
*/
|
|
51
|
+
@Aspect
|
|
52
|
+
@Component
|
|
53
|
+
@RequiredArgsConstructor
|
|
54
|
+
@Slf4j
|
|
55
|
+
public class RuleEnforcementAspect {
|
|
56
|
+
|
|
57
|
+
private static final org.slf4j.Logger VIOLATIONS = org.slf4j.LoggerFactory.getLogger("bskel.rules.violations");
|
|
58
|
+
|
|
59
|
+
private final RuleSetLoader ruleSetLoader;
|
|
60
|
+
private final ObjectMapper objectMapper;
|
|
61
|
+
|
|
62
|
+
@Value("${bskel.rules.mode:observe}")
|
|
63
|
+
private String mode;
|
|
64
|
+
|
|
65
|
+
@Around("@annotation(enforceRules)")
|
|
66
|
+
public Object enforce(ProceedingJoinPoint joinPoint, EnforceRules enforceRules) throws Throwable {
|
|
67
|
+
String operationId = enforceRules.operationId();
|
|
68
|
+
RuleOperation rules = ruleSetLoader.forOperation(operationId);
|
|
69
|
+
if (rules.isEmpty()) {
|
|
70
|
+
// Nothing compiled for this operation -- most commonly a feature with no rules.yaml and
|
|
71
|
+
// a contract that projected nothing enforceable. Proceeds silently: an empty rule set is
|
|
72
|
+
// not a misconfiguration worth logging on every call.
|
|
73
|
+
return joinPoint.proceed();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
List<RuleCheck.Violation> violations = safelyCheck(joinPoint, rules, operationId);
|
|
77
|
+
if (!violations.isEmpty()) {
|
|
78
|
+
logViolations(operationId, violations);
|
|
79
|
+
if ("enforce".equals(mode)) {
|
|
80
|
+
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, summarize(operationId, violations));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return joinPoint.proceed();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private List<RuleCheck.Violation> safelyCheck(ProceedingJoinPoint joinPoint, RuleOperation rules, String operationId) {
|
|
87
|
+
try {
|
|
88
|
+
Object requestBody = findRequestBodyArg(joinPoint);
|
|
89
|
+
if (requestBody == null) return List.of();
|
|
90
|
+
JsonNode body = objectMapper.valueToTree(requestBody);
|
|
91
|
+
return RuleCheck.check(rules, body);
|
|
92
|
+
} catch (Exception e) {
|
|
93
|
+
log.warn("RuleEnforcementAspect: could not evaluate rules for operation \"{}\" -- the wrapped call proceeds unaffected", operationId, e);
|
|
94
|
+
return List.of();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Same technique ContractObservationAspect's own findRequestBodyArg uses: a real, unambiguous `@RequestBody` signal on the method, never guessed by position. */
|
|
99
|
+
private static Object findRequestBodyArg(ProceedingJoinPoint joinPoint) {
|
|
100
|
+
if (!(joinPoint.getSignature() instanceof org.aspectj.lang.reflect.MethodSignature sig)) return null;
|
|
101
|
+
Method method = sig.getMethod();
|
|
102
|
+
Annotation[][] paramAnnotations = method.getParameterAnnotations();
|
|
103
|
+
Object[] args = joinPoint.getArgs();
|
|
104
|
+
for (int i = 0; i < paramAnnotations.length && i < args.length; i++) {
|
|
105
|
+
for (Annotation a : paramAnnotations[i]) {
|
|
106
|
+
if (a instanceof RequestBody) return args[i];
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private void logViolations(String operationId, List<RuleCheck.Violation> violations) {
|
|
113
|
+
try {
|
|
114
|
+
for (RuleCheck.Violation v : violations) {
|
|
115
|
+
VIOLATIONS.warn("operation={} rule={} pointer={} kind={} mode={} -- {}", operationId, v.ruleId(), v.pointer(), v.kind(), mode, v.message());
|
|
116
|
+
}
|
|
117
|
+
} catch (Exception e) {
|
|
118
|
+
log.warn("RuleEnforcementAspect: could not log violations for operation \"{}\"", operationId, e);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Builds the HTTP 400 message from rule id / pointer / bound-derived text only -- never an observed value, the same redaction invariant every Violation#message() already holds. */
|
|
123
|
+
private static String summarize(String operationId, List<RuleCheck.Violation> violations) {
|
|
124
|
+
StringBuilder sb = new StringBuilder("business rule violation(s) for ").append(operationId).append(": ");
|
|
125
|
+
for (int i = 0; i < violations.size(); i++) {
|
|
126
|
+
if (i > 0) sb.append("; ");
|
|
127
|
+
RuleCheck.Violation v = violations.get(i);
|
|
128
|
+
sb.append(v.pointer()).append(" (").append(v.ruleId()).append("): ").append(v.message());
|
|
129
|
+
}
|
|
130
|
+
return sb.toString();
|
|
131
|
+
}
|
|
132
|
+
}
|