Du kan inte välja fler än 25 ämnen Ämnen måste starta med en bokstav eller siffra, kan innehålla bindestreck ('-') och vara max 35 tecken långa.

api-request.md 17KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563
  1. # API Request Utility
  2. ## Principle
  3. 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.
  4. ## Rationale
  5. Vanilla Playwright's request API requires boilerplate for common patterns:
  6. - Manual JSON parsing (`await response.json()`)
  7. - Repetitive status code checking
  8. - No built-in retry logic for transient failures
  9. - No schema validation
  10. - Complex URL construction
  11. The `apiRequest` utility provides:
  12. - **Automatic JSON parsing**: Response body pre-parsed
  13. - **Built-in retry**: 5xx errors retry with exponential backoff
  14. - **Schema validation**: Single-line validation (JSON Schema, Zod, OpenAPI)
  15. - **URL resolution**: Four-tier strategy (explicit > config > Playwright > direct)
  16. - **TypeScript generics**: Type-safe response bodies
  17. - **No browser required**: Pure API testing without browser overhead
  18. ## Pattern Examples
  19. ### Example 1: Basic API Request
  20. **Context**: Making authenticated API requests with automatic retry and type safety.
  21. **Implementation**:
  22. ```typescript
  23. import { test } from '@seontechnologies/playwright-utils/api-request/fixtures';
  24. test('should fetch user data', async ({ apiRequest }) => {
  25. const { status, body } = await apiRequest<User>({
  26. method: 'GET',
  27. path: '/api/users/123',
  28. headers: { Authorization: 'Bearer token' },
  29. });
  30. expect(status).toBe(200);
  31. expect(body.name).toBe('John Doe'); // TypeScript knows body is User
  32. });
  33. ```
  34. **Key Points**:
  35. - Generic type `<User>` provides TypeScript autocomplete for `body`
  36. - Status and body destructured from response
  37. - Headers passed as object
  38. - Automatic retry for 5xx errors (configurable)
  39. ### Example 2: Schema Validation (Single Line)
  40. **Context**: Validate API responses match expected schema with single-line syntax.
  41. **Implementation**:
  42. ```typescript
  43. import { test } from '@seontechnologies/playwright-utils/api-request/fixtures';
  44. import { z } from 'zod';
  45. // JSON Schema validation
  46. test('should validate response schema (JSON Schema)', async ({ apiRequest }) => {
  47. const { status, body } = await apiRequest({
  48. method: 'GET',
  49. path: '/api/users/123',
  50. validateSchema: {
  51. type: 'object',
  52. required: ['id', 'name', 'email'],
  53. properties: {
  54. id: { type: 'string' },
  55. name: { type: 'string' },
  56. email: { type: 'string', format: 'email' },
  57. },
  58. },
  59. });
  60. // Throws if schema validation fails
  61. expect(status).toBe(200);
  62. });
  63. // Zod schema validation
  64. const UserSchema = z.object({
  65. id: z.string(),
  66. name: z.string(),
  67. email: z.string().email(),
  68. });
  69. test('should validate response schema (Zod)', async ({ apiRequest }) => {
  70. const { status, body } = await apiRequest({
  71. method: 'GET',
  72. path: '/api/users/123',
  73. validateSchema: UserSchema,
  74. });
  75. // Response body is type-safe AND validated
  76. expect(status).toBe(200);
  77. expect(body.email).toContain('@');
  78. });
  79. ```
  80. **Key Points**:
  81. - Single `validateSchema` parameter
  82. - Supports JSON Schema, Zod, YAML files, OpenAPI specs
  83. - Throws on validation failure with detailed errors
  84. - Zero boilerplate validation code
  85. ### Example 3: POST with Body and Retry Configuration
  86. **Context**: Creating resources with custom retry behavior for error testing.
  87. **Implementation**:
  88. ```typescript
  89. test('should create user', async ({ apiRequest }) => {
  90. const newUser = {
  91. name: 'Jane Doe',
  92. email: 'jane@example.com',
  93. };
  94. const { status, body } = await apiRequest({
  95. method: 'POST',
  96. path: '/api/users',
  97. body: newUser, // Automatically sent as JSON
  98. headers: { Authorization: 'Bearer token' },
  99. });
  100. expect(status).toBe(201);
  101. expect(body.id).toBeDefined();
  102. });
  103. // Disable retry for error testing
  104. test('should handle 500 errors', async ({ apiRequest }) => {
  105. await expect(
  106. apiRequest({
  107. method: 'GET',
  108. path: '/api/error',
  109. retryConfig: { maxRetries: 0 }, // Disable retry
  110. }),
  111. ).rejects.toThrow('Request failed with status 500');
  112. });
  113. ```
  114. **Key Points**:
  115. - `body` parameter auto-serializes to JSON
  116. - Default retry: 5xx errors, 3 retries, exponential backoff
  117. - Disable retry with `retryConfig: { maxRetries: 0 }`
  118. - Only 5xx errors retry (4xx errors fail immediately)
  119. ### Example 4: URL Resolution Strategy
  120. **Context**: Flexible URL handling for different environments and test contexts.
  121. **Implementation**:
  122. ```typescript
  123. // Strategy 1: Explicit baseUrl (highest priority)
  124. await apiRequest({
  125. method: 'GET',
  126. path: '/users',
  127. baseUrl: 'https://api.example.com', // Uses https://api.example.com/users
  128. });
  129. // Strategy 2: Config baseURL (from fixture)
  130. import { test } from '@seontechnologies/playwright-utils/api-request/fixtures';
  131. test.use({ configBaseUrl: 'https://staging-api.example.com' });
  132. test('uses config baseURL', async ({ apiRequest }) => {
  133. await apiRequest({
  134. method: 'GET',
  135. path: '/users', // Uses https://staging-api.example.com/users
  136. });
  137. });
  138. // Strategy 3: Playwright baseURL (from playwright.config.ts)
  139. // playwright.config.ts
  140. export default defineConfig({
  141. use: {
  142. baseURL: 'https://api.example.com',
  143. },
  144. });
  145. test('uses Playwright baseURL', async ({ apiRequest }) => {
  146. await apiRequest({
  147. method: 'GET',
  148. path: '/users', // Uses https://api.example.com/users
  149. });
  150. });
  151. // Strategy 4: Direct path (full URL)
  152. await apiRequest({
  153. method: 'GET',
  154. path: 'https://api.example.com/users', // Full URL works too
  155. });
  156. ```
  157. **Key Points**:
  158. - Four-tier resolution: explicit > config > Playwright > direct
  159. - Trailing slashes normalized automatically
  160. - Environment-specific baseUrl easy to configure
  161. ### Example 5: Integration with Recurse (Polling)
  162. **Context**: Waiting for async operations to complete (background jobs, eventual consistency).
  163. **Implementation**:
  164. ```typescript
  165. import { test } from '@seontechnologies/playwright-utils/fixtures';
  166. test('should poll until job completes', async ({ apiRequest, recurse }) => {
  167. // Create job
  168. const { body } = await apiRequest({
  169. method: 'POST',
  170. path: '/api/jobs',
  171. body: { type: 'export' },
  172. });
  173. const jobId = body.id;
  174. // Poll until ready
  175. const completedJob = await recurse(
  176. () => apiRequest({ method: 'GET', path: `/api/jobs/${jobId}` }),
  177. (response) => response.body.status === 'completed',
  178. { timeout: 60000, interval: 2000 },
  179. );
  180. expect(completedJob.body.result).toBeDefined();
  181. });
  182. ```
  183. **Key Points**:
  184. - `apiRequest` returns full response object
  185. - `recurse` polls until predicate returns true
  186. - Composable utilities work together seamlessly
  187. ### Example 6: Microservice Testing (Multiple Services)
  188. **Context**: Test interactions between microservices without a browser.
  189. **Implementation**:
  190. ```typescript
  191. import { test, expect } from '@seontechnologies/playwright-utils/fixtures';
  192. const USER_SERVICE = process.env.USER_SERVICE_URL || 'http://localhost:3001';
  193. const ORDER_SERVICE = process.env.ORDER_SERVICE_URL || 'http://localhost:3002';
  194. test.describe('Microservice Integration', () => {
  195. test('should validate cross-service user lookup', async ({ apiRequest }) => {
  196. // Create user in user-service
  197. const { body: user } = await apiRequest({
  198. method: 'POST',
  199. path: '/api/users',
  200. baseUrl: USER_SERVICE,
  201. body: { name: 'Test User', email: 'test@example.com' },
  202. });
  203. // Create order in order-service (validates user via user-service)
  204. const { status, body: order } = await apiRequest({
  205. method: 'POST',
  206. path: '/api/orders',
  207. baseUrl: ORDER_SERVICE,
  208. body: {
  209. userId: user.id,
  210. items: [{ productId: 'prod-1', quantity: 2 }],
  211. },
  212. });
  213. expect(status).toBe(201);
  214. expect(order.userId).toBe(user.id);
  215. });
  216. test('should reject order for invalid user', async ({ apiRequest }) => {
  217. const { status, body } = await apiRequest({
  218. method: 'POST',
  219. path: '/api/orders',
  220. baseUrl: ORDER_SERVICE,
  221. body: {
  222. userId: 'non-existent-user',
  223. items: [{ productId: 'prod-1', quantity: 1 }],
  224. },
  225. });
  226. expect(status).toBe(400);
  227. expect(body.code).toBe('INVALID_USER');
  228. });
  229. });
  230. ```
  231. **Key Points**:
  232. - Test multiple services without browser
  233. - Use `baseUrl` to target different services
  234. - Validate cross-service communication
  235. - Pure API testing - fast and reliable
  236. ### Example 7: GraphQL API Testing
  237. **Context**: Test GraphQL endpoints with queries and mutations.
  238. **Implementation**:
  239. ```typescript
  240. test.describe('GraphQL API', () => {
  241. const GRAPHQL_ENDPOINT = '/graphql';
  242. test('should query users via GraphQL', async ({ apiRequest }) => {
  243. const query = `
  244. query GetUsers($limit: Int) {
  245. users(limit: $limit) {
  246. id
  247. name
  248. email
  249. }
  250. }
  251. `;
  252. const { status, body } = await apiRequest({
  253. method: 'POST',
  254. path: GRAPHQL_ENDPOINT,
  255. body: {
  256. query,
  257. variables: { limit: 10 },
  258. },
  259. });
  260. expect(status).toBe(200);
  261. expect(body.errors).toBeUndefined();
  262. expect(body.data.users).toHaveLength(10);
  263. });
  264. test('should create user via mutation', async ({ apiRequest }) => {
  265. const mutation = `
  266. mutation CreateUser($input: CreateUserInput!) {
  267. createUser(input: $input) {
  268. id
  269. name
  270. }
  271. }
  272. `;
  273. const { status, body } = await apiRequest({
  274. method: 'POST',
  275. path: GRAPHQL_ENDPOINT,
  276. body: {
  277. query: mutation,
  278. variables: {
  279. input: { name: 'GraphQL User', email: 'gql@example.com' },
  280. },
  281. },
  282. });
  283. expect(status).toBe(200);
  284. expect(body.data.createUser.id).toBeDefined();
  285. });
  286. });
  287. ```
  288. **Key Points**:
  289. - GraphQL via POST request
  290. - Variables in request body
  291. - Check `body.errors` for GraphQL errors (not status code)
  292. - Works for queries and mutations
  293. ### Example 8: Operation-Based Overload (OpenAPI / Code Generators)
  294. **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.
  295. **Implementation**:
  296. ```typescript
  297. // Generated operation definition — structural typing, no import from playwright-utils needed
  298. // type OperationShape = { path: string; method: 'POST'|'GET'|'PUT'|'DELETE'|'PATCH'|'HEAD'; response: unknown; request: unknown; query?: unknown }
  299. import { test, expect } from '@seontechnologies/playwright-utils/api-request/fixtures';
  300. // --- Basic usage: operation replaces method + path ---
  301. test('should upsert person via operation overload', async ({ apiRequest }) => {
  302. const { status, body } = await apiRequest({
  303. operation: upsertPersonv2({ customerId }),
  304. headers: getHeaders(customerId),
  305. body: personInput, // compile-time typed as Schemas.PersonInput
  306. });
  307. expect(status).toBe(200);
  308. expect(body.id).toBeDefined(); // body typed as Schemas.Person
  309. });
  310. // --- Typed query parameters (replaces string concatenation) ---
  311. test('should list people with typed query', async ({ apiRequest }) => {
  312. const { body } = await apiRequest({
  313. operation: getPeoplev2({ customerId }),
  314. headers: getHeaders(customerId),
  315. query: { page: 0, page_size: 5 }, // typed from operation's query definition
  316. });
  317. expect(body.items).toHaveLength(5);
  318. });
  319. // --- Params escape hatch (pre-formatted query strings) ---
  320. test('should fetch billing history with raw params', async ({ apiRequest }) => {
  321. const { body } = await apiRequest({
  322. operation: getBillingHistoryv2({ customerId }),
  323. headers: getHeaders(customerId),
  324. params: {
  325. 'filters[start_date]': getThisMonthTimestamp(),
  326. 'filters[date_type]': 'MONTH',
  327. },
  328. });
  329. expect(body.entries.length).toBeGreaterThan(0);
  330. });
  331. // --- Works with recurse (polling) ---
  332. test('should poll until person is reviewed', async ({ apiRequest, recurse }) => {
  333. await recurse(
  334. async () =>
  335. apiRequest({
  336. operation: getPersonv2({ customerId, hash }),
  337. headers: getHeaders(customerId),
  338. }),
  339. (res) => {
  340. expect(res.status).toBe(200);
  341. expect(res.body.status).toBe('REVIEWED');
  342. },
  343. { timeout: 30000, interval: 1000 },
  344. );
  345. });
  346. // --- Schema validation chains work identically ---
  347. test('should create movie with schema validation', async ({ apiRequest }) => {
  348. const { body } = await apiRequest({
  349. operation: createMovieOp,
  350. headers: commonHeaders(authToken),
  351. body: movie,
  352. }).validateSchema(CreateMovieResponseSchema, {
  353. shape: { status: 200, data: { name: movie.name } },
  354. });
  355. expect(body.data.id).toBeDefined();
  356. });
  357. ```
  358. **Key Points**:
  359. - Pass `operation` instead of `method` + `path` — mutually exclusive at compile time
  360. - Response body, request body, and query types inferred from operation definition
  361. - Uses structural typing (duck typing) — works with any code generator producing `{ path, method, response, request, query? }`
  362. - `query` field auto-serializes to bracket notation (`filters[type]=pep`, `ids[0]=10`)
  363. - `params` escape hatch for pre-formatted strings — wins over `query` on conflict
  364. - Fully composable with `recurse`, `validateSchema`, and all existing features
  365. - `response`/`request`/`query` on the operation are type-level only — runtime never reads their values
  366. ## Comparison with Vanilla Playwright
  367. | Vanilla Playwright | playwright-utils apiRequest |
  368. | ---------------------------------------------- | ---------------------------------------------------------------------------------- |
  369. | `const resp = await request.get('/api/users')` | `const { status, body } = await apiRequest({ method: 'GET', path: '/api/users' })` |
  370. | `const body = await resp.json()` | Response already parsed |
  371. | `expect(resp.ok()).toBeTruthy()` | Status code directly accessible |
  372. | No retry logic | Auto-retry 5xx errors with backoff |
  373. | No schema validation | Built-in multi-format validation |
  374. | Manual error handling | Descriptive error messages |
  375. ## When to Use
  376. **Use apiRequest for:**
  377. - ✅ Pure API/service testing (no browser needed)
  378. - ✅ Microservice integration testing
  379. - ✅ GraphQL API testing
  380. - ✅ Schema validation needs
  381. - ✅ Tests requiring retry logic
  382. - ✅ Background API calls in UI tests
  383. - ✅ Contract testing support
  384. - ✅ Type-safe API testing with OpenAPI-generated operations (v3.14.0+)
  385. **Stick with vanilla Playwright for:**
  386. - Simple one-off requests where utility overhead isn't worth it
  387. - Testing Playwright's native features specifically
  388. - Legacy tests where migration isn't justified
  389. ## Related Fragments
  390. - `api-testing-patterns.md` - Comprehensive pure API testing patterns
  391. - `overview.md` - Installation and design principles
  392. - `auth-session.md` - Authentication token management
  393. - `recurse.md` - Polling for async operations
  394. - `fixtures-composition.md` - Combining utilities with mergeTests
  395. - `log.md` - Logging API requests
  396. - `contract-testing.md` - Pact contract testing
  397. ## Anti-Patterns
  398. **❌ Ignoring retry failures:**
  399. ```typescript
  400. try {
  401. await apiRequest({ method: 'GET', path: '/api/unstable' });
  402. } catch {
  403. // Silent failure - loses retry information
  404. }
  405. ```
  406. **✅ Let retries happen, handle final failure:**
  407. ```typescript
  408. await expect(apiRequest({ method: 'GET', path: '/api/unstable' })).rejects.toThrow(); // Retries happen automatically, then final error caught
  409. ```
  410. **❌ Disabling TypeScript benefits:**
  411. ```typescript
  412. const response: any = await apiRequest({ method: 'GET', path: '/users' });
  413. ```
  414. **✅ Use generic types:**
  415. ```typescript
  416. const { body } = await apiRequest<User[]>({ method: 'GET', path: '/users' });
  417. // body is typed as User[]
  418. ```
  419. **❌ Mixing operation overload with explicit generics:**
  420. ```typescript
  421. // Don't pass a generic when using operation — types are inferred from the operation
  422. const { body } = await apiRequest<MyType>({
  423. operation: getPersonv2({ customerId }),
  424. headers: getHeaders(customerId),
  425. });
  426. ```
  427. **✅ Let the operation infer the types:**
  428. ```typescript
  429. const { body } = await apiRequest({
  430. operation: getPersonv2({ customerId }),
  431. headers: getHeaders(customerId),
  432. });
  433. // body type inferred from operation.response
  434. ```
  435. **❌ Mixing operation with method/path:**
  436. ```typescript
  437. // Compile error — operation and method/path are mutually exclusive
  438. await apiRequest({
  439. operation: getPersonv2({ customerId }),
  440. method: 'GET', // Error: method?: never
  441. path: '/api/person', // Error: path?: never
  442. });
  443. ```