Files
rust_browser/_bmad/tea/testarch/knowledge/api-request.md
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

17 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. 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:

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';
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:

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

Example 6: Microservice Testing (Multiple Services)

Context: Test interactions between microservices without a browser.

Implementation:

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:

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:

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

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[]

Mixing operation overload with explicit generics:

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

const { body } = await apiRequest({
  operation: getPersonv2({ customerId }),
  headers: getHeaders(customerId),
});
// body type inferred from operation.response

Mixing operation with method/path:

// 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
});