Files
Zachary D. Rowitsch 931f17b70e
All checks were successful
ci / fast (linux) (push) Successful in 6m46s
Add BMAD framework, planning artifacts, and architecture decision document
Install BMAD workflow framework with agent commands and templates.
Create product brief, PRD, project context, and architecture decision
document covering networking/persistence strategy, JS engine evolution
path, threading model, web_api scaling, system integration, and
tab/process model. Add generated project documentation (architecture
overview, component inventory, development guide, source tree analysis).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 15:13:06 -04:00

564 lines
17 KiB
Markdown

# API Request Utility
## Principle
Use typed HTTP client with built-in schema validation and automatic retry for server errors. The utility handles URL resolution, header management, response parsing, and single-line response validation with proper TypeScript support. **Works without a browser** - ideal for pure API/service testing.
## Rationale
Vanilla Playwright's request API requires boilerplate for common patterns:
- Manual JSON parsing (`await response.json()`)
- Repetitive status code checking
- No built-in retry logic for transient failures
- No schema validation
- Complex URL construction
The `apiRequest` utility provides:
- **Automatic JSON parsing**: Response body pre-parsed
- **Built-in retry**: 5xx errors retry with exponential backoff
- **Schema validation**: Single-line validation (JSON Schema, Zod, OpenAPI)
- **URL resolution**: Four-tier strategy (explicit > config > Playwright > direct)
- **TypeScript generics**: Type-safe response bodies
- **No browser required**: Pure API testing without browser overhead
## Pattern Examples
### Example 1: Basic API Request
**Context**: Making authenticated API requests with automatic retry and type safety.
**Implementation**:
```typescript
import { test } from '@seontechnologies/playwright-utils/api-request/fixtures';
test('should fetch user data', async ({ apiRequest }) => {
const { status, body } = await apiRequest<User>({
method: 'GET',
path: '/api/users/123',
headers: { Authorization: 'Bearer token' },
});
expect(status).toBe(200);
expect(body.name).toBe('John Doe'); // TypeScript knows body is User
});
```
**Key Points**:
- Generic type `<User>` provides TypeScript autocomplete for `body`
- Status and body destructured from response
- Headers passed as object
- Automatic retry for 5xx errors (configurable)
### Example 2: Schema Validation (Single Line)
**Context**: Validate API responses match expected schema with single-line syntax.
**Implementation**:
```typescript
import { test } from '@seontechnologies/playwright-utils/api-request/fixtures';
import { z } from 'zod';
// JSON Schema validation
test('should validate response schema (JSON Schema)', async ({ apiRequest }) => {
const { status, body } = await apiRequest({
method: 'GET',
path: '/api/users/123',
validateSchema: {
type: 'object',
required: ['id', 'name', 'email'],
properties: {
id: { type: 'string' },
name: { type: 'string' },
email: { type: 'string', format: 'email' },
},
},
});
// Throws if schema validation fails
expect(status).toBe(200);
});
// Zod schema validation
const UserSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
});
test('should validate response schema (Zod)', async ({ apiRequest }) => {
const { status, body } = await apiRequest({
method: 'GET',
path: '/api/users/123',
validateSchema: UserSchema,
});
// Response body is type-safe AND validated
expect(status).toBe(200);
expect(body.email).toContain('@');
});
```
**Key Points**:
- Single `validateSchema` parameter
- Supports JSON Schema, Zod, YAML files, OpenAPI specs
- Throws on validation failure with detailed errors
- Zero boilerplate validation code
### Example 3: POST with Body and Retry Configuration
**Context**: Creating resources with custom retry behavior for error testing.
**Implementation**:
```typescript
test('should create user', async ({ apiRequest }) => {
const newUser = {
name: 'Jane Doe',
email: 'jane@example.com',
};
const { status, body } = await apiRequest({
method: 'POST',
path: '/api/users',
body: newUser, // Automatically sent as JSON
headers: { Authorization: 'Bearer token' },
});
expect(status).toBe(201);
expect(body.id).toBeDefined();
});
// Disable retry for error testing
test('should handle 500 errors', async ({ apiRequest }) => {
await expect(
apiRequest({
method: 'GET',
path: '/api/error',
retryConfig: { maxRetries: 0 }, // Disable retry
}),
).rejects.toThrow('Request failed with status 500');
});
```
**Key Points**:
- `body` parameter auto-serializes to JSON
- Default retry: 5xx errors, 3 retries, exponential backoff
- Disable retry with `retryConfig: { maxRetries: 0 }`
- Only 5xx errors retry (4xx errors fail immediately)
### Example 4: URL Resolution Strategy
**Context**: Flexible URL handling for different environments and test contexts.
**Implementation**:
```typescript
// Strategy 1: Explicit baseUrl (highest priority)
await apiRequest({
method: 'GET',
path: '/users',
baseUrl: 'https://api.example.com', // Uses https://api.example.com/users
});
// Strategy 2: Config baseURL (from fixture)
import { test } from '@seontechnologies/playwright-utils/api-request/fixtures';
test.use({ configBaseUrl: 'https://staging-api.example.com' });
test('uses config baseURL', async ({ apiRequest }) => {
await apiRequest({
method: 'GET',
path: '/users', // Uses https://staging-api.example.com/users
});
});
// Strategy 3: Playwright baseURL (from playwright.config.ts)
// playwright.config.ts
export default defineConfig({
use: {
baseURL: 'https://api.example.com',
},
});
test('uses Playwright baseURL', async ({ apiRequest }) => {
await apiRequest({
method: 'GET',
path: '/users', // Uses https://api.example.com/users
});
});
// Strategy 4: Direct path (full URL)
await apiRequest({
method: 'GET',
path: 'https://api.example.com/users', // Full URL works too
});
```
**Key Points**:
- Four-tier resolution: explicit > config > Playwright > direct
- Trailing slashes normalized automatically
- Environment-specific baseUrl easy to configure
### Example 5: Integration with Recurse (Polling)
**Context**: Waiting for async operations to complete (background jobs, eventual consistency).
**Implementation**:
```typescript
import { test } from '@seontechnologies/playwright-utils/fixtures';
test('should poll until job completes', async ({ apiRequest, recurse }) => {
// Create job
const { body } = await apiRequest({
method: 'POST',
path: '/api/jobs',
body: { type: 'export' },
});
const jobId = body.id;
// Poll until ready
const completedJob = await recurse(
() => apiRequest({ method: 'GET', path: `/api/jobs/${jobId}` }),
(response) => response.body.status === 'completed',
{ timeout: 60000, interval: 2000 },
);
expect(completedJob.body.result).toBeDefined();
});
```
**Key Points**:
- `apiRequest` returns full response object
- `recurse` polls until predicate returns true
- Composable utilities work together seamlessly
### Example 6: Microservice Testing (Multiple Services)
**Context**: Test interactions between microservices without a browser.
**Implementation**:
```typescript
import { test, expect } from '@seontechnologies/playwright-utils/fixtures';
const USER_SERVICE = process.env.USER_SERVICE_URL || 'http://localhost:3001';
const ORDER_SERVICE = process.env.ORDER_SERVICE_URL || 'http://localhost:3002';
test.describe('Microservice Integration', () => {
test('should validate cross-service user lookup', async ({ apiRequest }) => {
// Create user in user-service
const { body: user } = await apiRequest({
method: 'POST',
path: '/api/users',
baseUrl: USER_SERVICE,
body: { name: 'Test User', email: 'test@example.com' },
});
// Create order in order-service (validates user via user-service)
const { status, body: order } = await apiRequest({
method: 'POST',
path: '/api/orders',
baseUrl: ORDER_SERVICE,
body: {
userId: user.id,
items: [{ productId: 'prod-1', quantity: 2 }],
},
});
expect(status).toBe(201);
expect(order.userId).toBe(user.id);
});
test('should reject order for invalid user', async ({ apiRequest }) => {
const { status, body } = await apiRequest({
method: 'POST',
path: '/api/orders',
baseUrl: ORDER_SERVICE,
body: {
userId: 'non-existent-user',
items: [{ productId: 'prod-1', quantity: 1 }],
},
});
expect(status).toBe(400);
expect(body.code).toBe('INVALID_USER');
});
});
```
**Key Points**:
- Test multiple services without browser
- Use `baseUrl` to target different services
- Validate cross-service communication
- Pure API testing - fast and reliable
### Example 7: GraphQL API Testing
**Context**: Test GraphQL endpoints with queries and mutations.
**Implementation**:
```typescript
test.describe('GraphQL API', () => {
const GRAPHQL_ENDPOINT = '/graphql';
test('should query users via GraphQL', async ({ apiRequest }) => {
const query = `
query GetUsers($limit: Int) {
users(limit: $limit) {
id
name
email
}
}
`;
const { status, body } = await apiRequest({
method: 'POST',
path: GRAPHQL_ENDPOINT,
body: {
query,
variables: { limit: 10 },
},
});
expect(status).toBe(200);
expect(body.errors).toBeUndefined();
expect(body.data.users).toHaveLength(10);
});
test('should create user via mutation', async ({ apiRequest }) => {
const mutation = `
mutation CreateUser($input: CreateUserInput!) {
createUser(input: $input) {
id
name
}
}
`;
const { status, body } = await apiRequest({
method: 'POST',
path: GRAPHQL_ENDPOINT,
body: {
query: mutation,
variables: {
input: { name: 'GraphQL User', email: 'gql@example.com' },
},
},
});
expect(status).toBe(200);
expect(body.data.createUser.id).toBeDefined();
});
});
```
**Key Points**:
- GraphQL via POST request
- Variables in request body
- Check `body.errors` for GraphQL errors (not status code)
- Works for queries and mutations
### Example 8: Operation-Based Overload (OpenAPI / Code Generators)
**Context**: When using a code generator (orval, openapi-generator, custom scripts) that produces typed operation definitions from an OpenAPI spec, pass the operation object directly to `apiRequest`. This eliminates manual `method`/`path` extraction and `typeof` assertions while preserving full type inference for request body, response, and query parameters. Available since v3.14.0.
**Implementation**:
```typescript
// Generated operation definition — structural typing, no import from playwright-utils needed
// type OperationShape = { path: string; method: 'POST'|'GET'|'PUT'|'DELETE'|'PATCH'|'HEAD'; response: unknown; request: unknown; query?: unknown }
import { test, expect } from '@seontechnologies/playwright-utils/api-request/fixtures';
// --- Basic usage: operation replaces method + path ---
test('should upsert person via operation overload', async ({ apiRequest }) => {
const { status, body } = await apiRequest({
operation: upsertPersonv2({ customerId }),
headers: getHeaders(customerId),
body: personInput, // compile-time typed as Schemas.PersonInput
});
expect(status).toBe(200);
expect(body.id).toBeDefined(); // body typed as Schemas.Person
});
// --- Typed query parameters (replaces string concatenation) ---
test('should list people with typed query', async ({ apiRequest }) => {
const { body } = await apiRequest({
operation: getPeoplev2({ customerId }),
headers: getHeaders(customerId),
query: { page: 0, page_size: 5 }, // typed from operation's query definition
});
expect(body.items).toHaveLength(5);
});
// --- Params escape hatch (pre-formatted query strings) ---
test('should fetch billing history with raw params', async ({ apiRequest }) => {
const { body } = await apiRequest({
operation: getBillingHistoryv2({ customerId }),
headers: getHeaders(customerId),
params: {
'filters[start_date]': getThisMonthTimestamp(),
'filters[date_type]': 'MONTH',
},
});
expect(body.entries.length).toBeGreaterThan(0);
});
// --- Works with recurse (polling) ---
test('should poll until person is reviewed', async ({ apiRequest, recurse }) => {
await recurse(
async () =>
apiRequest({
operation: getPersonv2({ customerId, hash }),
headers: getHeaders(customerId),
}),
(res) => {
expect(res.status).toBe(200);
expect(res.body.status).toBe('REVIEWED');
},
{ timeout: 30000, interval: 1000 },
);
});
// --- Schema validation chains work identically ---
test('should create movie with schema validation', async ({ apiRequest }) => {
const { body } = await apiRequest({
operation: createMovieOp,
headers: commonHeaders(authToken),
body: movie,
}).validateSchema(CreateMovieResponseSchema, {
shape: { status: 200, data: { name: movie.name } },
});
expect(body.data.id).toBeDefined();
});
```
**Key Points**:
- Pass `operation` instead of `method` + `path` — mutually exclusive at compile time
- Response body, request body, and query types inferred from operation definition
- Uses structural typing (duck typing) — works with any code generator producing `{ path, method, response, request, query? }`
- `query` field auto-serializes to bracket notation (`filters[type]=pep`, `ids[0]=10`)
- `params` escape hatch for pre-formatted strings — wins over `query` on conflict
- Fully composable with `recurse`, `validateSchema`, and all existing features
- `response`/`request`/`query` on the operation are type-level only — runtime never reads their values
## Comparison with Vanilla Playwright
| Vanilla Playwright | playwright-utils apiRequest |
| ---------------------------------------------- | ---------------------------------------------------------------------------------- |
| `const resp = await request.get('/api/users')` | `const { status, body } = await apiRequest({ method: 'GET', path: '/api/users' })` |
| `const body = await resp.json()` | Response already parsed |
| `expect(resp.ok()).toBeTruthy()` | Status code directly accessible |
| No retry logic | Auto-retry 5xx errors with backoff |
| No schema validation | Built-in multi-format validation |
| Manual error handling | Descriptive error messages |
## When to Use
**Use apiRequest for:**
- ✅ Pure API/service testing (no browser needed)
- ✅ Microservice integration testing
- ✅ GraphQL API testing
- ✅ Schema validation needs
- ✅ Tests requiring retry logic
- ✅ Background API calls in UI tests
- ✅ Contract testing support
- ✅ Type-safe API testing with OpenAPI-generated operations (v3.14.0+)
**Stick with vanilla Playwright for:**
- Simple one-off requests where utility overhead isn't worth it
- Testing Playwright's native features specifically
- Legacy tests where migration isn't justified
## Related Fragments
- `api-testing-patterns.md` - Comprehensive pure API testing patterns
- `overview.md` - Installation and design principles
- `auth-session.md` - Authentication token management
- `recurse.md` - Polling for async operations
- `fixtures-composition.md` - Combining utilities with mergeTests
- `log.md` - Logging API requests
- `contract-testing.md` - Pact contract testing
## Anti-Patterns
**❌ Ignoring retry failures:**
```typescript
try {
await apiRequest({ method: 'GET', path: '/api/unstable' });
} catch {
// Silent failure - loses retry information
}
```
**✅ Let retries happen, handle final failure:**
```typescript
await expect(apiRequest({ method: 'GET', path: '/api/unstable' })).rejects.toThrow(); // Retries happen automatically, then final error caught
```
**❌ Disabling TypeScript benefits:**
```typescript
const response: any = await apiRequest({ method: 'GET', path: '/users' });
```
**✅ Use generic types:**
```typescript
const { body } = await apiRequest<User[]>({ method: 'GET', path: '/users' });
// body is typed as User[]
```
**❌ Mixing operation overload with explicit generics:**
```typescript
// Don't pass a generic when using operation — types are inferred from the operation
const { body } = await apiRequest<MyType>({
operation: getPersonv2({ customerId }),
headers: getHeaders(customerId),
});
```
**✅ Let the operation infer the types:**
```typescript
const { body } = await apiRequest({
operation: getPersonv2({ customerId }),
headers: getHeaders(customerId),
});
// body type inferred from operation.response
```
**❌ Mixing operation with method/path:**
```typescript
// Compile error — operation and method/path are mutually exclusive
await apiRequest({
operation: getPersonv2({ customerId }),
method: 'GET', // Error: method?: never
path: '/api/person', // Error: path?: never
});
```