From 2bc211ac6988317d2ae1d2c04bfa8ee7e0038938 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Raffray?= Date: Fri, 1 Jul 2022 12:32:08 +0200 Subject: [PATCH 1/4] fix issue #20: invoke() call throws if shouldThrowOnError flag is set --- src/index.ts | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index fef1a2e..9823b56 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,24 +1,40 @@ import { resolveFetch } from './helper' import { Fetch, FunctionInvokeOptions } from './types' +export class HttpError extends Error { + statusCode: number + statusText: string + data: any + constructor(statusCode: number, statusText: string, data: any) { + super('Invoke call returned HTTP Error code') + this.statusCode = statusCode + this.statusText = statusText + this.data = data + } +} + export class FunctionsClient { protected url: string protected headers: Record protected fetch: Fetch + protected shouldThrowOnError: boolean constructor( url: string, { headers = {}, customFetch, + shouldThrowOnError = false, }: { headers?: Record customFetch?: Fetch + shouldThrowOnError?: boolean } = {} ) { this.url = url this.headers = headers this.fetch = resolveFetch(customFetch) + this.shouldThrowOnError = shouldThrowOnError } /** @@ -51,7 +67,7 @@ export class FunctionsClient { const isRelayError = response.headers.get('x-relay-error') if (isRelayError && isRelayError === 'true') { - return { data: null, error: new Error(await response.text()) } + throw new Error(await response.text()) } let data @@ -66,8 +82,16 @@ export class FunctionsClient { data = await response.text() } + // Detect HTTP status codes other than 2xx and reject as error together with statusCode property + if (!response.ok) { + throw new HttpError(response.status, response.statusText, data) + } + return { data, error: null } } catch (error: any) { + if (this.shouldThrowOnError) { + throw error + } return { data: null, error } } } From 88f94b0e365179c7586ad2b63af210023d2667ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Raffray?= Date: Tue, 5 Jul 2022 23:08:25 +0200 Subject: [PATCH 2/4] Refactor PR to reflect comments: - rename HttpError to FunctionsError - pull out status & statusText from FunctionsError - status and statusText are optional --- src/index.ts | 54 +++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 43 insertions(+), 11 deletions(-) diff --git a/src/index.ts b/src/index.ts index 9823b56..a3c98d9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,14 +1,30 @@ import { resolveFetch } from './helper' import { Fetch, FunctionInvokeOptions } from './types' -export class HttpError extends Error { - statusCode: number +/** + * Response format + * + */ + interface FunctionsResponseBase { + status?: number + statusText?: string +} +interface FunctionsResponseSuccess extends FunctionsResponseBase { + status: number statusText: string + error: null + data: T +} +interface FunctionsResponseFailure extends FunctionsResponseBase { + error: any + data: null +} +export type FunctionsResponse = FunctionsResponseSuccess | FunctionsResponseFailure + +export class FunctionsError extends Error { data: any - constructor(statusCode: number, statusText: string, data: any) { - super('Invoke call returned HTTP Error code') - this.statusCode = statusCode - this.statusText = statusText + constructor(data: any) { + super('Invoke call returned non-2xx status') this.data = data } } @@ -56,7 +72,11 @@ export class FunctionsClient { async invoke( functionName: string, invokeOptions?: FunctionInvokeOptions - ): Promise<{ data: T; error: null } | { data: null; error: Error }> { + ): Promise> { + + let status: number | undefined + let statusText: string | undefined + try { const { headers, body } = invokeOptions ?? {} const response = await this.fetch(`${this.url}/${functionName}`, { @@ -65,6 +85,9 @@ export class FunctionsClient { body, }) + status = response.status + statusText = response.statusText + const isRelayError = response.headers.get('x-relay-error') if (isRelayError && isRelayError === 'true') { throw new Error(await response.text()) @@ -82,17 +105,26 @@ export class FunctionsClient { data = await response.text() } - // Detect HTTP status codes other than 2xx and reject as error together with statusCode property + // Detect HTTP status codes other than 2xx and reject as error if (!response.ok) { - throw new HttpError(response.status, response.statusText, data) + throw new FunctionsError(data) } - return { data, error: null } + const success: FunctionsResponseSuccess = { data, error: null, status, statusText } + return success } catch (error: any) { if (this.shouldThrowOnError) { throw error } - return { data: null, error } + + const failure: FunctionsResponseFailure = { data: null, error } + if (status) ( + failure.status = status + ) + if (statusText) { + failure.statusText = statusText + } + return failure } } } From 223f7e7f9474895e2d062ccf8432b2317a7f3754 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Raffray?= Date: Tue, 5 Jul 2022 23:25:25 +0200 Subject: [PATCH 3/4] fix syntax error --- src/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/index.ts b/src/index.ts index a3c98d9..0ff7522 100644 --- a/src/index.ts +++ b/src/index.ts @@ -118,9 +118,9 @@ export class FunctionsClient { } const failure: FunctionsResponseFailure = { data: null, error } - if (status) ( + if (status) { failure.status = status - ) + } if (statusText) { failure.statusText = statusText } From fa238c423d2b09e322394584afc7b8e2429e91ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Raffray?= Date: Mon, 11 Jul 2022 14:45:44 +0200 Subject: [PATCH 4/4] Initial implementation proposal --- README.md | 157 +++++++++++++++++++++++++++++++++++ src/index.ts | 227 ++++++++++++++++++++++++++++++++++++--------------- src/types.ts | 28 +++++-- 3 files changed, 335 insertions(+), 77 deletions(-) diff --git a/README.md b/README.md index 67c74d0..8907407 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,160 @@ # `functions-js` JS Client library to interact with Supabase Functions. + +## How to use +------ + +This library provides an `invoke()` function that can be used with the following syntax: + +```ts +const { data: any, error: FunctionsError } = await invoke(functionName: string, input: any, options?:FunctionsInvokeOptions) +``` + +By default, the invoke function can be used without the `options` parameter and will work automatically for the most common `input` types (e.g. JSON, File, string, etc.) + +```js +const { data } = await invoke('function-name', input) +``` + +However invoke can also be used with the `options` parameter and in that case provides full-powered low-level access to the underlying Fetch API. + + +## Sending requests to the edge server +----- + +### Sending JSON data + +By default, `invoke` will assume that `input` is a plain JSON-parseable object and will send it as a JSON body. +```js +const object = { + foo: "bar" +} +const { data, error } = await invoke('function-name', object) +``` + +The `'application/json'` header will automatically be added to the request. + +### Sending a File + +You can provide a `File` object as input: +```js +const file = new File(...) +const { data, error } = await invoke('function-name', file) +``` + +The `'application/octet-stream'` header will automatically be added to the request. + +### Other supported types + +Other supported types are `FormData`, `string`, `Blob` and `ArrayBuffer`. + +The headers are automatically associated according to the input type: +| `input` type | Header | +|------------|--------| +| any (default) | 'application/json'| +| string | 'text/plain' | +| FormData | 'mutlipart/form-data' | +| File | 'application/octet-stream' | +| Blob | 'application/octet-stream' | +| ArrayBuffer | 'application/octet-stream' | + +If you want to send form content (for instance to transfer multiple file attachements), use the [FormData API](https://developer.mozilla.org/en-US/docs/Web/API/FormData/Using_FormData_Objects) to format your content. + +### Full customization with the `requestTransform` option + +Under the hood, `invoke()` uses the [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API). If you need to further customize the headers or body of your request, you can use the `options.requestTransform` callback. + +This option gives you the ability to fully transform the underlying [Request](https://developer.mozilla.org/en-US/docs/Web/API/Request) options. `requestTransform` is a callback that takes the `input` as argument and must return a `RequestInit` object. + +```js +const { data, error } = await invoke('function-name', input, { requestTransform: (input) => { + // Any transform of the input data + return { + headers: ... + body: ... + } +}}) +``` +The properties available on the `RequestInit` object are: `headers`, `body`, `mode`, `credentials`, `cache`, `redirect`, `referrer`, and `integrity`. Further information on these options is available [here](https://developer.mozilla.org/en-US/docs/Web/API/Request/Request). + +The following property modifications will be ignored : +- `method`: the method is always 'POST' +- `headers` with `'Authorization'`: the `'Authorization'` header always contains the Supabase auth token. + + +## Receiving responses from the edge server +----- + +By default, `invoke()` will return a `{ data, error }` object, where `data` can either be a Blob, a string or a plain JSON object. + +### Default `data` return types + +The type of `data` is inferred by `invoke()` based on the servers's Response headers: + +| Header | `data` type | +|--------|------------------------| +| 'application/json' | any (default) | +| 'text/plain' | string | +| 'application/octet-stream' | Blob | + +Examples : +- If your server sends an `'application/json'` response, `data` will be a JSON object: + +> ```js +> const { data } = await invoke(...) +> // data is an object whose properties can be extracted : +> const { foo } = data +> ``` + +- If your server sends an `'application/octet-stream'` response, `data` will be a Blob object: + +> ```js +> const { data } = await invoke(...) +> // data is a Blob that can be used to construct a File: +> const file = new File(data) +> ``` + +### Full customization with the `responseTransform` option + +If you need to access the underlying raw [Response](https://developer.mozilla.org/en-US/docs/Web/API/Response) object to extract its properties, you can use the `options.responseTransform` callback. + + `responseTransform` is a callback that takes the `Response` object as argument and must return a `Promise`. + + ```js +const { data } = await invoke('function-name', input, { responseTransform: async (response) => { + // Do anything with the response + console.log(response.status) + console.log(response.headers) + return await response.text() +}}) + ``` + + ### `error` object + + Upon error, `invoke()` will return `{ data: null, error }`, where `error` is an `FunctionsError` object which has the following properties : + + | `error instanceof` | Description | + |--------------------|-------------| + | `HttpError` | The edge server returned a non-2xx status code | + | `RelayError` | There was an error communicating with the Deno backend | + | `FetchError` | The `fetch()` to the edge server call generated an error | + | `SerializationError` | The `input` data could not be parsed into a suitable `Request` body, or the provided `options.requestTransform` callback is triggering an error | + | `DeserializationError` | The `Response` object cannot be parsed according to the `headers` value, or the provided `options.responseTransform` callback is triggering an error | + + ```js + const { data, error } = await invoke(...) + if (error) { + if (error instanceof HttpError) { + console.log(error.status) + if (error.status === 403) { + alert('Forbidden') + } + // etc. + } else if (error instanceof RelayError) { + console.log(error.context) + } else { + // etc. + } + } + ``` \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 0ff7522..47416d3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,54 +1,70 @@ import { resolveFetch } from './helper' -import { Fetch, FunctionInvokeOptions } from './types' +import { Fetch, FunctionsResponse, FunctionInvokeOptions } from './types' -/** - * Response format - * - */ - interface FunctionsResponseBase { - status?: number - statusText?: string + +class FunctionsError extends Error { + context: any + constructor(message: string, name: string = 'FunctionsError', context?: any) { + super('FunctionsError: ' + message) + super.name = name + this.context = context + } } -interface FunctionsResponseSuccess extends FunctionsResponseBase { - status: number - statusText: string - error: null - data: T + +export class SerializationError extends FunctionsError { + constructor(context: any) { + super('Failed to serialize input data into a proper Request object', 'SerializationError', context) + } } -interface FunctionsResponseFailure extends FunctionsResponseBase { - error: any - data: null + +export class DeserializationError extends FunctionsError { + constructor(context: any) { + super('Failed to deserialize the Response object into output data', 'DeserializationError', context) + } +} + +export class FetchError extends FunctionsError { + constructor(context: any) { + super('Failed to fetch data from edge server', 'FetchError', context) + } } -export type FunctionsResponse = FunctionsResponseSuccess | FunctionsResponseFailure -export class FunctionsError extends Error { - data: any - constructor(data: any) { - super('Invoke call returned non-2xx status') - this.data = data +export class RelayError extends FunctionsError { + constructor(context: any) { + super('Relay error communicating with deno backend', 'RelayError', context) + } +} + +export class HttpError extends FunctionsError { + status: number + statusText: string + constructor(status: number, statusText: string, context: any) { + super('Edge server returned a non-2xx status code', context) + this.status = status + this.statusText = statusText } } export class FunctionsClient { - protected url: string - protected headers: Record + protected url: URL + protected headers: Headers protected fetch: Fetch protected shouldThrowOnError: boolean constructor( url: string, { - headers = {}, + headers, customFetch, shouldThrowOnError = false, }: { - headers?: Record + headers?: RequestInit["headers"] customFetch?: Fetch shouldThrowOnError?: boolean } = {} ) { - this.url = url - this.headers = headers + this.url = new URL(url) + this.headers = new Headers(headers) this.fetch = resolveFetch(customFetch) this.shouldThrowOnError = shouldThrowOnError } @@ -58,73 +74,148 @@ export class FunctionsClient { * @params token - the new jwt token sent in the authorisation header */ setAuth(token: string) { - this.headers.Authorization = `Bearer ${token}` + this.headers.set('Authorization', `Bearer ${token}`) } /** * Invokes a function * @param functionName - the name of the function to invoke + * @param input - the input data: can be File, Blob, FormData or JSON object * @param invokeOptions - object with the following properties - * `headers`: object representing the headers to send with the request - * `body`: the body of the request - * `responseType`: how the response should be parsed. The default is `json` + * `requestTransform`: a serialization callback returning RequestInit parameters from the input data parameter + * `responseTransform`: a de-serialization callback returning data from a Response object */ - async invoke( + async invoke( functionName: string, + input: any, invokeOptions?: FunctionInvokeOptions - ): Promise> { + ): Promise { - let status: number | undefined - let statusText: string | undefined - try { - const { headers, body } = invokeOptions ?? {} - const response = await this.fetch(`${this.url}/${functionName}`, { - method: 'POST', - headers: Object.assign({}, this.headers, headers), - body, - }) + // Serialize the input data + const requestTransform = invokeOptions?.requestTransform || defaultRequestTransform + let request: Request + try { + const requestInit = requestTransform(input) + + // In all cases, enforce POST method and set default headers, including auth + requestInit.method = 'POST' + const headers = new Headers(requestInit.headers) + for (const header of this.headers.entries()) { + const [ name, value ] = header + headers.set(name, value) + } + requestInit.headers = headers - status = response.status - statusText = response.statusText + // Create the request + const url = new URL(functionName, this.url) + request = new Request(url, requestInit) + } catch (error) { + throw new SerializationError(error) + } + + // Fetch the response from the server + let response: Response + try { + response = await this.fetch(request) + } catch (error) { + throw new FetchError(error) + } + // Detect relay errors const isRelayError = response.headers.get('x-relay-error') if (isRelayError && isRelayError === 'true') { - throw new Error(await response.text()) + throw new RelayError(await response.text()) } - let data - const { responseType } = invokeOptions ?? {} - if (!responseType || responseType === 'json') { - data = await response.json() - } else if (responseType === 'arrayBuffer') { - data = await response.arrayBuffer() - } else if (responseType === 'blob') { - data = await response.blob() - } else { - data = await response.text() + // Deserialize the response + let data: any + try { + const responseTransform = invokeOptions?.responseTransform || defaultResponseTransform + data = await responseTransform(response) + } catch (error) { + throw new DeserializationError(error) } // Detect HTTP status codes other than 2xx and reject as error if (!response.ok) { - throw new FunctionsError(data) + throw new HttpError(response.status, response.statusText, data) } - const success: FunctionsResponseSuccess = { data, error: null, status, statusText } - return success - } catch (error: any) { + // Return data + return { data, error: null } + } catch (error) { + // Throw if shouldThrowOnError flag is set if (this.shouldThrowOnError) { throw error } - - const failure: FunctionsResponseFailure = { data: null, error } - if (status) { - failure.status = status - } - if (statusText) { - failure.statusText = statusText + // Otherwise return error + else { + return { + data: null, error + } } - return failure } } } + +/** + * This function serializes the most common data types and add the corresponding headers + * @param data - data can be File, Blob, ArrayBuffer, FormData, string or JSON object + * @returns a RequestInit object that can be used with the standard Request API to use with fetch() + */ +function defaultRequestTransform (data: any) { + let requestInit = {} as RequestInit + requestInit.headers = new Headers() + + if (data instanceof Blob || data instanceof ArrayBuffer) { + // will work for File as File inherits Blob + // also works for ArrayBuffer as it is the same underlying structure as a Blob + requestInit.headers.set('Content-Type', 'application/octet-stream') + requestInit.body = data + } else if (typeof data === 'string') { + // plain string + requestInit.headers.set('Content-Type', 'text/plain') + requestInit.body = data + } else if (data instanceof FormData) { + // don't set content-type headers + // Request will automatically add the right boundary value + requestInit.headers.delete('Content-Type') + requestInit.body = data + } else { + // default, assume this is JSON + requestInit.headers.set('Content-Type', 'application/json') + requestInit.body = JSON.stringify(data) + } + return requestInit +} + +/** + * This function deserializes the Response object returned by fetch() according to the 'Content-Type' header + * @param response - the raw Response returned by the fetch call + * @returns either a plain JSON object, a Blob, or a string (or even a FormData) + */ +async function defaultResponseTransform(response: Response) { + const contentType = response.headers.get('Content-Type') + const responseTypes = contentType?.split('; ') // several values can be present and are separated by semicolon + let data + // Use of Array.includes() below would be more natural but we are targeting ES2015 so let's go for Array.indexOf() + if (!responseTypes || responseTypes.indexOf('application/json') >= 0) { + // by default assume JSON and parse + data = await response.json() + } else if (responseTypes.indexOf('application/octet-stream') >= 0) { + // return Blob type for all byte streams + // leave it up to user to transform to File or ArrayBuffer if needed + // as File inherits Blob and Blob has arrayBuffer method + data = await response.blob() + } else if (responseTypes.indexOf('text/plain') >= 0) { + // text string + data = await response.text() + } else if (responseTypes.indexOf('multipart/form-data') >= 0 ) { + // unlikely content-type on a response but let's treat it here nevertheless + data = await response.formData() + } else { + throw new Error('Unable to deserialize the response as no suitable Content-Type header was found') + } + return data +} \ No newline at end of file diff --git a/src/types.ts b/src/types.ts index 1d8c6a8..64a82fd 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,14 +1,24 @@ export type Fetch = typeof fetch -export enum ResponseType { - json, - text, - arrayBuffer, - blob, -} +type requestTransformer = (data: any) => RequestInit +type responseTransformer = (reponse: Response) => Promise export type FunctionInvokeOptions = { - headers?: { [key: string]: string } - body?: Blob | BufferSource | FormData | URLSearchParams | ReadableStream | string - responseType?: keyof typeof ResponseType + requestTransform: requestTransformer + responseTransform: responseTransformer +} + +/** + * Response format + * + */ + + interface FunctionsResponseSuccess { + data: any + error: null +} +interface FunctionsResponseFailure { + data: null + error: any } +export type FunctionsResponse = FunctionsResponseSuccess | FunctionsResponseFailure