backend-skeleton 1.3.0 → 1.5.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/bin/bskel.mjs +308 -0
- package/contracts/emit.mjs +22 -6
- package/handles/providers/java-spring/plan.mjs +24 -3
- 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/plan.mjs +15 -2
- 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 +37 -0
- package/lib/gate-definitions.mjs +27 -1
- package/lib/workflow.mjs +9 -0
- package/package.json +2 -1
- 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/_java-spring-analyzer.mjs +49 -0
- package/scanners/adapters/java-spring.mjs +154 -3
- package/scanners/index.mjs +10 -0
- package/schemas/feature-contract.schema.json +12 -1
- package/schemas/feature-rules.schema.json +139 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
package {{BASE_PACKAGE}}.global.rules;
|
|
2
|
+
|
|
3
|
+
import {{JACKSON_PACKAGE}}.JsonNode;
|
|
4
|
+
import {{JACKSON_PACKAGE}}.ObjectMapper;
|
|
5
|
+
import lombok.extern.slf4j.Slf4j;
|
|
6
|
+
import org.springframework.core.io.Resource;
|
|
7
|
+
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
|
|
8
|
+
import org.springframework.stereotype.Component;
|
|
9
|
+
|
|
10
|
+
import java.io.IOException;
|
|
11
|
+
import java.io.InputStream;
|
|
12
|
+
import java.util.ArrayList;
|
|
13
|
+
import java.util.HashMap;
|
|
14
|
+
import java.util.List;
|
|
15
|
+
import java.util.Map;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* D-business-rules (R9): loads every {@code bskel/*.rules.json} classpath resource (one per feature
|
|
19
|
+
* {@code bskel rules emit} has been run against) at startup and merges them into one flat map keyed
|
|
20
|
+
* by operationId. Same reasoning as {@code ObserveSchemaLoader}: a deployed app has no access to
|
|
21
|
+
* {@code specs/} at runtime, so the compiled artifact travels as a classpath resource.
|
|
22
|
+
*
|
|
23
|
+
* <p>This class does NO interpretation. It deserializes an artifact whose every semantic decision
|
|
24
|
+
* was already made and verified in JS by {@code rules/compile.mjs} -- see {@code RuleCheck}'s own
|
|
25
|
+
* javadoc for that boundary.
|
|
26
|
+
*
|
|
27
|
+
* <p>Generated by backend-skeleton ({@code bskel rules emit}). Do not hand-edit -- change the
|
|
28
|
+
* source rule and regenerate.
|
|
29
|
+
*/
|
|
30
|
+
@Component
|
|
31
|
+
@Slf4j
|
|
32
|
+
public class RuleSetLoader {
|
|
33
|
+
|
|
34
|
+
/** {@code value} carries the declared bound for numeric/length assertions; {@code enumValues} is used only by the `enum` assertion. */
|
|
35
|
+
public record FieldRule(String id, String pointer, String assertion, JsonNode value, List<JsonNode> enumValues, String origin) {}
|
|
36
|
+
|
|
37
|
+
public record CrossRule(String id, List<String> pointers, String assertion, List<String> types, String origin) {}
|
|
38
|
+
|
|
39
|
+
public record TransitionRule(String id, String pointer, List<String> from, List<String> to, String origin) {}
|
|
40
|
+
|
|
41
|
+
public record RuleOperation(List<FieldRule> field, List<CrossRule> cross, List<TransitionRule> transition) {
|
|
42
|
+
public static final RuleOperation EMPTY = new RuleOperation(List.of(), List.of(), List.of());
|
|
43
|
+
public boolean isEmpty() { return field.isEmpty() && cross.isEmpty() && transition.isEmpty(); }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
private final Map<String, RuleOperation> operationsById = new HashMap<>();
|
|
47
|
+
private final Map<String, String> contractRefByOperation = new HashMap<>();
|
|
48
|
+
|
|
49
|
+
public RuleSetLoader(ObjectMapper objectMapper) {
|
|
50
|
+
try {
|
|
51
|
+
Resource[] resources = new PathMatchingResourcePatternResolver().getResources("classpath*:bskel/*.rules.json");
|
|
52
|
+
for (Resource resource : resources) {
|
|
53
|
+
try (InputStream in = resource.getInputStream()) {
|
|
54
|
+
load(objectMapper.readTree(in));
|
|
55
|
+
} catch (IOException e) {
|
|
56
|
+
// One unreadable resource must not take down application startup -- the same
|
|
57
|
+
// posture ObserveSchemaLoader takes for its own resources.
|
|
58
|
+
log.warn("bskel: could not read rules resource {} -- its rules will not be enforced", resource.getFilename(), e);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
} catch (IOException e) {
|
|
62
|
+
log.warn("bskel: could not scan for bskel/*.rules.json rule resources -- no business rules will be enforced", e);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private void load(JsonNode doc) {
|
|
67
|
+
String contractRef = doc.path("contract_ref").asText("");
|
|
68
|
+
JsonNode operations = doc.path("operations");
|
|
69
|
+
operations.fieldNames().forEachRemaining((operationId) -> {
|
|
70
|
+
JsonNode entry = operations.path(operationId);
|
|
71
|
+
operationsById.put(operationId, new RuleOperation(
|
|
72
|
+
fieldRules(entry.path("field")),
|
|
73
|
+
crossRules(entry.path("cross")),
|
|
74
|
+
transitionRules(entry.path("transition"))
|
|
75
|
+
));
|
|
76
|
+
contractRefByOperation.put(operationId, contractRef);
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private static List<FieldRule> fieldRules(JsonNode array) {
|
|
81
|
+
List<FieldRule> rules = new ArrayList<>();
|
|
82
|
+
if (!array.isArray()) return rules;
|
|
83
|
+
for (JsonNode node : array) {
|
|
84
|
+
JsonNode value = node.path("value");
|
|
85
|
+
List<JsonNode> enumValues = new ArrayList<>();
|
|
86
|
+
if (value.isArray()) for (JsonNode item : value) enumValues.add(item);
|
|
87
|
+
rules.add(new FieldRule(
|
|
88
|
+
node.path("id").asText(), node.path("pointer").asText(), node.path("assert").asText(),
|
|
89
|
+
value, List.copyOf(enumValues), node.path("origin").asText()
|
|
90
|
+
));
|
|
91
|
+
}
|
|
92
|
+
return List.copyOf(rules);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private static List<CrossRule> crossRules(JsonNode array) {
|
|
96
|
+
List<CrossRule> rules = new ArrayList<>();
|
|
97
|
+
if (!array.isArray()) return rules;
|
|
98
|
+
for (JsonNode node : array) {
|
|
99
|
+
rules.add(new CrossRule(
|
|
100
|
+
node.path("id").asText(), texts(node.path("pointers")), node.path("assert").asText(),
|
|
101
|
+
texts(node.path("types")), node.path("origin").asText()
|
|
102
|
+
));
|
|
103
|
+
}
|
|
104
|
+
return List.copyOf(rules);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private static List<TransitionRule> transitionRules(JsonNode array) {
|
|
108
|
+
List<TransitionRule> rules = new ArrayList<>();
|
|
109
|
+
if (!array.isArray()) return rules;
|
|
110
|
+
for (JsonNode node : array) {
|
|
111
|
+
rules.add(new TransitionRule(
|
|
112
|
+
node.path("id").asText(), node.path("pointer").asText(),
|
|
113
|
+
texts(node.path("from")), texts(node.path("to")), node.path("origin").asText()
|
|
114
|
+
));
|
|
115
|
+
}
|
|
116
|
+
return List.copyOf(rules);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private static List<String> texts(JsonNode array) {
|
|
120
|
+
List<String> out = new ArrayList<>();
|
|
121
|
+
if (array.isArray()) for (JsonNode node : array) out.add(node.asText());
|
|
122
|
+
return List.copyOf(out);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Never null -- an operation with no rules returns {@link RuleOperation#EMPTY}. */
|
|
126
|
+
public RuleOperation forOperation(String operationId) {
|
|
127
|
+
return operationsById.getOrDefault(operationId, RuleOperation.EMPTY);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The sha256 of the contract these rules were compiled and verified against. */
|
|
131
|
+
public String contractRefFor(String operationId) {
|
|
132
|
+
return contractRefByOperation.getOrDefault(operationId, "");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
public int operationCount() {
|
|
136
|
+
return operationsById.size();
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// D-business-rules (R9): the emit-side half of compiled business rules for python-fastapi. Mirrors
|
|
2
|
+
// handles/providers/java-spring/rules.mjs's own shape exactly -- rules, observe, and handles are
|
|
3
|
+
// orthogonal capabilities that happen to share the same repo-wide "generated infra" pattern.
|
|
4
|
+
//
|
|
5
|
+
// This emitter is deliberately THIN. It renders three fixed infra modules and copies an
|
|
6
|
+
// already-compiled artifact into `rules_schemas/` -- it makes no decision about what a rule means.
|
|
7
|
+
// Every such decision was made and verified in JS by rules/compile.mjs at `bskel rules check` time
|
|
8
|
+
// (R3), which is the invariant that lets three languages agree.
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
import { emitUnits, unifiedDiff } from '../../_engine.mjs';
|
|
13
|
+
import { renderExprInfix, groupDerivedByResource } from '../../../rules/derived.mjs';
|
|
14
|
+
import { snakeCase } from '../../../rules/vocabulary.mjs';
|
|
15
|
+
|
|
16
|
+
const PROVIDER_ROOT = path.dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
const TEMPLATES_DIR = path.join(PROVIDER_ROOT, 'templates');
|
|
18
|
+
|
|
19
|
+
// Repo-wide, shared across every feature that ever runs `bskel rules emit` -- rule_set.py
|
|
20
|
+
// discovers every `rules_schemas/*.rules.json` file at module-import time rather than being
|
|
21
|
+
// regenerated per feature, so these files are true infra (create-once-per-repo, all-or-nothing
|
|
22
|
+
// conflict unit), the same treatment observe.mjs's own INFRA_FILES get. None of the three
|
|
23
|
+
// templates need any {{VAR}} substitution -- same reasoning observe's own INFRA_FILES give (no
|
|
24
|
+
// {{PKG}}, this stays decoupled from handles/, cross-imports are relative `from . import ...`).
|
|
25
|
+
const INFRA_FILES = [
|
|
26
|
+
{ template: '__init__.py.tmpl', target: '__init__.py' },
|
|
27
|
+
{ template: 'rule_check.py.tmpl', target: 'rule_check.py' },
|
|
28
|
+
{ template: 'rule_set.py.tmpl', target: 'rule_set.py' },
|
|
29
|
+
{ template: 'enforce_rules.py.tmpl', target: 'enforce_rules.py' },
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
function render(templatePath, vars) {
|
|
33
|
+
let content = fs.readFileSync(templatePath, 'utf8');
|
|
34
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
35
|
+
content = content.replaceAll(`{{${key}}}`, String(value));
|
|
36
|
+
}
|
|
37
|
+
return content;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function writeUnit(target, content) {
|
|
41
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
42
|
+
fs.writeFileSync(target, content);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// R5/Phase 3: one module per resource, one function per derived field -- same "complete, never a
|
|
46
|
+
// stub" posture java-spring's own renderDerivedClass() holds. All params are plain numbers: this
|
|
47
|
+
// vocabulary's operator set (add/sub/mul/div) is numeric-only by construction.
|
|
48
|
+
function renderDerivedModule(resource, rules, featureId) {
|
|
49
|
+
const functions = rules.map((rule) => {
|
|
50
|
+
const params = rule.params.map(snakeCase).join(', ');
|
|
51
|
+
const refName = (name) => snakeCase(name);
|
|
52
|
+
const formula = renderExprInfix(rule.expr, refName);
|
|
53
|
+
return `def compute_${snakeCase(rule.field)}(${params}):\n """${rule.field} = ${formula} -- rule "${rule.id}"."""\n return ${formula}`;
|
|
54
|
+
});
|
|
55
|
+
return `"""Generated by backend-skeleton (bskel rules emit) for feature ${featureId}.
|
|
56
|
+
|
|
57
|
+
D-business-rules (R5): a complete, callable pure function per derived field -- never a stub.
|
|
58
|
+
Nothing calls these; wire the call site yourself wherever a computed field on ${resource} should
|
|
59
|
+
actually be set (e.g. before persisting).
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
${functions.join('\n\n\n')}
|
|
64
|
+
`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* `plan` is the already-computed python-fastapi resource plan (bin/bskel.mjs calls
|
|
69
|
+
* planPythonFastApi() before this) -- only plan.importRoot/plan.topPackage are used, matching
|
|
70
|
+
* observe.mjs's own tolerance-of-unused-fields pattern. `artifact` is the already-compiled,
|
|
71
|
+
* already-schema-validated rules artifact (rules/store.mjs's loadRulesArtifact) -- this function
|
|
72
|
+
* never reads specs/ itself, the same split observe.mjs holds for the contract.
|
|
73
|
+
*/
|
|
74
|
+
export function emitRulesPythonFastApi({ repoRoot, featureId, artifact, plan, force = false, reason = '', dryRun = false, computeDiff = false }) {
|
|
75
|
+
const rulesDir = path.join(plan.importRoot, plan.topPackage, 'rules');
|
|
76
|
+
// __init__.py.tmpl is shared verbatim with the handles/observe infra sets (an empty marker
|
|
77
|
+
// file) -- reuse the same template rather than duplicating a one-line file a third time.
|
|
78
|
+
const sharedInitTemplate = path.join(PROVIDER_ROOT, 'templates', '__init__.py.tmpl');
|
|
79
|
+
|
|
80
|
+
const infraUnits = INFRA_FILES.map((f) => ({
|
|
81
|
+
id: f.template,
|
|
82
|
+
templatePath: f.template === '__init__.py.tmpl' ? sharedInitTemplate : path.join(TEMPLATES_DIR, f.template),
|
|
83
|
+
targetAbs: path.join(rulesDir, f.target),
|
|
84
|
+
rendered: render(f.template === '__init__.py.tmpl' ? sharedInitTemplate : path.join(TEMPLATES_DIR, f.template), {}),
|
|
85
|
+
}));
|
|
86
|
+
|
|
87
|
+
// R5/Phase 3: one <resource>_rules.py per resource with a derived field, emitted the SAME
|
|
88
|
+
// unconditional way the *.rules.json spec resource below is. See java-spring's own
|
|
89
|
+
// groupDerivedByResource() comment for the named cross-feature-collision limitation.
|
|
90
|
+
const derivedGroups = groupDerivedByResource(artifact.derived ?? []);
|
|
91
|
+
const derivedUnits = derivedGroups.map(([resource, rules]) => ({
|
|
92
|
+
resource,
|
|
93
|
+
targetAbs: path.join(rulesDir, `${snakeCase(resource)}_rules.py`),
|
|
94
|
+
content: renderDerivedModule(resource, rules, featureId),
|
|
95
|
+
}));
|
|
96
|
+
|
|
97
|
+
const result = emitUnits({ repoRoot, featureId, provider: 'python-fastapi', force, reason, infraUnits, resolverUnits: [], orphanScan: null, dryRun, computeDiff });
|
|
98
|
+
|
|
99
|
+
for (const unit of derivedUnits) {
|
|
100
|
+
const relPath = path.relative(repoRoot, unit.targetAbs);
|
|
101
|
+
const diskContent = fs.existsSync(unit.targetAbs) ? fs.readFileSync(unit.targetAbs, 'utf8') : null;
|
|
102
|
+
const action = diskContent === null ? 'create' : (diskContent === unit.content ? 'unchanged' : 'update');
|
|
103
|
+
if (!dryRun) writeUnit(unit.targetAbs, unit.content);
|
|
104
|
+
result.written.push(relPath);
|
|
105
|
+
const actionEntry = { path: relPath, kind: 'derived', resourceType: unit.resource, action };
|
|
106
|
+
if (computeDiff && action === 'update') actionEntry.diff = unifiedDiff(relPath, diskContent, unit.content);
|
|
107
|
+
result.actions.push(actionEntry);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// The compiled artifact, copied verbatim -- byte-identical to the file `bskel rules check`
|
|
111
|
+
// already wrote and schema-validated, deliberately NOT re-serialized here, the same "exactly
|
|
112
|
+
// one representation of these rules" invariant java-spring's own emitter holds.
|
|
113
|
+
const content = `${JSON.stringify(artifact, null, '\t')}\n`;
|
|
114
|
+
const target = path.join(rulesDir, 'rules_schemas', `${featureId}.rules.json`);
|
|
115
|
+
const relPath = path.relative(repoRoot, target);
|
|
116
|
+
const diskContent = fs.existsSync(target) ? fs.readFileSync(target, 'utf8') : null;
|
|
117
|
+
const action = diskContent === null ? 'create' : (diskContent === content ? 'unchanged' : 'update');
|
|
118
|
+
if (!dryRun) writeUnit(target, content);
|
|
119
|
+
result.written.push(relPath);
|
|
120
|
+
const actionEntry = { path: relPath, kind: 'spec', action };
|
|
121
|
+
if (computeDiff && action === 'update') actionEntry.diff = unifiedDiff(relPath, diskContent, content);
|
|
122
|
+
result.actions.push(actionEntry);
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
...result,
|
|
126
|
+
postEmitNotes: [
|
|
127
|
+
`field/cross rules are LOADED but not yet ACTIVE: decorate the real route handler with @enforce_rules(operation_id="<operationId>", body_param="<arg name>") to have them checked automatically on every call.`,
|
|
128
|
+
`defaults to OBSERVE (logs to the "bskel.rules.violations" logger, never rejects a request) -- set the BSKEL_RULES_MODE=enforce environment variable when you're ready for a real violation to reject with HTTP 400. No re-run of \`bskel rules emit\` needed to switch.`,
|
|
129
|
+
`transition rules are NOT checked by @enforce_rules -- they need the resource's CURRENT state, which no decorator can supply generically. Call rule_check.check_transitions(rules, body, {"/status": current.status}) directly wherever your service layer has that state. A transition whose current state is not supplied reports "unchecked", never a silent pass.`,
|
|
130
|
+
...(derivedUnits.length > 0 ? [`derived field(s) compiled to ${derivedUnits.map((u) => `${snakeCase(u.resource)}_rules.py`).join(', ')}: complete, callable 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.`] : []),
|
|
131
|
+
],
|
|
132
|
+
};
|
|
133
|
+
}
|