BMAD-METHOD/src/modules/bmm/testarch/knowledge/api-request.md

8.6 KiB

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.

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

Pattern Examples

Example 1: Basic API Request

Context: Making authenticated API requests with automatic retry and type safety.

Implementation:

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:

import { test } from '@seontechnologies/playwright-utils/api-request/fixtures';

test('should validate response schema', async ({ apiRequest }) => {
  // JSON Schema validation
  const response = 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

  // Zod schema validation
  import { z } from 'zod';

  const UserSchema = z.object({
    id: z.string(),
    name: z.string(),
    email: z.string().email(),
  });

  const response = await apiRequest({
    method: 'GET',
    path: '/api/users/123',
    validateSchema: UserSchema,
  });
  // Response body is type-safe AND validated
});

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:

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:

// 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:

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

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:

  • API endpoint testing
  • Background API calls in UI tests
  • Schema validation needs
  • Tests requiring retry logic
  • Typed API responses

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
  • 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

Anti-Patterns

Ignoring retry failures:

try {
  await apiRequest({ method: 'GET', path: '/api/unstable' });
} catch {
  // Silent failure - loses retry information
}

Let retries happen, handle final failure:

await expect(apiRequest({ method: 'GET', path: '/api/unstable' })).rejects.toThrow(); // Retries happen automatically, then final error caught

Disabling TypeScript benefits:

const response: any = await apiRequest({ method: 'GET', path: '/users' });

Use generic types:

const { body } = await apiRequest<User[]>({ method: 'GET', path: '/users' });
// body is typed as User[]