254 lines
7.5 KiB
Markdown
254 lines
7.5 KiB
Markdown
|
|
# Feedback for Apophis Team: Cross-Route Relationships and Hypermedia Validation
|
||
|
|
|
||
|
|
**From:** Arbiter Team (Multi-tenant identity platform with LDF+Action hypermedia architecture)
|
||
|
|
**Date:** 2026-04-26
|
||
|
|
**Status:** ✅ **IMPLEMENTED in v2.1** — All P0/P1 features complete
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 1. Executive Summary
|
||
|
|
|
||
|
|
**The Gap (v2.0):** Apophis validated routes as independent entities. Real-world APIs have relationships:
|
||
|
|
- **Parent-child**: Tenant owns Applications, Application owns Users
|
||
|
|
- **Hypermedia links**: Resources expose `controls` with URLs to related resources
|
||
|
|
- **Cascade behavior**: Deleting a parent should make children inaccessible
|
||
|
|
- **Path correlation**: Child routes use parent IDs from path parameters
|
||
|
|
|
||
|
|
**The Solution (v2.1):** All cross-route validation is now expressed through APOSTL formulas using extension predicates. No imperative APIs or special endpoints.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 2. What Was Implemented
|
||
|
|
|
||
|
|
### 2.1 Extension Predicate: `route_exists()` ✅
|
||
|
|
|
||
|
|
Check that hypermedia links resolve to registered routes:
|
||
|
|
|
||
|
|
```apostl
|
||
|
|
'route_exists(this).controls.self.href == true'
|
||
|
|
'route_exists(this).controls.tenant.href == true'
|
||
|
|
'route_exists(this).controls.applications.href == true'
|
||
|
|
```
|
||
|
|
|
||
|
|
**File**: `src/extensions/relationships.ts`
|
||
|
|
**Tests**: `src/test/relationships.test.ts`, `src/test/cross-operation-support.test.ts`
|
||
|
|
|
||
|
|
### 2.2 Extension Predicate: `relationship_valid()` ✅
|
||
|
|
|
||
|
|
Validate parent-child consistency:
|
||
|
|
|
||
|
|
```apostl
|
||
|
|
'relationship_valid("parent", request_params(this).tenantId, response_body(this).tenantId) == true'
|
||
|
|
```
|
||
|
|
|
||
|
|
**File**: `src/extensions/relationships.ts`
|
||
|
|
**Tests**: `src/test/relationships.test.ts`
|
||
|
|
|
||
|
|
### 2.3 Extension Predicate: `cascade_valid()` ✅
|
||
|
|
|
||
|
|
Verify cascade after DELETE:
|
||
|
|
|
||
|
|
```apostl
|
||
|
|
'cascade_valid("tenant", request_params(this).id, ["application", "user"]) == true'
|
||
|
|
```
|
||
|
|
|
||
|
|
**File**: `src/extensions/relationships.ts`
|
||
|
|
**Tests**: `src/test/relationships.test.ts`
|
||
|
|
|
||
|
|
### 2.4 Automatic Path Substitution in Stateful Tests ✅
|
||
|
|
|
||
|
|
When generating commands for routes with path params (e.g., `:tenantId`):
|
||
|
|
- Checks if resource type `tenant` exists in state
|
||
|
|
- If yes, substitutes with a known ID from state
|
||
|
|
- If no, falls back to arbitrary generation
|
||
|
|
|
||
|
|
**File**: `src/domain/request-builder.ts` (enhanced `substitutePathParams()`)
|
||
|
|
**Tests**: `src/test/stateful-runner.test.ts`
|
||
|
|
|
||
|
|
### 2.5 Cascade Validator ✅
|
||
|
|
|
||
|
|
After DELETE commands, automatically discovers child routes and verifies they return 404:
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
const validator = createCascadeValidator(routes)
|
||
|
|
const report = await validator.validateAfterDelete(
|
||
|
|
'/tenants/tenant:acme',
|
||
|
|
{ id: 'tenant:acme' },
|
||
|
|
{ maxDepth: 2 }
|
||
|
|
)
|
||
|
|
```
|
||
|
|
|
||
|
|
**File**: `src/test/cascade-validator.ts`
|
||
|
|
**Tests**: `src/test/cascade-validator.test.ts`
|
||
|
|
|
||
|
|
### 2.6 Hypermedia Link Extraction ✅
|
||
|
|
|
||
|
|
Utility for extracting links from response bodies (controls, _links, links array):
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
const links = extractLinks(response.body, 'GET /users/:id')
|
||
|
|
// Returns: [{ route: 'GET /users/:id', control: 'self', href: '/users/123' }, ...]
|
||
|
|
```
|
||
|
|
|
||
|
|
**File**: `src/test/hypermedia-validator.ts`
|
||
|
|
**Tests**: `src/test/hypermedia-validator.test.ts`
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 3. Design Philosophy: APOSTL-First
|
||
|
|
|
||
|
|
**We rejected the imperative API approach.** Instead of:
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// ❌ WRONG: Imperative API
|
||
|
|
const report = await fastify.apophis.validateHypermedia({
|
||
|
|
checkLinks: true,
|
||
|
|
checkDescriptors: true
|
||
|
|
})
|
||
|
|
```
|
||
|
|
|
||
|
|
We use declarative APOSTL contracts:
|
||
|
|
|
||
|
|
```apostl
|
||
|
|
// ✅ CORRECT: Declarative contracts
|
||
|
|
'route_exists(this).controls.self.href == true'
|
||
|
|
'route_exists(this).controls.tenant.href == true'
|
||
|
|
```
|
||
|
|
|
||
|
|
**Why?**
|
||
|
|
- Contracts are evaluated during all test phases (petit, stateful, runtime)
|
||
|
|
- No special endpoints or hooks needed
|
||
|
|
- Consistent with APOPHIS's design philosophy
|
||
|
|
- Self-documenting in route schemas
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 4. Usage Examples
|
||
|
|
|
||
|
|
### 4.1 Hypermedia Controls
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
fastify.get('/tenants/:id', {
|
||
|
|
schema: {
|
||
|
|
'x-category': 'observer',
|
||
|
|
'x-ensures': [
|
||
|
|
'route_exists(this).controls.self.href == true',
|
||
|
|
'route_exists(this).controls.applications.href == true',
|
||
|
|
],
|
||
|
|
response: {
|
||
|
|
200: {
|
||
|
|
type: 'object',
|
||
|
|
properties: {
|
||
|
|
id: { type: 'string' },
|
||
|
|
controls: {
|
||
|
|
type: 'object',
|
||
|
|
properties: {
|
||
|
|
self: { type: 'object', properties: { href: { type: 'string' } } },
|
||
|
|
applications: { type: 'object', properties: { href: { type: 'string' } } },
|
||
|
|
},
|
||
|
|
},
|
||
|
|
},
|
||
|
|
},
|
||
|
|
},
|
||
|
|
},
|
||
|
|
})
|
||
|
|
```
|
||
|
|
|
||
|
|
### 4.2 Parent-Child Validation
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
fastify.post('/tenants/:tenantId/applications', {
|
||
|
|
schema: {
|
||
|
|
'x-category': 'constructor',
|
||
|
|
'x-ensures': [
|
||
|
|
'response_body(this).tenantId == request_params(this).tenantId',
|
||
|
|
'response_code(GET /tenants/{request_params(this).tenantId}/applications/{response_body(this).id}) == 200',
|
||
|
|
],
|
||
|
|
},
|
||
|
|
})
|
||
|
|
```
|
||
|
|
|
||
|
|
### 4.3 Cascade Validation
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
fastify.delete('/tenants/:id', {
|
||
|
|
schema: {
|
||
|
|
'x-category': 'destructor',
|
||
|
|
'x-ensures': [
|
||
|
|
'cascade_valid("tenant", request_params(this).id, ["application", "user"]) == true',
|
||
|
|
],
|
||
|
|
},
|
||
|
|
})
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 5. Test Results
|
||
|
|
|
||
|
|
| Feature | Tests | Status |
|
||
|
|
|---------|-------|--------|
|
||
|
|
| `route_exists()` predicate | 5 tests | ✅ Passing |
|
||
|
|
| `relationship_valid()` predicate | 2 tests | ✅ Passing |
|
||
|
|
| `cascade_valid()` predicate | 2 tests | ✅ Passing |
|
||
|
|
| Path substitution | 1 test | ✅ Passing |
|
||
|
|
| Cascade validator | 6 tests | ✅ Passing |
|
||
|
|
| Hypermedia extraction | 9 tests | ✅ Passing |
|
||
|
|
| **Total** | **487 tests** | **✅ All passing** |
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 6. What We Learned
|
||
|
|
|
||
|
|
### 6.1 APOSTL is Sufficient
|
||
|
|
|
||
|
|
We initially proposed imperative APIs (`validateHypermedia()`, `x-relationships` annotations). Through implementation, we discovered that APOSTL predicates are more powerful and consistent:
|
||
|
|
|
||
|
|
- **Composability**: `route_exists()` can be combined with any other APOSTL expression
|
||
|
|
- **Test coverage**: Works in petit, stateful, and runtime validation without extra code
|
||
|
|
- **Clarity**: Contracts are self-documenting in route schemas
|
||
|
|
|
||
|
|
### 6.2 Extension Predicates are the Right Abstraction
|
||
|
|
|
||
|
|
The extension system (predicates + headers + hooks) provides exactly the right level of flexibility:
|
||
|
|
|
||
|
|
- **Domain-specific**: Each predicate solves one problem well
|
||
|
|
- **Composable**: Multiple extensions work together
|
||
|
|
- **Testable**: Pure functions with clear inputs/outputs
|
||
|
|
|
||
|
|
### 6.3 State Tracking is Key
|
||
|
|
|
||
|
|
Automatic path substitution requires tracking resource state across test commands. The `ModelState` with `ResourceHierarchy` provides the right structure:
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
interface ModelState {
|
||
|
|
resources: Map<string, Map<string, ResourceHierarchy>>
|
||
|
|
// resourceType → resourceId → { id, type, parentId, parentType, ... }
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 7. Remaining Work (Out of Scope for v2.1)
|
||
|
|
|
||
|
|
| Feature | Status | Reason |
|
||
|
|
|---------|--------|--------|
|
||
|
|
| `x-relationships` schema annotation | ❌ Not implemented | Replaced by APOSTL predicates |
|
||
|
|
| Full graph traversal | ❌ Out of scope | Complex graph algorithms belong in application tests |
|
||
|
|
| Database foreign key validation | ❌ Out of scope | Apophis shouldn't access databases directly |
|
||
|
|
| Cross-service link validation | ❌ Out of scope | Microservice links require running external services |
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 8. References
|
||
|
|
|
||
|
|
- **Implementation**: `src/extensions/relationships.ts`
|
||
|
|
- **Route Matcher**: `src/infrastructure/route-matcher.ts`
|
||
|
|
- **Cascade Validator**: `src/test/cascade-validator.ts`
|
||
|
|
- **Hypermedia Validator**: `src/test/hypermedia-validator.ts`
|
||
|
|
- **Tests**: `src/test/relationships.test.ts`, `src/test/cross-operation-support.test.ts`
|
||
|
|
- **Extension System**: `docs/extensions/EXTENSION-PLUGIN-SYSTEM.md`
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
**Contact:** Arbiter Team — We'd love to hear how these features work for your use cases!
|