backend-skeleton 1.0.0-beta.1
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/LICENSE +21 -0
- package/README.md +284 -0
- package/bin/bskel.mjs +2384 -0
- package/contracts/completeness.mjs +176 -0
- package/contracts/emit.mjs +287 -0
- package/contracts/export.mjs +325 -0
- package/contracts/openapi.mjs +869 -0
- package/contracts/validate.mjs +147 -0
- package/handles/_engine.mjs +281 -0
- package/handles/codec.mjs +119 -0
- package/handles/conformance.mjs +74 -0
- package/handles/providers/java-spring/ast-bridge.mjs +59 -0
- package/handles/providers/java-spring/ast-helper/build.gradle +34 -0
- package/handles/providers/java-spring/ast-helper/gradle/wrapper/gradle-wrapper.jar +0 -0
- package/handles/providers/java-spring/ast-helper/gradle/wrapper/gradle-wrapper.properties +9 -0
- package/handles/providers/java-spring/ast-helper/gradlew +248 -0
- package/handles/providers/java-spring/ast-helper/gradlew.bat +82 -0
- package/handles/providers/java-spring/ast-helper/settings.gradle +1 -0
- package/handles/providers/java-spring/ast-helper/src/main/java/com/backendskeleton/asthelper/Main.java +178 -0
- package/handles/providers/java-spring/emit.mjs +232 -0
- package/handles/providers/java-spring/patch-strategy.mjs +229 -0
- package/handles/providers/java-spring/plan.mjs +377 -0
- package/handles/providers/java-spring/templates/HandleAspect.java.tmpl +125 -0
- package/handles/providers/java-spring/templates/HandleCodec.java.tmpl +150 -0
- package/handles/providers/java-spring/templates/HandleController.java.tmpl +177 -0
- package/handles/providers/java-spring/templates/HandleRegistry.java.tmpl +107 -0
- package/handles/providers/java-spring/templates/HandleRegistryRepository.java.tmpl +8 -0
- package/handles/providers/java-spring/templates/HandleService.java.tmpl +95 -0
- package/handles/providers/java-spring/templates/HandleSnapshot.java.tmpl +75 -0
- package/handles/providers/java-spring/templates/HandleSnapshotRepository.java.tmpl +20 -0
- package/handles/providers/java-spring/templates/RecordHandleSnapshot.java.tmpl +50 -0
- package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +50 -0
- package/handles/providers/java-spring/templates/ResourceResolverStub.java.tmpl +77 -0
- package/handles/providers/java-spring/templates/migration.sql.tmpl +34 -0
- package/handles/providers/java-spring.mjs +21 -0
- package/handles/providers/python-fastapi/emit.mjs +171 -0
- package/handles/providers/python-fastapi/plan.mjs +186 -0
- package/handles/providers/python-fastapi/templates/__init__.py.tmpl +1 -0
- package/handles/providers/python-fastapi/templates/codec.py.tmpl +122 -0
- package/handles/providers/python-fastapi/templates/handle_service.py.tmpl +96 -0
- package/handles/providers/python-fastapi/templates/migration.sql.tmpl +35 -0
- package/handles/providers/python-fastapi/templates/record_snapshot.py.tmpl +155 -0
- package/handles/providers/python-fastapi/templates/registry.py.tmpl +37 -0
- package/handles/providers/python-fastapi/templates/resolver.py.tmpl +59 -0
- package/handles/providers/python-fastapi/templates/resolvers_init.py.tmpl +13 -0
- package/handles/providers/python-fastapi/templates/router.py.tmpl +140 -0
- package/handles/providers/python-fastapi/templates/tables.py.tmpl +66 -0
- package/handles/providers/python-fastapi.mjs +22 -0
- package/handles/providers/typescript-express/emit.mjs +128 -0
- package/handles/providers/typescript-express/plan.mjs +234 -0
- package/handles/providers/typescript-express/templates/codec.ts.tmpl +116 -0
- package/handles/providers/typescript-express/templates/registry.ts.tmpl +39 -0
- package/handles/providers/typescript-express/templates/resolver.ts.tmpl +55 -0
- package/handles/providers/typescript-express/templates/resolvers_index.ts.tmpl +11 -0
- package/handles/providers/typescript-express/templates/router.ts.tmpl +122 -0
- package/handles/providers/typescript-express.mjs +20 -0
- package/handles/registry.mjs +90 -0
- package/lib/cli.mjs +430 -0
- package/lib/doctor.mjs +200 -0
- package/lib/exit-codes.mjs +67 -0
- package/lib/featureid.mjs +55 -0
- package/lib/featurelifecycle.mjs +205 -0
- package/lib/fsutil.mjs +50 -0
- package/lib/gate-definitions.mjs +293 -0
- package/lib/gates.mjs +263 -0
- package/lib/handles-manifest.mjs +92 -0
- package/lib/lock.mjs +68 -0
- package/lib/patch-approvals.mjs +56 -0
- package/lib/paths.mjs +21 -0
- package/lib/repo.mjs +44 -0
- package/lib/schema-validate.mjs +56 -0
- package/lib/state.mjs +124 -0
- package/lib/template.mjs +35 -0
- package/lib/verify.mjs +206 -0
- package/lib/workflow.mjs +142 -0
- package/new/fastapi.mjs +165 -0
- package/new/index.mjs +62 -0
- package/new/params.mjs +233 -0
- package/new/spring.mjs +198 -0
- package/new/templates/fastapi/README.md +26 -0
- package/new/templates/fastapi/app/__init__.py +0 -0
- package/new/templates/fastapi/app/main.py +8 -0
- package/new/templates/fastapi/gitignore +6 -0
- package/new/templates/fastapi/pyproject.toml +14 -0
- package/package.json +50 -0
- package/scanners/adapters/_express-shared.mjs +238 -0
- package/scanners/adapters/_java-spring-analyzer.mjs +273 -0
- package/scanners/adapters/generic-grep.mjs +128 -0
- package/scanners/adapters/java-spring.mjs +301 -0
- package/scanners/adapters/javascript-express.mjs +422 -0
- package/scanners/adapters/python-fastapi.mjs +348 -0
- package/scanners/adapters/typescript-express.mjs +299 -0
- package/scanners/capabilities.mjs +90 -0
- package/scanners/conformance.mjs +59 -0
- package/scanners/db/introspect.mjs +109 -0
- package/scanners/db/migrations.mjs +126 -0
- package/scanners/index.mjs +281 -0
- package/scanners/registry.mjs +130 -0
- package/scanners/render.mjs +136 -0
- package/scanners/text-util.mjs +8 -0
- package/schemas/adapter.schema.json +23 -0
- package/schemas/agent-envelope.schema.json +21 -0
- package/schemas/contract-resolution.schema.json +28 -0
- package/schemas/feature-contract.schema.json +78 -0
- package/schemas/feature-index.schema.json +25 -0
- package/schemas/feature.schema.json +17 -0
- package/schemas/gate-event.schema.json +19 -0
- package/schemas/handles-plan.schema.json +31 -0
- package/schemas/handles-provider.schema.json +26 -0
- package/schemas/patch-approvals.schema.json +28 -0
- package/schemas/scan-report.schema.json +102 -0
- package/schemas/stack-choice.schema.json +89 -0
- package/schemas/stack-record.schema.json +20 -0
- package/schemas/state.schema.json +43 -0
- package/scripts/preflight-base-ref.sh +226 -0
- package/stack/apply.mjs +159 -0
- package/stack/bootstrap/_lib.sh +73 -0
- package/stack/bootstrap/ngrok.sh +90 -0
- package/stack/catalog/ngrok.yml +63 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
package {{BASE_PACKAGE}}.global.handle;
|
|
2
|
+
|
|
3
|
+
import {{JACKSON_PACKAGE}}.JsonNode;
|
|
4
|
+
import {{JACKSON_PACKAGE}}.ObjectMapper;
|
|
5
|
+
import lombok.RequiredArgsConstructor;
|
|
6
|
+
import org.springframework.http.ResponseEntity;
|
|
7
|
+
import org.springframework.security.core.GrantedAuthority;
|
|
8
|
+
import org.springframework.security.core.context.SecurityContextHolder;
|
|
9
|
+
import org.springframework.http.HttpStatus;
|
|
10
|
+
import org.springframework.web.bind.annotation.GetMapping;
|
|
11
|
+
import org.springframework.web.bind.annotation.PatchMapping;
|
|
12
|
+
import org.springframework.web.bind.annotation.PathVariable;
|
|
13
|
+
import org.springframework.web.bind.annotation.RequestBody;
|
|
14
|
+
import org.springframework.web.bind.annotation.RequestMapping;
|
|
15
|
+
import org.springframework.web.bind.annotation.RequestParam;
|
|
16
|
+
import org.springframework.web.bind.annotation.RestController;
|
|
17
|
+
import org.springframework.web.server.ResponseStatusException;
|
|
18
|
+
|
|
19
|
+
import java.time.Instant;
|
|
20
|
+
import java.util.List;
|
|
21
|
+
import java.util.Map;
|
|
22
|
+
import java.util.Objects;
|
|
23
|
+
import java.util.Optional;
|
|
24
|
+
import java.util.UUID;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* D-handles (DECISIONS.md): exposed in production. Per-resolver {@code requiredAuthority()} is
|
|
28
|
+
* the entire defense for this generic-object-accessor security surface -- a resolver that
|
|
29
|
+
* forgets to check something it should is a real vulnerability, not a theoretical one. Review
|
|
30
|
+
* new resolvers accordingly.
|
|
31
|
+
*
|
|
32
|
+
* <p>Generated by backend-skeleton ({@code bskel handles emit}). Registered resolvers come from
|
|
33
|
+
* {@code Map<String, ResourceResolver>} auto-collected by Spring from all {@code @Component}
|
|
34
|
+
* beans implementing {@link ResourceResolver} -- keyed by BEAN NAME, not {@link
|
|
35
|
+
* ResourceResolver#type()}, so {@link #resolverFor} looks up by iterating and matching {@code
|
|
36
|
+
* type()} explicitly rather than assuming bean name == type.
|
|
37
|
+
*/
|
|
38
|
+
@RestController
|
|
39
|
+
@RequestMapping("/handles")
|
|
40
|
+
@RequiredArgsConstructor
|
|
41
|
+
public class HandleController {
|
|
42
|
+
|
|
43
|
+
private final Map<String, ResourceResolver> resolversByBeanName;
|
|
44
|
+
private final HandleRegistryRepository handleRegistryRepository;
|
|
45
|
+
private final HandleSnapshotRepository handleSnapshotRepository;
|
|
46
|
+
private final ObjectMapper objectMapper;
|
|
47
|
+
|
|
48
|
+
@GetMapping("/{handle}")
|
|
49
|
+
public ResponseEntity<Object> fetch(@PathVariable String handle) {
|
|
50
|
+
HandleCodec.Decoded decoded = decodeOrThrow(handle);
|
|
51
|
+
ResourceResolver resolver = resolverFor(decoded.type());
|
|
52
|
+
requireAuthority(resolver.requiredAuthority());
|
|
53
|
+
|
|
54
|
+
Object resource = resolver.fetch(decoded.uuid());
|
|
55
|
+
if (decoded.pointer() == null) {
|
|
56
|
+
return ResponseEntity.ok(resource);
|
|
57
|
+
}
|
|
58
|
+
// O4 (D-handle-lifecycle): kind=f -- serialize the whole resource through the SAME
|
|
59
|
+
// ObjectMapper the rest of the app uses, then walk the RFC 6901 pointer via Jackson's own
|
|
60
|
+
// JsonNode#at (no hand-rolled walker needed here, unlike the JS reference implementation's
|
|
61
|
+
// resolveJsonPointer, which had no such built-in to reach for). A pointer that resolves to
|
|
62
|
+
// a genuinely missing node returns 404, not null/absent -- resolveJsonPointer's own JS
|
|
63
|
+
// behavior ("current == null -> return undefined") is preserved as "the field truly isn't
|
|
64
|
+
// there", not silently coerced to some other shape.
|
|
65
|
+
JsonNode root = objectMapper.valueToTree(resource);
|
|
66
|
+
JsonNode target = root.at(decoded.pointer());
|
|
67
|
+
if (target.isMissingNode()) {
|
|
68
|
+
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "pointer \"" + decoded.pointer() + "\" does not resolve on " + decoded.type());
|
|
69
|
+
}
|
|
70
|
+
return ResponseEntity.ok(target);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
@PatchMapping("/{handle}")
|
|
74
|
+
public ResponseEntity<Void> patch(@PathVariable String handle, @RequestBody Object value) {
|
|
75
|
+
HandleCodec.Decoded decoded = decodeOrThrow(handle);
|
|
76
|
+
// D-security-10: checks kind explicitly rather than inferring "field handle" from
|
|
77
|
+
// pointer-presence alone -- HandleCodec.decode() doesn't itself enforce that only kind=f
|
|
78
|
+
// carries a pointer (that's enforced on the encode side, see HandleCodec#encode), so a
|
|
79
|
+
// hand-crafted token of a reserved/future kind (e.g. kind=o) with a pointer appended must
|
|
80
|
+
// still be rejected here rather than treated as a field handle. Found by the Codex
|
|
81
|
+
// security review.
|
|
82
|
+
if (!decoded.kind().equals("f") || decoded.pointer() == null) {
|
|
83
|
+
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "cannot PATCH a resource-level handle (kind=r) -- only field handles (kind=f) support PATCH");
|
|
84
|
+
}
|
|
85
|
+
ResourceResolver resolver = resolverFor(decoded.type());
|
|
86
|
+
requireAuthority(resolver.requiredAuthority());
|
|
87
|
+
|
|
88
|
+
resolver.patchField(decoded.uuid(), decoded.pointer(), value);
|
|
89
|
+
return ResponseEntity.noContent().build();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
@GetMapping("/{handle}/recover")
|
|
93
|
+
public ResponseEntity<?> recover(@PathVariable String handle, @RequestParam(required = false) Instant at) {
|
|
94
|
+
HandleCodec.Decoded decoded = decodeOrThrow(handle);
|
|
95
|
+
ResourceResolver resolver = resolverFor(decoded.type());
|
|
96
|
+
requireAuthority(resolver.requiredAuthority());
|
|
97
|
+
|
|
98
|
+
UUID handleUid = HandleCodec.deriveHandleUid(decoded.kind(), decoded.type(), decoded.uuid(), decoded.pointer());
|
|
99
|
+
|
|
100
|
+
// D-security-9: handleUid alone does not prove WHAT is being recovered -- kind=r's
|
|
101
|
+
// derivation returns the resource UUID verbatim (no type binding baked into the hash), so
|
|
102
|
+
// an attacker who controls `type` in the handle token (as long as it names a real,
|
|
103
|
+
// registered resolver whose requiredAuthority() they can pass) can request the snapshot
|
|
104
|
+
// history of a DIFFERENT, more sensitive resource type that happens to share the same
|
|
105
|
+
// UUID. Found by the Codex security review. Fix: cross-check the decoded type/kind/pointer
|
|
106
|
+
// against the registry row this handleUid was actually registered under, and 404 on ANY
|
|
107
|
+
// disagreement -- without saying which field mismatched, so the error can't be used to
|
|
108
|
+
// probe which part was wrong. A revoked handle is rejected the same way; it must not be
|
|
109
|
+
// recoverable at all.
|
|
110
|
+
HandleRegistry registry = handleRegistryRepository.findById(handleUid)
|
|
111
|
+
.filter(r -> r.getResourceType().equals(decoded.type()))
|
|
112
|
+
.filter(r -> r.getKind().equals(decoded.kind()))
|
|
113
|
+
.filter(r -> Objects.equals(r.getPointer(), decoded.pointer()))
|
|
114
|
+
.filter(r -> !r.isRevoked())
|
|
115
|
+
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "no snapshot recorded for this handle"));
|
|
116
|
+
|
|
117
|
+
List<HandleSnapshot> snapshots = handleSnapshotRepository.findByHandleUidOrderByRecordedAtDesc(handleUid);
|
|
118
|
+
Optional<HandleSnapshot> match = at == null
|
|
119
|
+
? snapshots.stream().findFirst()
|
|
120
|
+
: snapshots.stream().filter(s -> !s.getRecordedAt().isAfter(at)).findFirst();
|
|
121
|
+
|
|
122
|
+
if (match.isEmpty()) {
|
|
123
|
+
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "no snapshot recorded for this handle" + (at != null ? " at or before " + at : ""));
|
|
124
|
+
}
|
|
125
|
+
HandleSnapshot snapshot = match.get();
|
|
126
|
+
// Reuses the registry row already fetched (and validated) above instead of querying again.
|
|
127
|
+
boolean drift = !registry.getContractRef().equals(snapshot.getContractHash());
|
|
128
|
+
|
|
129
|
+
// O4 (D-handle-lifecycle): snapshot.getPayload() is the raw JSON TEXT HandleService
|
|
130
|
+
// recorded (a Java String) -- embedding it into this response Map as-is would make
|
|
131
|
+
// Jackson serialize it as an ESCAPED JSON STRING VALUE, double-encoding a caller's real
|
|
132
|
+
// payload into something like "payload":"{\"name\":...}" instead of a normal nested
|
|
133
|
+
// object. Confirmed live (not assumed) by inspecting a real recover() response before
|
|
134
|
+
// this fix -- readTree parses it back into a JsonNode so it serializes as genuine nested
|
|
135
|
+
// JSON. A parse failure here means the stored payload isn't valid JSON at all, which only
|
|
136
|
+
// this app's own recordSnapshot() ever writes -- an internal invariant violation, not a
|
|
137
|
+
// caller-facing input error.
|
|
138
|
+
JsonNode payload;
|
|
139
|
+
try {
|
|
140
|
+
payload = objectMapper.readTree(snapshot.getPayload());
|
|
141
|
+
} catch (Exception e) {
|
|
142
|
+
throw new IllegalStateException("stored handle snapshot payload is not valid JSON", e);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return ResponseEntity.ok(Map.of(
|
|
146
|
+
"handle", handle,
|
|
147
|
+
"recorded_at", snapshot.getRecordedAt(),
|
|
148
|
+
"operation_id", snapshot.getOperationId(),
|
|
149
|
+
"payload", payload,
|
|
150
|
+
"schema_drift", drift
|
|
151
|
+
));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private HandleCodec.Decoded decodeOrThrow(String handle) {
|
|
155
|
+
try {
|
|
156
|
+
return HandleCodec.decode(handle);
|
|
157
|
+
} catch (IllegalArgumentException e) {
|
|
158
|
+
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage());
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private ResourceResolver resolverFor(String type) {
|
|
163
|
+
return resolversByBeanName.values().stream()
|
|
164
|
+
.filter(r -> r.type().equals(type))
|
|
165
|
+
.findFirst()
|
|
166
|
+
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "no resolver registered for handle type \"" + type + "\""));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private void requireAuthority(String requiredRole) {
|
|
170
|
+
boolean granted = SecurityContextHolder.getContext().getAuthentication().getAuthorities().stream()
|
|
171
|
+
.map(GrantedAuthority::getAuthority)
|
|
172
|
+
.anyMatch(authority -> authority.equals("ROLE_" + requiredRole));
|
|
173
|
+
if (!granted) {
|
|
174
|
+
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "requires role " + requiredRole);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
package {{BASE_PACKAGE}}.global.handle;
|
|
2
|
+
|
|
3
|
+
import jakarta.persistence.Column;
|
|
4
|
+
import jakarta.persistence.Entity;
|
|
5
|
+
import jakarta.persistence.Id;
|
|
6
|
+
import jakarta.persistence.Table;
|
|
7
|
+
import lombok.AccessLevel;
|
|
8
|
+
import lombok.Getter;
|
|
9
|
+
import lombok.NoArgsConstructor;
|
|
10
|
+
import org.hibernate.annotations.CreationTimestamp;
|
|
11
|
+
|
|
12
|
+
import java.time.Instant;
|
|
13
|
+
import java.util.UUID;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Registry row for one addressable handle -- maps a derived {@code handle_uid} (see
|
|
17
|
+
* {@link HandleCodec#deriveHandleUid}) back to the resource/field it addresses, so a handle can
|
|
18
|
+
* be looked up in the reverse direction without re-deriving it from a token.
|
|
19
|
+
*
|
|
20
|
+
* <p>Generated by backend-skeleton ({@code bskel handles emit}). The corresponding migration
|
|
21
|
+
* (see the emitted {@code migration.sql}) is NOT applied automatically -- this repo has no
|
|
22
|
+
* Flyway/Liquibase, so apply it yourself against Supabase, and add a JPA {@code @Table}
|
|
23
|
+
* mapping only once that migration has actually run.
|
|
24
|
+
*/
|
|
25
|
+
@Getter
|
|
26
|
+
@Entity
|
|
27
|
+
@Table(name = "sbf_handle")
|
|
28
|
+
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
|
29
|
+
public class HandleRegistry {
|
|
30
|
+
|
|
31
|
+
@Id
|
|
32
|
+
@Column(name = "handle_uid", updatable = false, nullable = false)
|
|
33
|
+
private UUID handleUid;
|
|
34
|
+
|
|
35
|
+
/** {@code r} (resource), {@code f} (field), or {@code o} (operation instance -- reserved, unused). */
|
|
36
|
+
@Column(name = "kind", nullable = false, length = 1)
|
|
37
|
+
private String kind;
|
|
38
|
+
|
|
39
|
+
@Column(name = "resource_type", nullable = false, length = 100)
|
|
40
|
+
private String resourceType;
|
|
41
|
+
|
|
42
|
+
@Column(name = "resource_uid", nullable = false)
|
|
43
|
+
private UUID resourceUid;
|
|
44
|
+
|
|
45
|
+
/** RFC 6901 JSON Pointer, null for {@code kind = r}. */
|
|
46
|
+
@Column(name = "pointer")
|
|
47
|
+
private String pointer;
|
|
48
|
+
|
|
49
|
+
@Column(name = "feature_uid", nullable = false)
|
|
50
|
+
private UUID featureUid;
|
|
51
|
+
|
|
52
|
+
@Column(name = "operation_id", length = 100)
|
|
53
|
+
private String operationId;
|
|
54
|
+
|
|
55
|
+
@Column(name = "contract_ref", nullable = false)
|
|
56
|
+
private String contractRef;
|
|
57
|
+
|
|
58
|
+
@CreationTimestamp
|
|
59
|
+
@Column(name = "created_at", nullable = false, updatable = false)
|
|
60
|
+
private Instant createdAt;
|
|
61
|
+
|
|
62
|
+
@Column(name = "revoked_at")
|
|
63
|
+
private Instant revokedAt;
|
|
64
|
+
|
|
65
|
+
@Column(name = "revoked_reason")
|
|
66
|
+
private String revokedReason;
|
|
67
|
+
|
|
68
|
+
public static HandleRegistry create(
|
|
69
|
+
UUID handleUid, String kind, String resourceType, UUID resourceUid,
|
|
70
|
+
String pointer, UUID featureUid, String operationId, String contractRef
|
|
71
|
+
) {
|
|
72
|
+
HandleRegistry registry = new HandleRegistry();
|
|
73
|
+
registry.handleUid = handleUid;
|
|
74
|
+
registry.kind = kind;
|
|
75
|
+
registry.resourceType = resourceType;
|
|
76
|
+
registry.resourceUid = resourceUid;
|
|
77
|
+
registry.pointer = pointer;
|
|
78
|
+
registry.featureUid = featureUid;
|
|
79
|
+
registry.operationId = operationId;
|
|
80
|
+
registry.contractRef = contractRef;
|
|
81
|
+
return registry;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
public boolean isRevoked() {
|
|
85
|
+
return revokedAt != null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* O4 (D-handle-lifecycle): {@code handle_uid} is DERIVED from {@code kind}/{@code
|
|
90
|
+
* resourceType}/{@code resourceUid}/{@code pointer} (see {@link HandleCodec#deriveHandleUid})
|
|
91
|
+
* -- those four fields can never legitimately differ for an existing row with this same id,
|
|
92
|
+
* so {@link HandleService#register} only ever needs to refresh the three fields that CAN
|
|
93
|
+
* change across separate registrations of the same resource/pointer: which feature most
|
|
94
|
+
* recently touched it, which operation, and its current contract hash. Deliberately does NOT
|
|
95
|
+
* touch {@code revokedAt} -- re-registering a revoked handle must never silently un-revoke it.
|
|
96
|
+
*/
|
|
97
|
+
void refresh(UUID featureUid, String operationId, String contractRef) {
|
|
98
|
+
this.featureUid = featureUid;
|
|
99
|
+
this.operationId = operationId;
|
|
100
|
+
this.contractRef = contractRef;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
void revoke(String reason) {
|
|
104
|
+
this.revokedAt = Instant.now();
|
|
105
|
+
this.revokedReason = reason;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
package {{BASE_PACKAGE}}.global.handle;
|
|
2
|
+
|
|
3
|
+
import {{JACKSON_PACKAGE}}.ObjectMapper;
|
|
4
|
+
import lombok.RequiredArgsConstructor;
|
|
5
|
+
import org.springframework.stereotype.Service;
|
|
6
|
+
import org.springframework.transaction.annotation.Transactional;
|
|
7
|
+
|
|
8
|
+
import java.time.Instant;
|
|
9
|
+
import java.util.UUID;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* O4 (D-handle-lifecycle): the explicit API that makes {@code HandleController.recover()}
|
|
13
|
+
* actually reachable -- before this existed, nothing anywhere ever called {@link
|
|
14
|
+
* HandleRegistry#create}/{@link HandleSnapshot#create}, so a registry lookup could never find a
|
|
15
|
+
* row. Deliberately NEVER auto-invoked by anything generated elsewhere and never wired into any
|
|
16
|
+
* EXISTING business logic class -- call it explicitly from your own service code at the point a
|
|
17
|
+
* resource is actually created/updated, or apply {@code @RecordHandleSnapshot} to an existing
|
|
18
|
+
* service method to have {@link HandleAspect} call it for you (see that annotation's own javadoc
|
|
19
|
+
* for the opt-in, explicit-parameter-index design and why nothing here ever guesses).
|
|
20
|
+
*
|
|
21
|
+
* <p>Generated by backend-skeleton ({@code bskel handles emit}).
|
|
22
|
+
*/
|
|
23
|
+
@Service
|
|
24
|
+
@RequiredArgsConstructor
|
|
25
|
+
public class HandleService {
|
|
26
|
+
|
|
27
|
+
private final HandleRegistryRepository handleRegistryRepository;
|
|
28
|
+
private final HandleSnapshotRepository handleSnapshotRepository;
|
|
29
|
+
private final ObjectMapper objectMapper;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Derives {@code handle_uid} ({@link HandleCodec#deriveHandleUid}) and UPSERTS the registry
|
|
33
|
+
* row -- the schema's own {@code unique (resource_type, resource_uid, pointer)} constraint
|
|
34
|
+
* (see the emitted {@code migration.sql}) means the SAME {@code (kind, type, resourceUid,
|
|
35
|
+
* pointer)} triple always derives the SAME {@code handle_uid}, so re-registering it is
|
|
36
|
+
* expected, not an error: an existing row has its {@code featureUid}/{@code operationId}/
|
|
37
|
+
* {@code contractRef} refreshed (see {@link HandleRegistry#refresh}) rather than failing a
|
|
38
|
+
* unique-constraint check. Returns the encoded handle token ({@link HandleCodec#encode},
|
|
39
|
+
* already implemented -- "minting" a token was never the missing piece, persisting the row
|
|
40
|
+
* backing it was).
|
|
41
|
+
*/
|
|
42
|
+
@Transactional
|
|
43
|
+
public String register(String kind, String type, UUID resourceUid, String pointer, UUID featureUid, String operationId, String contractRef) {
|
|
44
|
+
String token = HandleCodec.encode(kind, type, resourceUid, pointer);
|
|
45
|
+
UUID handleUid = HandleCodec.deriveHandleUid(kind, type, resourceUid, pointer);
|
|
46
|
+
HandleRegistry existing = handleRegistryRepository.findById(handleUid).orElse(null);
|
|
47
|
+
if (existing == null) {
|
|
48
|
+
handleRegistryRepository.save(HandleRegistry.create(handleUid, kind, type, resourceUid, pointer, featureUid, operationId, contractRef));
|
|
49
|
+
} else {
|
|
50
|
+
existing.refresh(featureUid, operationId, contractRef);
|
|
51
|
+
}
|
|
52
|
+
return token;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Records one envelope for an already-registered handle. {@code payload} is serialized
|
|
57
|
+
* through the same {@link ObjectMapper} the rest of the app uses -- callers pass a real
|
|
58
|
+
* object (a DTO, a request body, an error shape), never a pre-serialized string, so this is
|
|
59
|
+
* the one place that decides how it becomes the JSON text {@code sbf_handle_snapshot.payload}
|
|
60
|
+
* stores.
|
|
61
|
+
*/
|
|
62
|
+
@Transactional
|
|
63
|
+
public void recordSnapshot(UUID handleUid, String envelopeDir, String operationId, String contractHash, Object payload) {
|
|
64
|
+
// A generic `catch (Exception e)` (not the Jackson-2-specific checked
|
|
65
|
+
// JsonProcessingException, nor assuming Jackson 3's unchecked JacksonException) compiles
|
|
66
|
+
// correctly against EITHER Jackson major version {{JACKSON_PACKAGE}} might have resolved
|
|
67
|
+
// to at `bskel handles emit` time -- this template must compile whichever one a real
|
|
68
|
+
// target repo is actually on, and the two versions disagree on whether this throws a
|
|
69
|
+
// checked or unchecked exception.
|
|
70
|
+
String json;
|
|
71
|
+
try {
|
|
72
|
+
json = objectMapper.writeValueAsString(payload);
|
|
73
|
+
} catch (Exception e) {
|
|
74
|
+
throw new IllegalStateException("could not serialize handle snapshot payload", e);
|
|
75
|
+
}
|
|
76
|
+
handleSnapshotRepository.save(HandleSnapshot.create(handleUid, envelopeDir, operationId, contractHash, json));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
@Transactional
|
|
80
|
+
public void revoke(UUID handleUid, String reason) {
|
|
81
|
+
handleRegistryRepository.findById(handleUid).ifPresent(registry -> registry.revoke(reason));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* O4 (D-handle-lifecycle): retention is EXPOSED, not auto-scheduled -- nothing generated here
|
|
86
|
+
* wires a {@code @Scheduled} job to call this automatically. Deciding how long snapshots
|
|
87
|
+
* should live, and whether a background job is even appropriate for this application, is left
|
|
88
|
+
* to a human -- the same boundary {@code D-migration-scope} already draws around never
|
|
89
|
+
* applying the emitted {@code migration.sql} on its own.
|
|
90
|
+
*/
|
|
91
|
+
@Transactional
|
|
92
|
+
public long pruneSnapshotsOlderThan(Instant cutoff) {
|
|
93
|
+
return handleSnapshotRepository.deleteByRecordedAtBefore(cutoff);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
package {{BASE_PACKAGE}}.global.handle;
|
|
2
|
+
|
|
3
|
+
import jakarta.persistence.Column;
|
|
4
|
+
import jakarta.persistence.Entity;
|
|
5
|
+
import jakarta.persistence.GeneratedValue;
|
|
6
|
+
import jakarta.persistence.GenerationType;
|
|
7
|
+
import jakarta.persistence.Id;
|
|
8
|
+
import jakarta.persistence.Table;
|
|
9
|
+
import lombok.AccessLevel;
|
|
10
|
+
import lombok.Getter;
|
|
11
|
+
import lombok.NoArgsConstructor;
|
|
12
|
+
import org.hibernate.annotations.CreationTimestamp;
|
|
13
|
+
import org.hibernate.annotations.JdbcTypeCode;
|
|
14
|
+
import org.hibernate.type.SqlTypes;
|
|
15
|
+
|
|
16
|
+
import java.time.Instant;
|
|
17
|
+
import java.util.UUID;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* One recorded envelope for a handle -- what {@code bskel handles emit}'s {@code recover} verb
|
|
21
|
+
* reads from. Rows are written explicitly via {@link HandleService#recordSnapshot}, either called
|
|
22
|
+
* directly or automatically by {@link HandleAspect} for methods annotated
|
|
23
|
+
* {@code @RecordHandleSnapshot} (O4, D-handle-lifecycle) -- nothing here writes on its own.
|
|
24
|
+
*
|
|
25
|
+
* <p>Generated by backend-skeleton ({@code bskel handles emit}). Migration NOT applied
|
|
26
|
+
* automatically -- see the note on {@link HandleRegistry}.
|
|
27
|
+
*/
|
|
28
|
+
@Getter
|
|
29
|
+
@Entity
|
|
30
|
+
@Table(name = "sbf_handle_snapshot")
|
|
31
|
+
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
|
32
|
+
public class HandleSnapshot {
|
|
33
|
+
|
|
34
|
+
@Id
|
|
35
|
+
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
|
36
|
+
@Column(name = "snapshot_id", updatable = false, nullable = false)
|
|
37
|
+
private Long snapshotId;
|
|
38
|
+
|
|
39
|
+
@Column(name = "handle_uid", nullable = false)
|
|
40
|
+
private UUID handleUid;
|
|
41
|
+
|
|
42
|
+
@Column(name = "envelope_dir", nullable = false, length = 20)
|
|
43
|
+
private String envelopeDir;
|
|
44
|
+
|
|
45
|
+
@Column(name = "operation_id", nullable = false, length = 100)
|
|
46
|
+
private String operationId;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Hash of the feature contract this snapshot was recorded against. {@code recover} compares
|
|
50
|
+
* this to the CURRENT contract's hash -- a mismatch means the contract has since changed
|
|
51
|
+
* shape, and {@code recover} must say so explicitly (a {@code schema_drift} marker) rather
|
|
52
|
+
* than silently returning a payload the current contract no longer describes.
|
|
53
|
+
*/
|
|
54
|
+
@Column(name = "contract_hash", nullable = false, length = 64)
|
|
55
|
+
private String contractHash;
|
|
56
|
+
|
|
57
|
+
/** JSON payload, stored as text (same convention as {@code ReportSnapshot.summaryPayload}) -- Hibernate handles JSON<->text at the JDBC layer via {@code SqlTypes.JSON}. */
|
|
58
|
+
@JdbcTypeCode(SqlTypes.JSON)
|
|
59
|
+
@Column(name = "payload", nullable = false)
|
|
60
|
+
private String payload;
|
|
61
|
+
|
|
62
|
+
@CreationTimestamp
|
|
63
|
+
@Column(name = "recorded_at", nullable = false, updatable = false)
|
|
64
|
+
private Instant recordedAt;
|
|
65
|
+
|
|
66
|
+
public static HandleSnapshot create(UUID handleUid, String envelopeDir, String operationId, String contractHash, String payload) {
|
|
67
|
+
HandleSnapshot snapshot = new HandleSnapshot();
|
|
68
|
+
snapshot.handleUid = handleUid;
|
|
69
|
+
snapshot.envelopeDir = envelopeDir;
|
|
70
|
+
snapshot.operationId = operationId;
|
|
71
|
+
snapshot.contractHash = contractHash;
|
|
72
|
+
snapshot.payload = payload;
|
|
73
|
+
return snapshot;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
package {{BASE_PACKAGE}}.global.handle;
|
|
2
|
+
|
|
3
|
+
import org.springframework.data.jpa.repository.JpaRepository;
|
|
4
|
+
|
|
5
|
+
import java.time.Instant;
|
|
6
|
+
import java.util.List;
|
|
7
|
+
import java.util.UUID;
|
|
8
|
+
|
|
9
|
+
public interface HandleSnapshotRepository extends JpaRepository<HandleSnapshot, Long> {
|
|
10
|
+
|
|
11
|
+
/** Most recent snapshot first -- `recover(handle)` without a timestamp wants index 0 of this. */
|
|
12
|
+
List<HandleSnapshot> findByHandleUidOrderByRecordedAtDesc(UUID handleUid);
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* O4 (D-handle-lifecycle): backs {@link HandleService#pruneSnapshotsOlderThan} -- retention is
|
|
16
|
+
* exposed as a callable method, but nothing generated here schedules it automatically (see
|
|
17
|
+
* HandleService's own javadoc for why).
|
|
18
|
+
*/
|
|
19
|
+
long deleteByRecordedAtBefore(Instant cutoff);
|
|
20
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
package {{BASE_PACKAGE}}.global.handle;
|
|
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
|
+
* O4 (D-handle-lifecycle): apply this to an EXISTING service method (never generated onto one --
|
|
10
|
+
* a human decides which methods are worth handle-tracking) to have {@link HandleAspect}
|
|
11
|
+
* automatically {@link HandleService#register register} the resource-level handle and {@link
|
|
12
|
+
* HandleService#recordSnapshot record} the request/response/error envelope around every call.
|
|
13
|
+
*
|
|
14
|
+
* <p>{@code resourceUidParam} is an explicit parameter INDEX, not inferred from the method
|
|
15
|
+
* signature -- the same "never guess, always explicit" boundary {@code D-resolver-scope}/
|
|
16
|
+
* {@code D-security-8} already draw for this codebase's generated code: a wrong guess here would
|
|
17
|
+
* silently attribute a snapshot to the wrong resource, which recording nothing at all would never
|
|
18
|
+
* do.
|
|
19
|
+
*
|
|
20
|
+
* <p>{@code redact} is an explicit array of JSON Pointers into the recorded envelope to blank
|
|
21
|
+
* before storage -- deliberately NOT a guessed heuristic (e.g. "any field named like a secret").
|
|
22
|
+
* An empty array (the default) means nothing is redacted; if the annotated method's payload
|
|
23
|
+
* carries anything sensitive, the human applying this annotation must name it.
|
|
24
|
+
*
|
|
25
|
+
* <p>Example:
|
|
26
|
+
* <pre>{@code
|
|
27
|
+
* @RecordHandleSnapshot(resourceType = "Organization", operationId = "updateOrganization", resourceUidParam = 0, redact = {"/internalNote"})
|
|
28
|
+
* public OrganizationResponse updateOrganization(UUID organizationId, UpdateOrganizationRequest request, UUID requesterId) { ... }
|
|
29
|
+
* }</pre>
|
|
30
|
+
*
|
|
31
|
+
* <p>Generated by backend-skeleton ({@code bskel handles emit}). Requires {@code
|
|
32
|
+
* spring-boot-starter-aop} on the target repo's own classpath -- NOT added automatically (see
|
|
33
|
+
* {@code handles emit}'s own post-emit notes), matching this project's established boundary of
|
|
34
|
+
* never auto-editing a target's build file.
|
|
35
|
+
*/
|
|
36
|
+
@Retention(RetentionPolicy.RUNTIME)
|
|
37
|
+
@Target(ElementType.METHOD)
|
|
38
|
+
public @interface RecordHandleSnapshot {
|
|
39
|
+
|
|
40
|
+
/** Must match a registered {@link ResourceResolver#type()}. */
|
|
41
|
+
String resourceType();
|
|
42
|
+
|
|
43
|
+
String operationId();
|
|
44
|
+
|
|
45
|
+
/** Zero-based index into the annotated method's own parameter list -- the resource's UUID. */
|
|
46
|
+
int resourceUidParam();
|
|
47
|
+
|
|
48
|
+
/** JSON Pointers (RFC 6901) into the recorded envelope to blank before storage. Empty by default -- nothing redacted unless explicitly named. */
|
|
49
|
+
String[] redact() default {};
|
|
50
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
package {{BASE_PACKAGE}}.global.handle;
|
|
2
|
+
|
|
3
|
+
import java.util.UUID;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* One implementation per resource type, registered as a {@code @Component} so Spring collects
|
|
7
|
+
* them into a {@code Map<String, ResourceResolver>} (bean name -> resolver) that
|
|
8
|
+
* {@link HandleController} dispatches through by decoded handle {@code type} -- no reflection,
|
|
9
|
+
* an unknown type is just a missing map key.
|
|
10
|
+
*
|
|
11
|
+
* <p>{@code fetch}/{@code patchField} return/accept plain {@code Object} (typically the
|
|
12
|
+
* resource's existing response DTO) rather than a fixed type, since resolvers are registered
|
|
13
|
+
* generically -- {@link HandleController#fetch} serializes whatever {@code fetch} returns
|
|
14
|
+
* through Jackson and walks a JSON Pointer with {@code JsonNode#at} for field-level (kind=f)
|
|
15
|
+
* addressing.
|
|
16
|
+
*/
|
|
17
|
+
public interface ResourceResolver {
|
|
18
|
+
|
|
19
|
+
/** Must match the {@code type} segment used when this resource's handles were minted (e.g. "Organization"). */
|
|
20
|
+
String type();
|
|
21
|
+
|
|
22
|
+
/** Fetches the whole resource, serialized the same shape its normal API response uses. */
|
|
23
|
+
Object fetch(UUID resourceUid);
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Applies a single field-level patch. Implementations route through the resource's EXISTING
|
|
27
|
+
* update path (its real service method + DTO) rather than raw persistence access, so
|
|
28
|
+
* existing validation/business rules keep applying -- see the generated resolver stub for
|
|
29
|
+
* this feature's target module for the specific pattern to follow (some DTOs use {@code
|
|
30
|
+
* PatchField<T>} for null-has-meaning fields, most just treat null-vs-absent as "unchanged",
|
|
31
|
+
* and some require re-submitting the whole object; check the target DTO before assuming).
|
|
32
|
+
*/
|
|
33
|
+
void patchField(UUID resourceUid, String pointer, Object value);
|
|
34
|
+
|
|
35
|
+
/** Spring Security role name (no "ROLE_" prefix) required to fetch/patch this resource type via a handle. */
|
|
36
|
+
String requiredAuthority();
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* O4 (D-handle-lifecycle): the content hash of the feature contract this resolver was
|
|
40
|
+
* generated from -- baked in at {@code bskel handles emit} time (regenerated every run, so a
|
|
41
|
+
* contract change is picked up automatically), never read from disk at runtime (a deployed
|
|
42
|
+
* app has no access to {@code specs/} at all). {@link HandleService#register} stores this as
|
|
43
|
+
* a registry row's {@code contract_ref}; {@code recover}'s {@code schema_drift} check compares
|
|
44
|
+
* a snapshot's own recorded hash against the CURRENT value of this method, not a stale one.
|
|
45
|
+
*/
|
|
46
|
+
String contractRef();
|
|
47
|
+
|
|
48
|
+
/** O4 (D-handle-lifecycle): the feature_uid this resolver was generated for, baked in the same way as {@link #contractRef()}. */
|
|
49
|
+
UUID featureUid();
|
|
50
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
package {{BASE_PACKAGE}}.domain.{{MODULE}}.infrastructure;
|
|
2
|
+
|
|
3
|
+
import {{BASE_PACKAGE}}.global.handle.ResourceResolver;
|
|
4
|
+
import {{SERVICE_IMPORT}};
|
|
5
|
+
{{PATCH_IMPORTS}}import lombok.RequiredArgsConstructor;
|
|
6
|
+
import org.springframework.stereotype.Component;
|
|
7
|
+
|
|
8
|
+
import java.util.UUID;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Generated by backend-skeleton ({@code bskel handles emit}) for feature {{FEATURE_ID}}.
|
|
12
|
+
*
|
|
13
|
+
* <p>{@code fetch} is wired to the real, existing read path ({@link {{SERVICE_TYPE}}#{{FETCH_METHOD}}})
|
|
14
|
+
* -- safe to trust as-is, it's a read-only call into code that already exists and is tested.
|
|
15
|
+
*
|
|
16
|
+
* <p>{@code patchField} is generated PER FIELD, not as one blanket stub (see DECISIONS.md
|
|
17
|
+
* D-patch-strategy, and D-resolver-scope for why a wrong guess here is worse than an honest
|
|
18
|
+
* stub). Each field of the update DTO is classified into one of four partial-update conventions
|
|
19
|
+
* found in this codebase's real DTOs:
|
|
20
|
+
* <ol>
|
|
21
|
+
* <li>{@code patch-wrapper} -- the field uses {@code PatchField<T>} because null itself is
|
|
22
|
+
* meaningful (distinguishes "don't touch" from "set to null").</li>
|
|
23
|
+
* <li>{@code null-means-unchanged} -- a plain nullable field where null/absent both mean
|
|
24
|
+
* "leave unchanged".</li>
|
|
25
|
+
* <li>{@code fetch-merge-submit} -- the field is required ({@code @NotNull} or a primitive)
|
|
26
|
+
* even in this otherwise-partial DTO, so patching it safely means fetching the current
|
|
27
|
+
* resource first and resubmitting the full request with only this field changed.</li>
|
|
28
|
+
* <li>{@code unsupported} -- a collection, nested {@code @Valid} object, or array field, not
|
|
29
|
+
* safely expressible as a single scalar patch.</li>
|
|
30
|
+
* </ol>
|
|
31
|
+
* Only {@code patch-wrapper}/{@code null-means-unchanged} fields are ever auto-generated, and
|
|
32
|
+
* only once explicitly approved via {@code bskel handles patch approve} -- every other case
|
|
33
|
+
* below explains, per field, exactly why it isn't (or isn't yet).
|
|
34
|
+
*/
|
|
35
|
+
@Component
|
|
36
|
+
@RequiredArgsConstructor
|
|
37
|
+
public class {{RESOURCE_TYPE}}Resolver implements ResourceResolver {
|
|
38
|
+
|
|
39
|
+
// O4 (D-handle-lifecycle): baked in at `bskel handles emit` time -- CONTRACT_REF is the
|
|
40
|
+
// content hash of specs/{{FEATURE_ID}}/contracts/{{FEATURE_ID}}.schema.json as of this run,
|
|
41
|
+
// regenerated fresh every time (never read from disk at runtime, a deployed app has no
|
|
42
|
+
// access to specs/ at all).
|
|
43
|
+
private static final String CONTRACT_REF = "{{CONTRACT_REF}}";
|
|
44
|
+
private static final UUID FEATURE_UID = UUID.fromString("{{FEATURE_UID}}");
|
|
45
|
+
|
|
46
|
+
private final {{SERVICE_TYPE}} {{SERVICE_FIELD}};
|
|
47
|
+
{{PATCH_FIELDS}}
|
|
48
|
+
@Override
|
|
49
|
+
public String type() {
|
|
50
|
+
return "{{RESOURCE_TYPE}}";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
@Override
|
|
54
|
+
public Object fetch(UUID resourceUid) {
|
|
55
|
+
return {{SERVICE_FIELD}}.{{FETCH_METHOD}}(resourceUid);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
@Override
|
|
59
|
+
public void patchField(UUID resourceUid, String pointer, Object value) {
|
|
60
|
+
{{PATCH_FIELD_BODY}}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
@Override
|
|
64
|
+
public String requiredAuthority() {
|
|
65
|
+
return "{{REQUIRED_AUTHORITY}}";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
@Override
|
|
69
|
+
public String contractRef() {
|
|
70
|
+
return CONTRACT_REF;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
@Override
|
|
74
|
+
public UUID featureUid() {
|
|
75
|
+
return FEATURE_UID;
|
|
76
|
+
}
|
|
77
|
+
}
|