Skip to main content

# User Guide

## Table of Contents​

- App Initialization
- Logger
- Routes and Error Handling
- Starting the Server
- Serverless Deployment
- API Requests
- Redis Cache Service
- Token Service
- Parameter Store Service
- Request Validation

## App Initialization​

`initApp` creates a pre-configured Express application with security middleware, CORS, compression, and a `/health` endpoint.

    import { initApp } from '@elliemae/pui-service-sdk';

    // Use defaults (ICE/Ellie Mae CORS origins)
    const app = initApp();

    // Or provide custom options
    const app = initApp({
      corsOptions: {
        origin: ['https://my-app.example.com'],
        methods: 'GET,POST',
      },
      jsonOptions: {
        limit: '1mb',
        strict: true,
      },
    });

### Default CORS Configuration​

The built-in CORS policy allows requests from these domains:

- `*.ice.com`
- `*.elliemae.com`
- `*.elliemae.io`
- `*.ellieservices.com`
- `*.ellielabs.com`

Allowed headers: `Authorization`, `Content-Type`, `Origin`, `X-Requested-With`, `Accept`

### Default JSON Options​

- Body size limit: `100kb`
- Content type: `application/json`

## Logger​

The SDK provides a structured Pino logger with automatic PII redaction, environment metadata, and log record validation.

### Creating a Logger​

    import { logger } from '@elliemae/pui-service-sdk';

    const log = logger({
      appName: 'my-service',       // required
      team: 'my-team',             // required
      environment: 'production',   // optional, falls back to ENV env var
      appVersion: '1.2.0',         // optional, falls back to VERSION env var
      location: 'us-west-2',      // optional, falls back to LOCATION env var
      index: 'my-index',          // optional, log index identifier
      level: 'debug',             // optional, default: 'info'
    });

### Logging Messages​

Every structured log message requires `message` and `correlationId`:

    log.info({
      message: 'Order created successfully',
      correlationId: 'abc-123-def',
      resourceType: 'order',
      resourceId: '12345',
    });

You can also pass a plain string (a `correlationId` is generated automatically):

    log.info('Server started');

### Logging Errors​

Pass errors using the `exception` field:

    log.error({
      message: 'Failed to process order',
      correlationId: 'abc-123-def',
      errorCode: 'ORD001',
      exception: new Error('Database timeout'),
    });

### PII Redaction​

The logger automatically redacts sensitive data from all log output:

- Field-level redaction: Fields named `password`, `pwd`, `creditcard`, `credentials`, `ssn`, or `socialsecuritynumber` are replaced with `[Redacted]`
- Pattern-based redaction: Credit card numbers, SSNs, email addresses, street addresses, IP addresses, and credential patterns within string values are detected and replaced

### Environment Variables​

The logger reads these environment variables at initialization:

Variable | Maps to | Fallback
--- | --- | ---
`ENV` | `environment` | Options value or `'NA'`
`VERSION` | `appVersion` | Options value or `'NA'`
`LOCATION` | `location` | Options value or `'NA'`

## Routes and Error Handling​

Use `service.handleRequest` to wire routes and error handling onto your app:

    import { initApp, service, logger } from '@elliemae/pui-service-sdk';

    const log = logger({ appName: 'my-service', team: 'my-team' });

    const userRoutes = (app) => {
      app.get('/api/users', (req, res) => {
        res.json([{ id: 1, name: 'Jane' }]);
      });

      app.get('/api/users/:id', (req, res) => {
        res.json({ id: req.params.id, name: 'Jane' });
      });
    };

    const orderRoutes = (app) => {
      app.post('/api/orders', (req, res) => {
        res.status(201).json({ id: 'new-order' });
      });
    };

    const app = initApp();
    service.handleRequest({
      logger: log,
      routes: [userRoutes, orderRoutes],
    })(app);

### Built-in Error Handling​

After all routes are registered, `handleRequest` automatically adds:

1. 404 handler — Returns `"endpoint not found"` for unmatched routes
2. Unhandled error handler — Catches thrown exceptions, logs them with a correlation ID (`puisvc001`), and returns an error response:
   - 5xx errors — Returns `"Internal Server Error"` (hides internal details from clients)
   - 4xx errors — Returns the actual error message for easier client-side debugging

## Starting the Server​

Use `listen` to start the HTTP server with graceful shutdown support:

    import { listen } from '@elliemae/pui-service-sdk';

    // Default port: process.env.PORT or 3000
    const server = await listen(app);

    // Custom port with cleanup hook
    const server = await listen(app, {
      port: 8080,
      onClose: async () => {
        await database.disconnect();
        await cache.quit();
      },
    });

### Error Handling​

The returned Promise rejects if the server fails to start (e.g., port already in use):

    try {
      const server = await listen(app, { port: 8080 });
    } catch (err) {
      console.error('Server failed to start:', err.message);
      process.exit(1);
    }

### Graceful Shutdown​

When the process receives `SIGINT` or `SIGTERM`:

1. The server stops accepting new connections
2. Existing connections are allowed to complete
3. The `onClose` callback (if provided) is awaited for cleanup
4. The process exits

## Serverless Deployment​

Wrap your Express app for AWS Lambda:

    import { initApp, service, logger, serverless } from '@elliemae/pui-service-sdk';

    const log = logger({ appName: 'my-lambda', team: 'my-team' });

    const routes = (app) => {
      app.get('/api/data', (req, res) => {
        res.json({ data: 'from lambda' });
      });
    };

    const app = initApp();
    service.handleRequest({ logger: log, routes: [routes] })(app);

    export const handler = serverless(app);

This uses `@vendia/serverless-express` under the hood to translate API Gateway events into Express requests. The `serverless` wrapper automatically uses the logger configured via `service.handleRequest` (from `app.locals.logger`), so your Lambda logs retain the same structured format, PII redaction, and metadata as your local server. If no logger is configured, it falls back to a default Pino instance.

## API Requests​

`service.apiRequest` provides an HTTP client with automatic retries and structured error handling:

    const { apiRequest, APIError } = service;

    try {
      const data = await apiRequest<{ users: User[] }>({
        url: 'https://api.example.com/users',
        options: {
          method: 'GET',
          headers: {
            Authorization: 'Bearer my-token',
            'X-Correlation-ID': 'abc-123',
          },
        },
        loggerService: log,
      });
    } catch (error) {
      if (error instanceof APIError) {
        console.error(error.details.responseStatusCode);
      }
    }

### Options​

Parameter | Type | Default | Description
--- | --- | --- | ---
`url` | `string` | — | Request URL (required)
`options` | `RequestInit` | — | Fetch options: method, headers, body (required)
`retryDelay` | `number` | `500` | Base delay between retries (ms)
`maxAttempts` | `number` | `3` | Maximum number of attempts
`retryCodes` | `number[]` | `[0, 408, 502, 503, 504]` | HTTP status codes that trigger retries
`loggerService` | `pino.Logger | Console` | `console` | Logger instance

### Retry Behavior​

- Retries use linear backoff: `attempt * retryDelay`
- Only status codes in `retryCodes` trigger retries
- After exhausting all attempts, an `APIError` is thrown
- Non-retryable error codes throw `APIError` immediately

### Response Handling​

- `application/json` responses are parsed as JSON
- `text/plain` responses are returned as strings
- Other content types throw an `APIError`

## Redis Cache Service​

`service.RedisService` provides a Redis Cluster client with automatic fallback to an in-memory cache:

    const { RedisService } = service;

    const cache = new RedisService({
      redisUrl: 'rediss://my-redis-cluster.example.com:6379',
      namespace: 'my-service',
      loggerService: log,
    });

    // Store a value (TTL in seconds)
    await cache.set('user:123', JSON.stringify(userData), 300);

    // Retrieve a value
    const cached = await cache.get('user:123');
    if (cached) {
      const user = JSON.parse(cached);
    }

### Options​

Parameter | Type | Default | Description
--- | --- | --- | ---
`redisUrl` | `string` | — | Redis cluster URL (required)
`namespace` | `string` | — | Key prefix for cache isolation (required)
`loggerService` | `pino.Logger` | `console` | Logger instance
`redisClientTimeout` | `number` | `2000` | Connection timeout (ms)
`redisClientMaxRetry` | `number` | `3` | Max retries per request
`redisDefaultTTL` | `number` | `60` | Default TTL in seconds
`defaultRedisPort` | `number` | `6379` | Fallback port if not in URL

### Fallback Behavior​

If Redis is unavailable (connection failure, timeout, etc.), the service transparently falls back to an in-memory cache. This ensures your service stays operational during Redis outages, though cached data will not be shared across instances.

### Connection Management​

The service manages its Redis Cluster connection automatically:

- Connection deduplication — Concurrent requests during startup share a single connection attempt rather than creating duplicate clients
- Automatic reconnection — If the Redis connection drops, the service detects the closure and establishes a new connection on the next `get()` or `set()` call
- TLS support — Connections use TLS by default, compatible with AWS ElastiCache clusters

### Key Namespacing​

All keys are automatically prefixed with `namespace:` to prevent collisions between services sharing a Redis cluster.

## Token Service​

`service.TokenService` handles OAuth2 token retrieval and JWT verification with Redis-backed caching:

    const { TokenService, RedisService } = service;

    const cache = new RedisService({
      redisUrl: process.env.REDIS_URL,
      namespace: 'my-service',
    });

    const tokenService = new TokenService({
      oapiUrl: 'https://api.elliemae.com',
      clientId: process.env.CLIENT_ID,
      clientSecret: process.env.CLIENT_SECRET,
      redisCacheService: cache,
      loggerService: log,
    });

### Getting a Token​

    const token = await tokenService.getJwt({
      grant_type: 'client_credentials',
      client_id: 'my-client',
      client_secret: 'my-secret',
      scope: 'read write',
      roles: 'admin',
      aud: 'my-audience',
      elli_iid: 'instance-id',
      realm: 'my-realm',
    });

    // token.access_token contains the JWT

Tokens are cached in Redis (default TTL: 240 seconds) to minimize OAuth calls.

### Verifying a Token​

    const isValid = await tokenService.verifyJwt('Bearer eyJhbGci...');
    // Returns true if signature is valid, throws TokenServiceError otherwise

Verification fetches JWKS from the OAuth provider and caches keys in Redis (default TTL: 24 hours).

### Utility Methods​

    // Strip "Bearer " prefix
    const jwt = tokenService.bearerTokenToJwt('Bearer eyJhbGci...');

    // Add "Bearer " prefix
    const bearer = tokenService.jwtToBearerToken('eyJhbGci...');

    // Decode without verification
    const decoded = tokenService.decodeJwt('Bearer eyJhbGci...');

## Parameter Store Service​

`service.ParamStoreService` retrieves parameters from AWS Systems Manager Parameter Store:

    const { ParamStoreService } = service;

    const paramStore = new ParamStoreService({
      namespace: '/my-service/production/',
      loggerService: log,
    });

    // Get a plain text parameter
    const apiUrl = await paramStore.get('api-url');

    // Get an encrypted parameter (SecureString)
    const dbPassword = await paramStore.get('db-password', true);

The `namespace` is prepended to all parameter names, so `paramStore.get('api-url')` fetches `/my-service/production/api-url` from Parameter Store.

## Request Validation​

The SDK provides Express middleware for request validation using `express-validator`:

    import { validate, customValidators } from '@elliemae/pui-service-sdk';
    import { body, param } from 'express-validator';

    const createUserRoute = (app) => {
      app.post(
        '/api/users',
        validate([
          body('email').isEmail(),
          body('name').isString().notEmpty(),
          body('roles').custom(customValidators.isArray),
          body('roles').custom(customValidators.notEmpty),
        ]),
        (req, res) => {
          // Only reached if validation passes
          res.status(201).json({ created: true });
        },
      );
    };

### Behavior​

- If all validations pass, the next middleware/handler is called
- If any validation fails, a `400` response (or custom status code) is returned with the error details:

    {
      "errors": [
        {
          "type": "field",
          "msg": "Invalid value",
          "path": "email",
          "location": "body"
        }
      ]
    }

### Custom Status Code​

    validate([body('id').isUUID()], 422)  // Returns 422 on failure instead of 400

### Built-in Custom Validators​

Validator | Description
--- | ---
`customValidators.isArray(value)` | Returns `true` if value is an array
`customValidators.notEmpty(array)` | Returns `true` if array has at least one element