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, orsocialsecuritynumberare 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:
- 404 handler — Returns
"endpoint not found"for unmatched routes - 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
- 5xx errors — Returns
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:
- The server stops accepting new connections
- Existing connections are allowed to complete
- The
onClosecallback (if provided) is awaited for cleanup - 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
retryCodestrigger retries - After exhausting all attempts, an
APIErroris thrown - Non-retryable error codes throw
APIErrorimmediately
Response Handling
application/jsonresponses are parsed as JSONtext/plainresponses 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()orset()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
400response (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 |