Building REST APIs in Apex often leads to repetitive boilerplate: parsing request bodies, validating headers, extracting path parameters, handling errors, and serializing responses. This scattered logic becomes hard to test, harder to reuse, and a maintenance burden as APIs grow. flxbl-apex-api brings the pipeline pattern to Salesforce, a single Conn object flows through composable Plug components, each doing one thing well:
- Parse JSON in one plug,
- Check authentication in another,
- Run your business logic,
- Then render the response—all in a declarative chain.
The result is APIs that are easy to read, trivial to test in isolation, and effortless to extend without touching existing code.
The framework has three building blocks: Conn, Plug, and HttpPipeline.
Conn is a single object that carries everything about the current request through the pipeline. It wraps Salesforce's RestContext.request and RestContext.response, and adds:
payload– the deserialized request body (set by a parsing plug)model– the response data to serialize (set by your handler)pathParams– extracted URL parameters like{accountId}assigns– a general-purpose map for passing data between plugshalted– a flag that stops further processing when something goes wrong
Think of Conn as a baton passed from plug to plug—each plug reads what it needs, writes what it produces, and hands it to the next.
A Plug is any class that implements one method:
public interface Plug {
Conn call(Conn conn);
}Each plug does one thing: validate a header, parse JSON, enforce auth, fetch data, render output. Because plugs are small and focused, they're easy to test independently and reuse across endpoints.
HttpPipeline chains plugs together and runs them in order:
new HttpPipeline()
.plug(new PlugSecureHeaders())
.plug(new PlugPathParams('/api/accounts/{accountId}'))
.plug(new FetchAccountPlug())
.plug(new HttpJsonView())
.call();When you call .call(), the pipeline creates a Conn and passes it through each plug. If any plug halts or throws, subsequent plugs are skipped—unless marked .always() (useful for error handlers and views that must always render a response).
┌──────────────────────────────────────────────────────┐
│ HttpPipeline │
│ ┌─────┐ ┌─────┐ ┌─────┐ ┌──────────────────┐ │
│ │Plug1│ → │Plug2│ → │Plug3│ → │HttpJsonView │ │
│ └──┬──┘ └──┬──┘ └──┬──┘ └────────┬─────────┘ │
│ │ │ │ │ │
│ └─────────┴─────────┴───────────────┘ │
│ ↓ Conn ↓ │
│ (request, response, payload, model, halted, error) │
└──────────────────────────────────────────────────────┘
Here's a minimal POST endpoint that validates input, runs business logic, and returns JSON:
@RestResource(urlMapping='/complaints')
global class ComplaintApi {
@HttpPost
global static void createComplaint() {
new HttpPipeline()
.plug(new PlugJsonBodyDeserializer(ComplaintPayload.class))
.plug(new PlugValidator(new ComplaintValidator()))
.plug(new CreateComplaintPlug())
.plug(new PlugError())
.always()
.plug(new HttpJsonView())
.always()
.call();
}
// ─── Request DTO ───────────────────────────────────────────────
public class ComplaintPayload {
public String title;
public String description;
public String category;
}
// ─── Validator ─────────────────────────────────────────────────
public class ComplaintValidator implements Validator {
public ValidationResult validate(Object payload) {
ComplaintPayload p = (ComplaintPayload) payload;
ValidationResult result = new ValidationResult();
if (String.isBlank(p.title)) {
result.addError('title', 'is required');
}
if (String.isBlank(p.description)) {
result.addError('description', 'is required');
}
return result;
}
}
// ─── Business Logic ────────────────────────────────────────────
public class CreateComplaintPlug implements Plug {
public Conn call(Conn conn) {
ComplaintPayload req = (ComplaintPayload) conn.payload;
// Your business logic here (e.g., insert a Case)
conn.statusCode = 201;
conn.model = new Map<String, Object>{
'message' => 'Complaint received',
'title' => req.title
};
return conn;
}
}
}What's happening:
PlugJsonBodyDeserializer– parses the JSON body intoComplaintPayloadand setsconn.payloadPlugValidator– runs your validation rules; halts with 400 if invalidCreateComplaintPlug– your business logic; setsconn.modelwith the responsePlugError(.always()) – converts any uncaught exception into an error responseHttpJsonView(.always()) – serializesconn.modelto JSON and writes toresponse.responseBody
The pipeline pattern makes testing straightforward at two levels: unit-testing individual plugs and integration-testing endpoints with stubbed dependencies.
Each plug is a simple class with one method. Test it by creating a Conn, calling the plug, and asserting the result:
@IsTest
private static void should_halt_when_title_missing() {
// Arrange
RestContext.request = new RestRequest();
RestContext.response = new RestResponse();
Conn conn = new Conn();
conn.payload = new ComplaintApi.ComplaintPayload(); // title is blank
// Act
Conn result = new PlugValidator(new ComplaintApi.ComplaintValidator()).call(conn);
// Assert
System.assertEquals(true, result.halted, 'Should halt on invalid input');
System.assertEquals(400, result.statusCode, 'Should return 400');
}No HTTP mocking, no complex setup—just instantiate, call, assert.
When testing the full endpoint, you may want to stub out certain plugs (e.g., skip real validation, mock external calls). Use HttpPipeline.stub():
@IsTest
private static void should_return_201_when_complaint_created() {
// Arrange - stub the validator to always pass
HttpPipeline.stub(PlugValidator.class, new PassthroughPlug());
RestContext.request = new RestRequest();
RestContext.request.httpMethod = 'POST';
RestContext.request.requestBody = Blob.valueOf('{"title":"Test","description":"Desc"}');
RestContext.request.addHeader('Content-Type', 'application/json');
RestContext.response = new RestResponse();
// Act
Test.startTest();
ComplaintApi.createComplaint();
Test.stopTest();
// Assert
System.assertEquals(201, RestContext.response.statusCode);
// Cleanup
HttpPipeline.clearStubs();
}
// Simple passthrough stub
private class PassthroughPlug implements Plug {
public Conn call(Conn conn) {
return conn;
}
}Key points:
HttpPipeline.stub(PlugType.class, mockInstance)– replaces any plug of that type with your mock- Stubs are only active during
Test.isRunningTest() - Always call
HttpPipeline.clearStubs()in cleanup to avoid test pollution
A production-ready endpoint typically includes observability, security headers, content negotiation, and structured error handling:
@RestResource(urlMapping='/complaints')
global class ComplaintApi {
@HttpPost
global static void createComplaint() {
new HttpPipeline()
// ─── Observability ─────────────────────────────────────
.plug(new PlugTimer()) // Start timing
.plug(new PlugRequestId()) // Generate correlation ID
// ─── Security ──────────────────────────────────────────
.plug(new PlugSecureHeaders()) // Add security headers
// ─── Content Negotiation ───────────────────────────────
.plug(
new PlugAccept()
.accept('application/json')
.allowMissingAcceptHeader(false)
) // Require Accept header
.plug(new PlugContentType()) // Validate Content-Type
// ─── Parsing & Validation ──────────────────────────────
.plug(
new PlugJsonBodyDeserializer(ComplaintPayload.class)
.substituteKeyName('category')
) // Handle reserved word
.plug(new PlugValidator(new ComplaintValidator()))
// ─── Business Logic ────────────────────────────────────
.plug(new CreateComplaintPlug())
// ─── Response Handling (always run) ────────────────────
.plug(new PlugError().withFormatter(new ValidationErrorFormatter()))
.always()
.plug(new HttpJsonView().suppressNulls(true))
.always()
.call();
}
// ─── Request DTO ───────────────────────────────────────────────
public class ComplaintPayload {
public String title;
public String description;
public String category_Z; // Maps from JSON "category"
}
// ─── Validator ─────────────────────────────────────────────────
public class ComplaintValidator implements Validator {
private final Set<String> VALID_CATEGORIES = new Set<String>{
'bug',
'feature',
'support',
'other'
};
public ValidationResult validate(Object payload) {
ComplaintPayload p = (ComplaintPayload) payload;
ValidationResult result = new ValidationResult();
if (String.isBlank(p.title)) {
result.addError('title', 'is required');
} else if (p.title.length() > 255) {
result.addError('title', 'must not exceed 255 characters');
}
if (String.isBlank(p.description)) {
result.addError('description', 'is required');
}
if (
String.isNotBlank(p.category_Z) &&
!VALID_CATEGORIES.contains(p.category_Z.toLowerCase())
) {
result.addError(
'category',
'must be one of: bug, feature, support, other'
);
}
return result;
}
}
// ─── Business Logic ────────────────────────────────────────────
public class CreateComplaintPlug implements Plug {
public Conn call(Conn conn) {
ComplaintPayload req = (ComplaintPayload) conn.payload;
// Create a Case record
Case c = new Case(
Subject = req.title,
Description = req.description,
Type = req.category_Z,
Origin = 'API'
);
insert c;
conn.statusCode = 201;
conn.model = new Map<String, Object>{
'id' => c.Id,
'message' => 'Complaint created successfully'
};
return conn;
}
}
}What each layer does:
| Layer | Plugs | Purpose |
|---|---|---|
| Observability | PlugTimer, PlugRequestId |
Track timing and correlate logs |
| Security | PlugSecureHeaders |
Add X-Frame-Options, Cache-Control, etc. |
| Content Negotiation | PlugAccept, PlugContentType |
Validate client can send/receive JSON |
| Parsing | PlugJsonBodyDeserializer |
Deserialize JSON into typed DTO |
| Validation | PlugValidator |
Run business rules, halt on errors |
| Business Logic | CreateComplaintPlug |
Your domain logic |
| Response | PlugError, HttpJsonView |
Format errors and serialize response |
The framework ships with plugs for common API tasks. They fall into a few categories:
| Category | Plugs |
|---|---|
| Parsing | PlugJsonBodyDeserializer, PlugPathParams |
| Validation | PlugAccept, PlugContentType, PlugValidator |
| Security | PlugSecureHeaders |
| Observability | PlugRequestId, PlugTimer |
| Error Handling | PlugError |
| Response | HttpJsonView |
Parses the JSON request body and stores the result in conn.payload.
.plug(new PlugJsonBodyDeserializer(MyDto.class))Options:
| Method | Description |
|---|---|
.useUntyped(true) |
Deserialize into Map<String, Object> instead of a typed class |
.substituteKeyName('class') |
Renames reserved words in JSON (e.g., "class" → "class_Z" in your DTO) |
Behavior:
- Skips if body is null or empty
- Halts with 400 if JSON is malformed
Extracts named parameters from the URL path into conn.pathParams.
.plug(new PlugPathParams('/api/users/{userId}/orders/{orderId}'))For a request to /services/apexrest/api/users/001ABC/orders/ORD123:
conn.pathParams.get('userId') // '001ABC'
conn.pathParams.get('orderId') // 'ORD123'Validates the Accept header to ensure the client accepts your response format.
.plug(new PlugAccept().accept('application/json'))Options:
| Method | Description |
|---|---|
.accept('application/json') |
Add an acceptable media type |
.allowMissingAcceptHeader(true) |
Don't reject requests without an Accept header |
Behavior:
- Halts with 406 Not Acceptable if client doesn't accept any allowed type
Validates the Content-Type header on requests with bodies (POST, PUT, PATCH).
.plug(new PlugContentType()) // Defaults to application/jsonOptions:
| Method | Description |
|---|---|
.allow('text/xml') |
Add an allowed content type (supports wildcards like application/*) |
.allowMissingContentType(true) |
Don't reject requests without Content-Type header |
.requireBodyForMethods(methods) |
Customize which HTTP methods require validation |
Behavior:
- Skips validation for GET, DELETE, etc.
- Halts with 415 Unsupported Media Type if content type isn't allowed
Runs validation logic against conn.payload using your Validator implementation.
.plug(new PlugValidator(new MyValidator()))Options:
| Method | Description |
|---|---|
.use(validator) |
Chain multiple validators (errors are merged) |
.skipWhenPayloadNull(true) |
Skip validation if payload is null |
Behavior:
- Halts with 400 and throws
ValidationExceptionif validation fails - Works with
PlugErrorto produce structured error responses
Implementing a Validator:
public class MyValidator implements Validator {
public ValidationResult validate(Object payload) {
MyDto p = (MyDto) payload;
ValidationResult result = new ValidationResult();
if (String.isBlank(p.name)) {
result.addError('name', 'is required');
}
return result;
}
}Adds security-related HTTP headers to responses.
.plug(new PlugSecureHeaders())Default headers:
| Header | Default Value | Purpose |
|---|---|---|
X-Content-Type-Options |
nosniff |
Prevents MIME-type sniffing |
X-Frame-Options |
DENY |
Prevents clickjacking via iframes |
Cache-Control |
no-store |
Prevents caching sensitive data |
Options:
| Method | Description |
|---|---|
.withXFrameOptions('SAMEORIGIN') |
Customize X-Frame-Options |
.withCacheControl('private') |
Customize Cache-Control |
.disableCacheControl() |
Don't set Cache-Control header |
Generates or extracts a unique request ID for correlation and tracing.
.plug(new PlugRequestId())Options:
| Method | Description |
|---|---|
.fromHeader('X-Correlation-Id') |
Use a custom header name (default: X-Request-Id) |
.useClientHeader(false) |
Always generate a new ID, ignore client header |
.setResponseHeader(false) |
Don't echo the ID back in response headers |
Retrieving the ID:
String requestId = PlugRequestId.getRequestId(conn);The ID is automatically included in error responses when using PlugError.
Captures request timing for performance monitoring.
.plug(new PlugTimer()) // Place early in pipelineRetrieving duration:
Long durationMs = PlugTimer.getDuration(conn);
Long startTime = PlugTimer.getStartTime(conn);Converts exceptions and halted states into consistent JSON error responses. Must be marked .always() to ensure it runs even when the pipeline halts.
.plug(new PlugError()).always()Options:
| Method | Description |
|---|---|
.includeStackTrace(true) |
Include stack trace (dev/sandbox only!) |
.withRequestId('abc-123') |
Manually set request ID (auto-detected from PlugRequestId) |
.withFormatter(new MyErrorFormatter()) |
Use a custom error formatter |
Default error response:
{
"error": "Record not found",
"type": "System.QueryException",
"requestId": "abc-123"
}Serializes conn.model to JSON and writes it to the response body. Typically marked .always() so it runs even after errors.
.plug(new HttpJsonView()).always()Options:
| Method | Description |
|---|---|
.suppressNulls(true) |
Omit properties with null values from JSON |
Behavior:
- Sets
Content-Type: application/json - Serializes whatever is in
conn.model