Appearance
Dynamic CRUD API
The workspace API dynamically generates REST endpoints for customer data without hardcoded entity classes. It runs on port 8083.
Architecture Decision
This runs as a Spring Boot service (workspace-api module) rather than Quarkus, because it needs classic Hibernate for dynamic entity generation at runtime.
How It Works
- An API request arrives (e.g.
GET /api/v1/acme-corp/my-workspace/customers) - The service resolves the workspace by querying
dynamicdbdirectly via JDBC - It loads entity metadata (tables, columns, relationships, constraints) from
dynamicdb - It fetches the customer database credentials from
dynamicdb(encrypted, decrypted at runtime) - ByteBuddy generates JPA entity classes at runtime matching the schema
- Hibernate opens a session against the customer database using the generated entities
- The query is executed and results are serialized to JSON
HTTP Request
│
▼
workspace-api (Spring Boot, port 8083)
│
├── JDBC → dynamicdb (schema metadata + DB credentials)
│
└── Hibernate → customer database (actual data)
using ByteBuddy-generated JPA entitiesNo messaging
The workspace-api has no RabbitMQ dependency. It reads metadata directly from PostgreSQL via JDBC. Schema changes flow through the separate processor service (which does use RabbitMQ).
Module Structure
spring-boot/workspace-api/
├── workspace-api-core/ ← ByteBuddy entity generation, metadata JDBC loading
├── workspace-api-rest/ ← REST controllers, interceptors, serialization
├── workspace-api-service/ ← Spring Boot application entry point
└── workspace-api-test/ ← Integration testsRequest Pipeline
Each request passes through interceptors before reaching the controller:
- ApiKeyAuthInterceptor — validates the API key
- WorkspaceStatusInterceptor — checks workspace access mode (Active, Read Only, etc.)
- RateLimitInterceptor — throttles requests
- SlugBasedCrudController — resolves workspace, loads metadata, executes query
Dynamic Entity Generation
ByteBuddy generates full JPA entity classes at runtime:
- Two-pass generation: first pass creates entities without relationships, second pass adds
@ManyToOne/@OneToManywith dependency resolution - Circular dependencies: entities with unresolvable circular refs are generated without relationship fields
- Class isolation: each workspace gets its own
WorkspaceClassLoader(extends URLClassLoader), so workspaces can't leak into each other - Package naming:
io.schemastack.workspace.generated.w{workspaceId}.{EntityName}
Generated classes include:
@Entity+@Tableannotations@Id+@GeneratedValue(IDENTITY)for primary keys@Columnwith nullable, length, unique, insertable, updatable@ManyToOne/@OneToManywith@JoinColumn- Getters and setters via ByteBuddy
FieldAccessor
SessionFactory Caching
Building a SessionFactory is expensive (~2s), so they're cached per workspace using Caffeine:
| Setting | Value |
|---|---|
| Max workspaces cached | 100 |
| Idle timeout | 60 minutes |
| Metadata cache TTL | 30 seconds |
On eviction: SessionFactory.close() releases DB connections, WorkspaceClassLoader.close() allows GC of generated classes.
Staleness detection: on each request, the service compares cached metadata (entity/column counts) against a fresh query. If stale, it triggers an async rebuild — the old SessionFactory keeps serving until the new one is ready.
CRUD Operations
All operations use Hibernate ORM with the generated JPA entities (not raw SQL):
| Operation | Method | Hibernate Call |
|---|---|---|
| List | GET /{org}/{workspace}/{table} | HQL query with pagination, sorting, filtering |
| Read | GET /{org}/{workspace}/{table}/{id} | session.get(entityClass, id) |
| Create | POST /{org}/{workspace}/{table} | session.persist(entity) |
| Update | PUT /{org}/{workspace}/{table}/{id} | session.merge(entity) with dirty checking |
| Delete | DELETE /{org}/{workspace}/{table}/{id} | session.remove(entity) |
| Bulk | POST/PUT/DELETE /{org}/{workspace}/{table}/bulk | Per-item processing, max 100 items |
Filtering
Filter syntax: ?filter[name][eq]=John&filter[age][gte]=18
Operators: eq, neq, gt, gte, lt, lte, like, in, isNull, isNotNull. Values are type-converted to match the column type.
Relationship Expansion
?expand=customer adds LEFT JOIN FETCH to the HQL query. Configurable per entity via API settings (whitelist, max depth).
Field Selection
?fields=id,name,email limits which fields are serialized in the response.
Relationship to the Processor
The workspace-api and processor are separate services with different responsibilities:
| workspace-api | processor | |
|---|---|---|
| Purpose | Serve CRUD requests for customer data | Apply schema changes to customer databases |
| Port | 8083 | 8082 |
| Messaging | None — reads metadata via JDBC | RabbitMQ (receives from Quarkus, sends task completion) |
| DB writes | Customer data (INSERT/UPDATE/DELETE rows) | Customer schema (CREATE/ALTER TABLE via Flyway) |
| Resilience | Rate limiting, metadata caching | Circuit breakers, retry with backoff |
API Documentation
The workspace-api generates OpenAPI 3.0.3 specs dynamically from entity metadata:
GET /_openapi— JSON spec generated per workspace (cached 60s)GET /_docs— embedded Swagger UI
Specs include constraint metadata (minLength, maxLength, pattern, format), pagination parameters, expand fields, and filtering operators.
Validation
Constraint validation runs on both create and update via MetadataConstraintValidator. Supported constraint types:
NOT_BLANK, MIN_LENGTH, MAX_LENGTH, PATTERN, EMAIL, URL, MIN, MAX, POSITIVE, NEGATIVE, POSITIVE_OR_ZERO, NEGATIVE_OR_ZERO
Also enforces nullable and type checking per column. Custom error messages supported via ColumnConstraintDTO.
Limitations
- No entity-level or column-level permission checks — only workspace-level API key access control (READ_ONLY / READ_WRITE) and entity-level public access flag
- No composite primary keys — throws
UnsupportedOperationException; only single-column keys supported - No full-text search — filtering is comparison-based only (eq, like, etc.)
- Limited relationship types — only
MANY_TO_ONEandONE_TO_MANY; other types log a warning and are skipped - Circular relationships — entities with circular dependencies are generated without relationship fields as a fallback