diff --git a/README.md b/README.md index 7bf34ba1..d4db457f 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,7 @@ Zimic provides a flexible and type-safe way to mock HTTP requests. - [HTTP `handler.bypass()`](#http-handlerbypass) - [HTTP `handler.clear()`](#http-handlerclear) - [HTTP `handler.requests()`](#http-handlerrequests) + - [Intercepted HTTP resources](#intercepted-http-resources) - [CLI](#cli) - [`zimic`](#zimic) - [`zimic browser`](#zimic-browser) @@ -738,7 +739,8 @@ const interceptor = http.createInterceptor({ ``` `onUnhandledRequest` also accepts a function to dynamically choose when to ignore an unhandled request. Calling -`await context.log()` logs the request to the console. +`await context.log()` logs the request to the console. Learn more about the `request` object at +[Intercepted HTTP resources](#intercepted-http-resources). ```ts import { http } from 'zimic/interceptor'; @@ -1104,6 +1106,27 @@ const interceptor = http.createInterceptor<{ > types do. Part of Zimic's JSON validation relies on index signatures. To workaround this, you can declare JSON bodies > using `type`. As an extra step to make sure the type is a valid JSON, you can use the utility type `JSONValue`. +> [!TIP] +> +> The utility type `JSONSerialized`, exported from `zimic`, can be handy to infer the serialized type of an object. It +> converts `Date`'s to strings, removes function properties and serializes nested objects and arrays. + +```ts +import { JSONSerialized } from 'zimic'; + +class User { + name: string; + age: number; + createdAt: Date; + method() { + // ... + } +} + +type SerializedUser = JSONSerialized; +// { name: string, age: number, createdAt: string } +``` +
Declaring a request type with form data body: @@ -1913,7 +1936,7 @@ const creationHandler = await interceptor
For blob bodies to be correctly parsed, make sure that the intercepted requests have the header `content-type` -indicating a binary data, such as `application/octet-stream`, `image/png`, `audio/mpeg`, etc. +indicating a binary data, such as `application/octet-stream`, `image/png`, `audio/mp3`, etc. @@ -2001,7 +2024,8 @@ const creationHandler = await interceptor ##### Computed restrictions -A function is also supported to declare restrictions in case they are dynamic. +A function is also supported to declare restrictions in case they are dynamic. Learn more about the `request` object at +[Intercepted HTTP resources](#intercepted-http-resources).
Local @@ -2035,8 +2059,6 @@ const creationHandler = await interceptor
-The `request` parameter represents the intercepted request, containing useful properties such as `request.body`, -`request.headers`, `request.pathParams`, and `request.searchParams`, which are typed based on the interceptor schema. The function should return a boolean: `true` if the request matches the handler and should receive the mock response; `false` otherwise. @@ -2219,7 +2241,8 @@ const listHandler = await interceptor.get('/users').respond({ ##### Computed responses -A function is also supported to declare a response in case it is dynamic: +A function is also supported to declare a response in case it is dynamic. Learn more about the `request` object at +[Intercepted HTTP resources](#intercepted-http-resources).
Local @@ -2247,9 +2270,6 @@ const listHandler = await interceptor.get('/users').respond((request) => {
-The `request` parameter represents the intercepted request, containing useful properties such as `request.body`, -`request.headers`, `request.pathParams`, and `request.searchParams`, which are typed based on the interceptor schema. - #### HTTP `handler.bypass()` Clears any response declared with [`handler.respond(declaration)`](#http-handlerresponddeclaration), making the handler @@ -2353,7 +2373,8 @@ await otherListHandler.requests(); // Now empty #### HTTP `handler.requests()` Returns the intercepted requests that matched this handler, along with the responses returned to each of them. This is -useful for testing that the correct requests were made by your application. +useful for testing that the correct requests were made by your application. Learn more about the `request` and +`response` objects at [Intercepted HTTP resources](#intercepted-http-resources).
Local @@ -2371,10 +2392,12 @@ await fetch(`http://localhost:3000/users/${1}`, { body: JSON.stringify({ username: 'new' }), }); -const updateRequests = updateHandler.requests(); +const updateRequests = await updateHandler.requests(); expect(updateRequests).toHaveLength(1); expect(updateRequests[0].pathParams).toEqual({ id: '1' }); expect(updateRequests[0].body).toEqual({ username: 'new' }); +expect(updateRequests[0].response.status).toBe(200); +expect(updateRequests[0].response.body).toEqual([{ username: 'new' }]); ```
Remote @@ -2397,40 +2420,48 @@ const updateRequests = await updateHandler.requests(); expect(updateRequests).toHaveLength(1); expect(updateRequests[0].pathParams).toEqual({ id: '1' }); expect(updateRequests[0].body).toEqual({ username: 'new' }); +expect(updateRequests[0].response.status).toBe(200); +expect(updateRequests[0].response.body).toEqual([{ username: 'new' }]); ```
-The return by `requests` are simplified objects based on the -[`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request) and -[`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response) web APIs, containing the body in `request.body`, -typed headers in `request.headers`, typed path params in `request.pathParams`, and typed search params in -`request.searchParams`. +### Intercepted HTTP resources -The body is already parsed based on the header `content-type` of the request or response. JSON objects, form data, -search params, blobs, and plain text are supported. If no `content-type` exists, Zimic tries to parse the body as JSON -and falls back to plain text if it fails. +The intercepted requests and responses are typed based on their [interceptor schema](#declaring-http-service-schemas). +They are available as simplified objects based on the +[`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request) and +[`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response) web APIs. `body` contains the parsed body, while +typed headers, path params and search params are in `headers`, `pathParams`, and `searchParams`, respectively. + +The body is automatically parsed based on the header `content-type` of the request or response. The following table +shows how each type is parsed, where `*` indicates any other resource that does not match the previous types: + +| `content-type` | Parsed to | +| ----------------------------------- | --------------------------------------- | +| `application/json` | `JSON` | +| `application/xml` | `String` | +| `application/x-www-form-urlencoded` | [`HttpSearchParams`](#httpsearchparams) | +| `application/*` (others) | `Blob` | +| `multipart/form-data` | [`HttpFormData`](#httpformdata) | +| `multipart/*` (others) | `Blob` | +| `text/*` | `String` | +| `image/*` | `Blob` | +| `audio/*` | `Blob` | +| `font/*` | `Blob` | +| `video/*` | `Blob` | +| `*/*` (others) | `JSON` if possible, otherwise `String` | + +If no `content-type` exists or it is unknown, Zimic tries to parse the body as JSON and falls back to plain text if it +fails. If you need access to the original `Request` and `Response` objects, you can use the `request.raw` property: -
Local - ```ts -const listRequests = listHandler.requests(); -console.log(listRequests[0].raw); // Request{} -console.log(listRequests[0].response.raw); // Response{} +console.log(request.raw); // Request{} +console.log(request.response.raw); // Response{} ``` -
Remote - -```ts -const listRequests = await listHandler.requests(); -console.log(listRequests[0].raw); // Request{} -console.log(listRequests[0].response.raw); // Response{} -``` - -
- ## CLI ### `zimic` diff --git a/packages/zimic/src/interceptor/http/interceptor/__tests__/shared/bodies.ts b/packages/zimic/src/interceptor/http/interceptor/__tests__/shared/bodies.ts index edc1ffd8..d63d23ff 100644 --- a/packages/zimic/src/interceptor/http/interceptor/__tests__/shared/bodies.ts +++ b/packages/zimic/src/interceptor/http/interceptor/__tests__/shared/bodies.ts @@ -11,6 +11,7 @@ import RemoteHttpRequestHandler from '@/interceptor/http/requestHandler/RemoteHt import { JSONValue } from '@/types/json'; import { getCrypto } from '@/utils/crypto'; import { getFile } from '@/utils/files'; +import { randomInt } from '@/utils/numbers'; import { joinURL } from '@/utils/urls'; import { usingIgnoredConsole } from '@tests/utils/console'; import { usingHttpInterceptor } from '@tests/utils/interceptors'; @@ -18,22 +19,32 @@ import { usingHttpInterceptor } from '@tests/utils/interceptors'; import { HttpInterceptorOptions } from '../../types/options'; import { RuntimeSharedHttpInterceptorTestsOptions } from './types'; -export async function createSampleFile( - format: 'image' | 'audio' | 'font' | 'video' | 'binary', - content: string[], +export async function createRandomFile( + contentType: + | 'image/png' + | 'audio/mp3' + | 'font/ttf' + | 'video/mp4' + | 'application/pdf' + | 'application/octet-stream' + | 'multipart/mixed', ): Promise { const File = await getFile(); - if (format === 'image') { - return new File(content, 'image.png', { type: 'image/png' }); - } else if (format === 'audio') { - return new File(content, 'audio.mp3', { type: 'audio/mpeg' }); - } else if (format === 'font') { - return new File(content, 'font.ttf', { type: 'font/ttf' }); - } else if (format === 'video') { - return new File(content, 'video.mp4', { type: 'video/mp4' }); + const randomContent = Uint8Array.from({ length: 1024 }, () => randomInt(0, 256)); + + if (contentType === 'image/png') { + return new File([randomContent], 'image.png', { type: contentType }); + } else if (contentType === 'audio/mp3') { + return new File([randomContent], 'audio.mp3', { type: contentType }); + } else if (contentType === 'font/ttf') { + return new File([randomContent], 'font.ttf', { type: contentType }); + } else if (contentType === 'video/mp4') { + return new File([randomContent], 'video.mp4', { type: contentType }); + } else if (contentType === 'application/pdf') { + return new File([randomContent], 'file.pdf', { type: contentType }); } else { - return new File(content, 'file.bin', { type: 'application/octet-stream' }); + return new File([randomContent], 'file.bin', { type: contentType }); } } @@ -1124,6 +1135,72 @@ export async function declareBodyHttpInterceptorTests(options: RuntimeSharedHttp }); }); + it(`should consider ${method} request or response XML bodies as plain text`, async () => { + type MethodSchema = HttpSchema.Method<{ + request: { + headers: { 'content-type': string }; + body: string; + }; + response: { + 200: { + headers: { 'content-type': string }; + body: string; + }; + }; + }>; + + await usingHttpInterceptor<{ + '/users/:id': { + POST: MethodSchema; + PUT: MethodSchema; + PATCH: MethodSchema; + DELETE: MethodSchema; + }; + }>(interceptorOptions, async (interceptor) => { + const handler = await promiseIfRemote( + interceptor[lowerMethod]('/users/:id').respond((request) => { + expectTypeOf(request.body).toEqualTypeOf(); + expect(request.body).toBe('content'); + + return { + status: 200, + headers: { 'content-type': 'application/xml' }, + body: 'content-response', + }; + }), + interceptor, + ); + expect(handler).toBeInstanceOf(Handler); + + let requests = await promiseIfRemote(handler.requests(), interceptor); + expect(requests).toHaveLength(0); + + const response = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { + method, + headers: { 'content-type': 'application/xml' }, + body: 'content', + }); + expect(response.status).toBe(200); + + const fetchedBody = await response.text(); + expect(fetchedBody).toBe('content-response'); + + requests = await promiseIfRemote(handler.requests(), interceptor); + expect(requests).toHaveLength(1); + const [request] = requests; + + expect(request).toBeInstanceOf(Request); + expect(request.headers.get('content-type')).toBe('application/xml'); + expectTypeOf(request.body).toEqualTypeOf(); + expect(request.body).toBe('content'); + + expect(request.response).toBeInstanceOf(Response); + expect(request.response.headers.get('content-type')).toBe('application/xml'); + expectTypeOf(request.response.body).toEqualTypeOf(); + expect(request.response.body).toBe('content-response'); + }); + }); + it(`should consider empty ${method} request or response plain text bodies as null`, async () => { type MethodSchema = HttpSchema.Method<{ request: { @@ -1190,75 +1267,80 @@ export async function declareBodyHttpInterceptorTests(options: RuntimeSharedHttp }); describe('Blob', () => { - it.each(['binary', 'image', 'audio', 'font', 'video'] as const)( - `should support intercepting ${method} requests having a binary body: %s`, - async (format) => { - type MethodSchema = HttpSchema.Method<{ - request: { body: Blob }; - response: { 200: { body: Blob } }; - }>; - - await usingHttpInterceptor<{ - '/users/:id': { - POST: MethodSchema; - PUT: MethodSchema; - PATCH: MethodSchema; - DELETE: MethodSchema; - }; - }>(interceptorOptions, async (interceptor) => { - const responseFile = await createSampleFile(format, ['response']); + it.each([ + 'image/png', + 'audio/mp3', + 'font/ttf', + 'video/mp4', + 'application/pdf', + 'application/octet-stream', + 'multipart/mixed', + ] as const)(`should support intercepting ${method} requests having a binary body: %s`, async (contentType) => { + type MethodSchema = HttpSchema.Method<{ + request: { body: Blob }; + response: { 200: { body: Blob } }; + }>; - const handler = await promiseIfRemote( - interceptor[lowerMethod]('/users/:id').respond((request) => { - expectTypeOf(request.body).toEqualTypeOf(); - expect(request.body).toBeInstanceOf(Blob); + await usingHttpInterceptor<{ + '/users/:id': { + POST: MethodSchema; + PUT: MethodSchema; + PATCH: MethodSchema; + DELETE: MethodSchema; + }; + }>(interceptorOptions, async (interceptor) => { + const responseFile = await createRandomFile(contentType); - return { status: 200, body: responseFile }; - }), - interceptor, - ); - expect(handler).toBeInstanceOf(Handler); + const handler = await promiseIfRemote( + interceptor[lowerMethod]('/users/:id').respond((request) => { + expectTypeOf(request.body).toEqualTypeOf(); + expect(request.body).toBeInstanceOf(Blob); - let requests = await promiseIfRemote(handler.requests(), interceptor); - expect(requests).toHaveLength(0); + return { status: 200, body: responseFile }; + }), + interceptor, + ); + expect(handler).toBeInstanceOf(Handler); - const requestFile = await createSampleFile(format, ['request']); + let requests = await promiseIfRemote(handler.requests(), interceptor); + expect(requests).toHaveLength(0); - const response = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { - method, - headers: { 'content-type': requestFile.type }, - body: requestFile, - }); - expect(response.status).toBe(200); + const requestFile = await createRandomFile(contentType); - const fetchedFile = await response.blob(); - expect(fetchedFile).toBeInstanceOf(Blob); - expect(fetchedFile.type).toBe(responseFile.type); - expect(fetchedFile.size).toBe(responseFile.size); - expect(await fetchedFile.text()).toEqual(await responseFile.text()); - - requests = await promiseIfRemote(handler.requests(), interceptor); - expect(requests).toHaveLength(1); - const [request] = requests; - - expect(request).toBeInstanceOf(Request); - expect(request.headers.get('content-type')).toBe(requestFile.type); - expectTypeOf(request.body).toEqualTypeOf(); - expect(request.body).toBeInstanceOf(Blob); - expect(request.body.type).toBe(requestFile.type); - expect(request.body.size).toBe(requestFile.size); - expect(await request.body.text()).toEqual(await requestFile.text()); - - expect(request.response).toBeInstanceOf(Response); - expect(request.response.headers.get('content-type')).toBe(responseFile.type); - expectTypeOf(request.response.body).toEqualTypeOf(); - expect(request.response.body).toBeInstanceOf(Blob); - expect(request.response.body.type).toBe(responseFile.type); - expect(request.response.body.size).toBe(responseFile.size); - expect(await request.response.body.text()).toEqual(await responseFile.text()); + const response = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { + method, + headers: { 'content-type': contentType }, + body: requestFile, }); - }, - ); + expect(response.status).toBe(200); + + const fetchedFile = await response.blob(); + expect(fetchedFile).toBeInstanceOf(Blob); + expect(fetchedFile.type).toBe(responseFile.type); + expect(fetchedFile.size).toBe(responseFile.size); + expect(await fetchedFile.text()).toEqual(await responseFile.text()); + + requests = await promiseIfRemote(handler.requests(), interceptor); + expect(requests).toHaveLength(1); + const [request] = requests; + + expect(request).toBeInstanceOf(Request); + expect(request.headers.get('content-type')).toBe(contentType); + expectTypeOf(request.body).toEqualTypeOf(); + expect(request.body).toBeInstanceOf(Blob); + expect(request.body.type).toBe(contentType); + expect(request.body.size).toBe(requestFile.size); + expect(await request.body.text()).toEqual(await requestFile.text()); + + expect(request.response).toBeInstanceOf(Response); + expect(request.response.headers.get('content-type')).toBe(responseFile.type); + expectTypeOf(request.response.body).toEqualTypeOf(); + expect(request.response.body).toBeInstanceOf(Blob); + expect(request.response.body.type).toBe(responseFile.type); + expect(request.response.body.size).toBe(responseFile.size); + expect(await request.response.body.text()).toEqual(await responseFile.text()); + }); + }); it(`should consider empty ${method} request or response binary bodies as blob`, async () => { type MethodSchema = HttpSchema.Method<{ diff --git a/packages/zimic/src/interceptor/http/interceptorWorker/HttpInterceptorWorker.ts b/packages/zimic/src/interceptor/http/interceptorWorker/HttpInterceptorWorker.ts index 20556ab9..8eb8edd6 100644 --- a/packages/zimic/src/interceptor/http/interceptorWorker/HttpInterceptorWorker.ts +++ b/packages/zimic/src/interceptor/http/interceptorWorker/HttpInterceptorWorker.ts @@ -332,15 +332,16 @@ abstract class HttpInterceptorWorker { if (contentType.startsWith('application/x-www-form-urlencoded')) { return await this.parseRawBodyAsSearchParams(resource); } - if (contentType.startsWith('text/')) { + if (contentType.startsWith('text/') || contentType.startsWith('application/xml')) { return await this.parseRawBodyAsText(resource); } if ( - contentType.startsWith('application/octet-stream') || + contentType.startsWith('application/') || contentType.startsWith('image/') || contentType.startsWith('audio/') || contentType.startsWith('font/') || - contentType.startsWith('video/') + contentType.startsWith('video/') || + contentType.startsWith('multipart/') ) { return await this.parseRawBodyAsBlob(resource); } diff --git a/packages/zimic/src/interceptor/http/requestHandler/HttpRequestHandlerClient.ts b/packages/zimic/src/interceptor/http/requestHandler/HttpRequestHandlerClient.ts index dedce577..5b898cd8 100644 --- a/packages/zimic/src/interceptor/http/requestHandler/HttpRequestHandlerClient.ts +++ b/packages/zimic/src/interceptor/http/requestHandler/HttpRequestHandlerClient.ts @@ -8,7 +8,7 @@ import { HttpServiceSchemaPath, } from '@/http/types/schema'; import { Default } from '@/types/utils'; -import { blobContains, blobEquals } from '@/utils/blob'; +import { blobContains, blobEquals } from '@/utils/data'; import { jsonContains, jsonEquals } from '@/utils/json'; import HttpInterceptorClient from '../interceptor/HttpInterceptorClient'; diff --git a/packages/zimic/src/utils/blob.ts b/packages/zimic/src/utils/blob.ts deleted file mode 100644 index f9d39cb0..00000000 --- a/packages/zimic/src/utils/blob.ts +++ /dev/null @@ -1,11 +0,0 @@ -export async function blobEquals(blob: Blob, otherBlob: Blob) { - return ( - blob.type === otherBlob.type && blob.size === otherBlob.size && (await blob.text()) === (await otherBlob.text()) - ); -} - -export async function blobContains(blob: Blob, otherBlob: Blob) { - return ( - blob.type === otherBlob.type && blob.size >= otherBlob.size && (await blob.text()).includes(await otherBlob.text()) - ); -} diff --git a/packages/zimic/src/utils/data.ts b/packages/zimic/src/utils/data.ts new file mode 100644 index 00000000..2988907f --- /dev/null +++ b/packages/zimic/src/utils/data.ts @@ -0,0 +1,32 @@ +import { isClientSide } from './environment'; + +export async function blobEquals(blob: Blob, otherBlob: Blob) { + return ( + blob.type === otherBlob.type && blob.size === otherBlob.size && (await blob.text()) === (await otherBlob.text()) + ); +} + +export async function blobContains(blob: Blob, otherBlob: Blob) { + return ( + blob.type === otherBlob.type && blob.size >= otherBlob.size && (await blob.text()).includes(await otherBlob.text()) + ); +} + +export function convertArrayBufferToBase64(buffer: ArrayBuffer) { + if (isClientSide()) { + const bufferAsString = String.fromCharCode(...new Uint8Array(buffer)); + return btoa(bufferAsString); + } else { + return Buffer.from(buffer).toString('base64'); + } +} + +export function convertBase64ToArrayBuffer(base64Value: string) { + if (isClientSide()) { + const bufferAsString = atob(base64Value); + const array = Uint8Array.from(bufferAsString, (character) => character.charCodeAt(0)); + return array.buffer; + } else { + return Buffer.from(base64Value, 'base64'); + } +} diff --git a/packages/zimic/src/utils/fetch.ts b/packages/zimic/src/utils/fetch.ts index 7657ccca..71609945 100644 --- a/packages/zimic/src/utils/fetch.ts +++ b/packages/zimic/src/utils/fetch.ts @@ -1,5 +1,7 @@ import { JSONValue } from '@/types/json'; +import { convertArrayBufferToBase64, convertBase64ToArrayBuffer } from './data'; + export async function fetchWithTimeout(url: URL | RequestInfo, options: RequestInit & { timeout: number }) { const abort = new AbortController(); @@ -32,7 +34,7 @@ export type SerializedHttpRequest = JSONValue<{ export async function serializeRequest(request: Request): Promise { const requestClone = request.clone(); - const serializedBody = request.body ? await requestClone.text() : null; + const serializedBody = requestClone.body ? convertArrayBufferToBase64(await requestClone.arrayBuffer()) : null; return { url: request.url, @@ -51,6 +53,8 @@ export async function serializeRequest(request: Request): Promise { const responseClone = response.clone(); - const serializedBody = response.body ? await responseClone.text() : null; + const serializedBody = responseClone.body ? convertArrayBufferToBase64(await responseClone.arrayBuffer()) : null; return { status: response.status, @@ -86,7 +90,9 @@ export async function serializeResponse(response: Response): Promise