Skip to main content

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

TaskLocationExample
View generated models[module]/generated/model/Workflow.java
View repositories[module]/generated/repository/WorkflowRepository.java
Edit OpenAPI specsthorapi/src/main/resources/openapi/core.yaml, bundles.yaml
Add business logicsrc/main/java/.../service/WorkflowBusinessService.java
Add custom endpointssrc/main/java/.../controller/WorkflowController.java
Use TypeScript hooksweb/typescript/backend/api/useGetWorkflowsQuery()
Access typesweb/typescript/backend/api/models/Workflow, Task, etc.

INTEGRATION COMPLETE