Skip to content

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

  1. An API request arrives (e.g. GET /api/v1/acme-corp/my-workspace/customers)
  2. The service resolves the workspace by querying dynamicdb directly via JDBC
  3. It loads entity metadata (tables, columns, relationships, constraints) from dynamicdb
  4. It fetches the customer database credentials from dynamicdb (encrypted, decrypted at runtime)
  5. ByteBuddy generates JPA entity classes at runtime matching the schema
  6. Hibernate opens a session against the customer database using the generated entities
  7. 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 entities

No 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 tests

Request Pipeline

Each request passes through interceptors before reaching the controller:

  1. ApiKeyAuthInterceptor — validates the API key
  2. WorkspaceStatusInterceptor — checks workspace access mode (Active, Read Only, etc.)
  3. RateLimitInterceptor — throttles requests
  4. 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 / @OneToMany with 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 + @Table annotations
  • @Id + @GeneratedValue(IDENTITY) for primary keys
  • @Column with nullable, length, unique, insertable, updatable
  • @ManyToOne / @OneToMany with @JoinColumn
  • Getters and setters via ByteBuddy FieldAccessor

SessionFactory Caching

Building a SessionFactory is expensive (~2s), so they're cached per workspace using Caffeine:

SettingValue
Max workspaces cached100
Idle timeout60 minutes
Metadata cache TTL30 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):

OperationMethodHibernate Call
ListGET /{org}/{workspace}/{table}HQL query with pagination, sorting, filtering
ReadGET /{org}/{workspace}/{table}/{id}session.get(entityClass, id)
CreatePOST /{org}/{workspace}/{table}session.persist(entity)
UpdatePUT /{org}/{workspace}/{table}/{id}session.merge(entity) with dirty checking
DeleteDELETE /{org}/{workspace}/{table}/{id}session.remove(entity)
BulkPOST/PUT/DELETE /{org}/{workspace}/{table}/bulkPer-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-apiprocessor
PurposeServe CRUD requests for customer dataApply schema changes to customer databases
Port80838082
MessagingNone — reads metadata via JDBCRabbitMQ (receives from Quarkus, sends task completion)
DB writesCustomer data (INSERT/UPDATE/DELETE rows)Customer schema (CREATE/ALTER TABLE via Flyway)
ResilienceRate limiting, metadata cachingCircuit 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_ONE and ONE_TO_MANY; other types log a warning and are skipped
  • Circular relationships — entities with circular dependencies are generated without relationship fields as a fallback

SchemaStack Internal Developer Documentation