# flxbl-apex-api

| Availability | ✅           |
| ------------ | ----------- |
| From         | December 25 |

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:

```apex
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:

```apex
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:

```apex
@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:

```apex
@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()`:

```apex
@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:

```apex
@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`.

```apex
.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`.

```apex
.plug(new PlugPathParams('/api/users/{userId}/orders/{orderId}'))
```

For a request to `/services/apexrest/api/users/001ABC/orders/ORD123`:

```apex
conn.pathParams.get('userId')   // '001ABC'
conn.pathParams.get('orderId')  // 'ORD123'
```

### PlugAccept

Validates the `Accept` header to ensure the client accepts your response format.

```apex
.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).

```apex
.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.

```apex
.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:**

```apex
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.

```apex
.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.

```apex
.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:**

```apex
String requestId = PlugRequestId.getRequestId(conn);
```

The ID is automatically included in error responses when using `PlugError`.

### PlugTimer

Captures request timing for performance monitoring.

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

**Retrieving duration:**

```apex
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.

```apex
.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:**

```json
{
    "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.

```apex
.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`
