Code Generation (ThorAPI)
Complete guide to using ThorAPI-generated models, repositories, and TypeScript clients in workflows and frontends.
What is ThorAPI?
ThorAPI (thorapi/) is ValkyrAI's code generation engine that:
- Transforms OpenAPI specifications into Spring Boot controllers, JPA models, and repositories
- Generates TypeScript clients with RTK Query integration
- Creates reusable "bundles" (RBAC, ecommerce, messaging, etc.)
- Enforces consistent architecture across the platform
- Outputs to
/generated/folders in consuming modules
Build Order: thorapi builds first; other modules depend on its output.
Architecture Overview
OpenAPI Specs (YAML)
↓
ThorAPI Codegen Engine
↓ (Generates)
├── valkyrai/generated/
│ ├── models/ (JPA entities)
│ ├── repositories/ (Spring Data)
│ └── services/ (Business layer)
│
├── web/typescript/backend/api/
│ ├── ApplicationService.ts (RTK Query hooks)
│ └── ...Service.ts (for each model)
│
└── gridheim/generated/
├── models/
└── repositories/
Key Files
- OpenAPI Specs:
thorapi/src/main/resources/openapi/(core specs) - Bundle Manifests:
thorapi/src/main/resources/openapi/bundles.yaml - Handlebars Templates:
thorapi/src/main/resources/templates/(output format) - Generated Code:
[module]/generated/(auto-generated, read-only)
Backend: Java Integration
Using Generated Models
// Generated model with JPA annotations
import com.valkyrlabs.generated.model.Workflow;
import com.valkyrlabs.generated.model.Task;
@Service
public class WorkflowBusinessService {
@Autowired
private WorkflowRepository workflowRepo; // Auto-generated
public Workflow createWorkflow(String name) {
Workflow workflow = new Workflow();
workflow.setName(name);
workflow.setStatus(WorkflowStatus.DRAFT);
return workflowRepo.save(workflow);
}
public List<Workflow> findByOwner(Principal owner) {
return workflowRepo.findByOwnerId(owner.getId());
}
public Workflow getWithTasks(UUID workflowId) {
Workflow workflow = workflowRepo.findById(workflowId)
.orElseThrow(() -> new NotFoundException("Workflow not found"));
// Eagerly load lazy relationships
Hibernate.initialize(workflow.getTasks());
workflow.getTasks().forEach(task ->
Hibernate.initialize(task.getExecModules())
);
return workflow;
}
}
Query by Example (QBE)
// Build example object
Workflow example = new Workflow();
example.setName("Production"); // Will search for CONTAINING (case-insensitive)
example.setStatus(WorkflowStatus.ACTIVE); // Exact match
// Define matching strategy
ExampleMatcher matcher = ExampleMatcher.matching()
.withStringMatcher(ExampleMatcher.StringMatcher.CONTAINING)
.withIgnoreCase()
.withIgnorePaths("createdAt", "updatedAt");
// Query
List<Workflow> results = workflowRepo.findAll(
Example.of(example, matcher)
);
Relationships & Cascading
@Entity
@Table(name = "workflow")
public class Workflow {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
// One-to-many: cascade saves and deletes
@OneToMany(mappedBy = "workflow", cascade = CascadeType.ALL)
private List<Task> tasks = new ArrayList<>();
// Many-to-one: load eagerly to avoid N+1
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "owner_id")
private Principal owner;
}
// Save with cascading
Workflow workflow = new Workflow();
workflow.setName("My Workflow");
Task task = new Task();
task.setName("Step 1");
workflow.addTask(task); // Set bidirectional
Workflow saved = workflowRepo.save(workflow); // Saves both
Handling Encrypted Fields
// Fields marked @SecureField are auto-encrypted/decrypted
@Entity
public class ApiCredential {
@SecureField // Encrypted at rest
@Column(name = "api_key")
private String apiKey;
@SecureField
@Column(name = "secret_token")
private String secretToken;
}
// Usage is transparent
ApiCredential cred = repo.findById(id).orElseThrow();
String key = cred.getApiKey(); // Auto-decrypted
// At rest in DB: encrypted value
// In memory: decrypted string
Frontend: TypeScript Integration
Using Generated Services
// Auto-generated service for each model
import {
useGetWorkflowsQuery,
useGetWorkflowQuery,
useCreateWorkflowMutation,
useUpdateWorkflowMutation,
useDeleteWorkflowMutation,
} from './backend/api/WorkflowService';
import { Workflow } from './backend/api/models';
// Component using hooks
export const WorkflowList = () => {
const [page, setPage] = useState(1);
const limit = 20;
// Query with pagination
const { data, isLoading, error } = useGetWorkflowsPagedQuery({
page,
limit,
});
if (error) {
return <ErrorAlert error={error} />;
}
if (isLoading) {
return <Spinner />;
}
return (
<div>
<h1>Workflows ({data.total})</h1>
<table>
<thead>
<tr>
<th>Name</th>
<th>Status</th>
<th>Created</th>
</tr>
</thead>
<tbody>
{data.items.map((w) => (
<tr key={w.id}>
<td>{w.name}</td>
<td>{w.status}</td>
<td>{new Date(w.createdAt).toLocaleDateString()}</td>
</tr>
))}
</tbody>
</table>
<Pagination
current={page}
total={Math.ceil(data.total / limit)}
onChange={setPage}
/>
</div>
);
};
Mutations & Caching
// Create new item
const [createWorkflow] = useCreateWorkflowMutation();
const handleCreate = async (workflow: Workflow) => {
try {
// Mutation automatically invalidates Workflow queries
const result = await createWorkflow(workflow).unwrap();
toast.success("Workflow created");
navigate(`/workflows/${result.id}`);
} catch (error) {
toast.error(error.message);
}
};
// Update item
const [updateWorkflow] = useUpdateWorkflowMutation();
const handleUpdate = async (id: string, updates: Partial<Workflow>) => {
try {
await updateWorkflow({ id, ...updates }).unwrap();
toast.success("Workflow updated");
} catch (error) {
toast.error("Failed to update: " + error.message);
}
};
// Delete item
const [deleteWorkflow] = useDeleteWorkflowMutation();
const handleDelete = async (id: string) => {
if (confirm("Are you sure?")) {
try {
await deleteWorkflow(id).unwrap();
toast.success("Workflow deleted");
} catch (error) {
toast.error("Failed to delete");
}
}
};
Query Filters (QBE)
// Filter by example JSON
const [example, setExample] = useState<Partial<Workflow> | undefined>();
// Hook with example filter
const { data } = useGetWorkflowsQuery(
{ example },
{ skip: !example } // Don't fetch if no filter
);
// QBE filter modal
const handleQBEFilter = (json: string) => {
try {
const parsed = JSON.parse(json);
setExample(parsed);
} catch {
toast.error("Invalid JSON filter");
}
};
// Renders as query param:
// GET /v1/workflow?example={"name":"Production","status":"ACTIVE"}
Type Safety
// All models are fully typed
import {
Workflow,
Task,
ExecModule,
WorkflowStatus,
} from "./backend/api/models";
const workflow: Workflow = {
id: "123",
name: "My Workflow",
status: WorkflowStatus.ACTIVE,
tasks: [], // Type-checked
createdAt: new Date(),
updatedAt: new Date(),
};
// Hook results are typed
const { data: workflows } = useGetWorkflowsQuery({
page: 1,
limit: 20,
});
// TypeScript error if accessing undefined
if (workflows?.items) {
workflows.items.forEach((w: Workflow) => {
console.log(w.name);
});
}
Adding New Models: The Codegen Workflow
Step 1: Update OpenAPI Spec
# thorapi/src/main/resources/openapi/core.yaml
components:
schemas:
Report:
type: object
properties:
id:
type: string
format: uuid
name:
type: string
generatedAt:
type: string
format: date-time
data:
type: object
additionalProperties: true
required:
- id
- name
- generatedAt
paths:
/v1/report:
get:
summary: List reports
tags: [Report]
responses:
"200":
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/Report"
post:
summary: Create report
tags: [Report]
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/Report"
responses:
"201":
content:
application/json:
schema:
$ref: "#/components/schemas/Report"
Step 2: Regenerate Code
# Build ThorAPI (generates all downstream code)
cd thorapi
mvn clean install -DskipTests
# Or just generate
mvn generate-resources
Step 3: Add Business Logic
// Hand-written service extends generated
@Service
public class ReportBusinessService extends ReportService {
@Autowired
private ReportRepository repo;
@Autowired
private DataExportService exporter;
@Transactional
public Report generateReport(UUID workflowId, Map<String, Object> parameters) {
// Custom logic
Map<String, Object> data = exporter.export(workflowId, parameters);
Report report = new Report();
report.setName("Report_" + System.currentTimeMillis());
report.setGeneratedAt(Instant.now());
report.setData(data);
return repo.save(report);
}
}
Step 4: Create Controller Endpoint
@RestController
@RequestMapping("/v1/report")
@PreAuthorize("hasRole('USER')")
public class ReportController {
@Autowired
private ReportBusinessService reportService;
@PostMapping("/generate")
public ResponseEntity<Report> generateReport(
@RequestBody GenerateReportRequest request
) {
Report report = reportService.generateReport(
request.getWorkflowId(),
request.getParameters()
);
return ResponseEntity.status(HttpStatus.CREATED).body(report);
}
}
Step 5: Use in TypeScript
// Hook auto-generated after code rebuild
const [generateReport] = useGenerateReportMutation();
const handleGenerate = async () => {
const result = await generateReport({
workflowId: workflowId,
parameters: {
/* ... */
},
}).unwrap();
console.log("Report generated:", result);
};
Using Bundles
What are Bundles?
Bundles are pre-packaged feature sets generated from specs:
# thorapi/src/main/resources/openapi/bundles.yaml
bundles:
- id: rbac-core
description: Role-Based Access Control
specs:
- core/rbac.yaml
- core/acl.yaml
- id: ecommerce
description: E-commerce Features
specs:
- ecommerce/products.yaml
- ecommerce/orders.yaml
- ecommerce/payments.yaml
- id: messaging
description: Internal Messaging
specs:
- messaging/channels.yaml
- messaging/messages.yaml
Registering Bundles
# Client application registers bundles
spring:
thorapi:
bundles:
- rbac-core
- ecommerce
- messaging
Using Bundle Models
// Models from registered bundles auto-imported
import com.valkyrlabs.generated.rbac.Role;
import com.valkyrlabs.generated.ecommerce.Order;
import com.valkyrlabs.generated.messaging.Channel;
@Service
public class OrderService {
@Autowired
private OrderRepository orderRepo; // From ecommerce bundle
public Order createOrder(Principal user, List<OrderItem> items) {
Order order = new Order();
order.setCustomer(user);
order.setItems(items);
order.setStatus(OrderStatus.PENDING);
return orderRepo.save(order);
}
}
Performance: Avoiding N+1 Queries
Problem: N+1 Query Pattern
// ❌ SLOW: N+1 queries!
List<Workflow> workflows = workflowRepo.findAll(); // 1 query
for (Workflow w : workflows) {
// Each iteration: 1 query for tasks
List<Task> tasks = w.getTasks(); // N queries total
for (Task t : tasks) {
// Each iteration: 1 query for modules
List<ExecModule> modules = t.getExecModules(); // N² queries!
}
}
Solution 1: Entity Graph
// Repository with @EntityGraph
@Repository
public interface WorkflowRepository extends JpaRepository<Workflow, UUID> {
@EntityGraph(attributePaths = {
"tasks",
"tasks.execModules",
"owner"
})
List<Workflow> findAll();
@EntityGraph(attributePaths = {
"tasks",
"tasks.execModules"
})
Optional<Workflow> findById(UUID id);
}
// ✅ FAST: 1 query with joins
List<Workflow> workflows = workflowRepo.findAll();
Solution 2: Manual Eager Loading
@Transactional(readOnly = true)
public Workflow getWithAll(UUID workflowId) {
Workflow workflow = workflowRepo.findById(workflowId)
.orElseThrow();
// Force load collections within transaction
Hibernate.initialize(workflow.getTasks());
workflow.getTasks().forEach(task -> {
Hibernate.initialize(task.getExecModules());
});
return workflow; // Collections loaded; can access outside transaction
}
Solution 3: Projection DTO
// Lightweight DTO instead of full entity
public record WorkflowSummary(
UUID id,
String name,
WorkflowStatus status,
int taskCount
) {}
@Repository
public interface WorkflowRepository extends JpaRepository<Workflow, UUID> {
@Query("""
SELECT NEW com.valkyrlabs.dto.WorkflowSummary(
w.id, w.name, w.status, SIZE(w.tasks)
)
FROM Workflow w
WHERE w.owner = :owner
""")
List<WorkflowSummary> findSummariesByOwner(Principal owner);
}
// ✅ FAST: Single query, smaller result set
List<WorkflowSummary> summaries = workflowRepo.findSummariesByOwner(owner);
Extending Generated Code
Do's ✅
// ✅ Extend service with custom methods
@Service
public class WorkflowBusinessService extends WorkflowService {
public Workflow publish(UUID id) {
Workflow w = this.get(id);
w.setStatus(WorkflowStatus.PUBLISHED);
return this.update(w);
}
}
// ✅ Add custom repository methods
@Repository
public interface WorkflowRepository extends JpaRepository<Workflow, UUID> {
// Custom finder
List<Workflow> findByStatusAndOwner(
WorkflowStatus status,
Principal owner
);
// Custom query
@Query("SELECT w FROM Workflow w WHERE w.name LIKE %:pattern%")
List<Workflow> searchByName(@Param("pattern") String pattern);
}
// ✅ Add validators/listeners
@Component
public class WorkflowValidator {
@PrePersist
@PreUpdate
public void validate(Workflow workflow) {
if (workflow.getName() == null || workflow.getName().isBlank()) {
throw new ValidationException("Name is required");
}
}
}
Don'ts ❌
// ❌ Edit generated code directly
// Generated files are LOST on regeneration
// ❌ Modify generated model properties
// Add new properties via model extension instead
// ❌ Copy-paste generated code
// Instead, reuse via composition or inheritance
Troubleshooting
Issue: Generated Code Not Updated
Symptom: "Cannot find symbol" or old method signatures
Solution:
# Full rebuild
cd thorapi
mvn clean install -DskipTests
# Check generated output exists
ls -la valkyrai/generated/
Issue: Lazy Loading Error
Symptom: LazyInitializationException accessing relationships
Solution:
// Load within transaction
@Transactional
public Workflow get(UUID id) {
Workflow w = repo.findById(id).orElseThrow();
Hibernate.initialize(w.getTasks()); // Within transaction
return w;
}
// Or use @EntityGraph
@EntityGraph(attributePaths = "tasks")
Optional<Workflow> findById(UUID id);
Issue: Type Mismatch
Symptom: Frontend types don't match backend
Solution: Regenerate TypeScript clients
# After backend changes
cd thorapi
mvn generate-resources
# TypeScript clients updated in web/typescript/backend/api/
Quick Reference
| Task | Location | Example |
|---|---|---|
| View generated models | [module]/generated/model/ | Workflow.java |
| View repositories | [module]/generated/repository/ | WorkflowRepository.java |
| Edit OpenAPI specs | thorapi/src/main/resources/openapi/ | core.yaml, bundles.yaml |
| Add business logic | src/main/java/.../service/ | WorkflowBusinessService.java |
| Add custom endpoints | src/main/java/.../controller/ | WorkflowController.java |
| Use TypeScript hooks | web/typescript/backend/api/ | useGetWorkflowsQuery() |
| Access types | web/typescript/backend/api/models/ | Workflow, Task, etc. |
✅ INTEGRATION COMPLETE