Skip to content

flxbl-io/flxbl-apex-api

Repository files navigation

FLXBL APEX API Framework

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.

Core Concepts

The framework has three building blocks: Conn, Plug, and HttpPipeline.

Conn – The Connection Token

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 plugs
  • halted – 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.

Plug – A Single Responsibility

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 – The Orchestrator

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)  │
└──────────────────────────────────────────────────────┘

Quick Start

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:

  1. PlugJsonBodyDeserializer – parses the JSON body into ComplaintPayload and sets conn.payload
  2. PlugValidator – runs your validation rules; halts with 400 if invalid
  3. CreateComplaintPlug – your business logic; sets conn.model with the response
  4. PlugError (.always()) – converts any uncaught exception into an error response
  5. HttpJsonView (.always()) – serializes conn.model to JSON and writes to response.responseBody

Testing

The pipeline pattern makes testing straightforward at two levels: unit-testing individual plugs and integration-testing endpoints with stubbed dependencies.

Unit Testing a Plug

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.

Integration Testing with Stubs

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 more complete example

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

Built-in Plugs

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

PlugJsonBodyDeserializer

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

PlugPathParams

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'

PlugAccept

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

PlugContentType

Validates the Content-Type header on requests with bodies (POST, PUT, PATCH).

.plug(new PlugContentType())  // Defaults to application/json

Options:

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

PlugValidator

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 ValidationException if validation fails
  • Works with PlugError to 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;
    }
}

PlugSecureHeaders

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

PlugRequestId

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.

PlugTimer

Captures request timing for performance monitoring.

.plug(new PlugTimer())  // Place early in pipeline

Retrieving duration:

Long durationMs = PlugTimer.getDuration(conn);
Long startTime = PlugTimer.getStartTime(conn);

PlugError

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"
}

HttpJsonView

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

About

Build Salesforce REST APIs with composable plugs: parse, validate, handle, respond — Elixir Plug-inspired, zero boilerplate.

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors