# 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`


---

# Agent Instructions: Querying This Documentation

If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter:

```
GET https://docs.flxbl.io/flxbl/libs/flxbl-apex-api.md?ask=<question>
```

The question should be specific, self-contained, and written in natural language.
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
