feat(http): add plugin (#325)

This commit is contained in:
Lucas Fernandes Nogueira
2023-04-23 11:34:31 -07:00
committed by GitHub
parent 7a8633f429
commit 89fb40caac
17 changed files with 5370 additions and 4 deletions
+541
View File
@@ -0,0 +1,541 @@
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
/**
* Access the HTTP client written in Rust.
*
* The APIs must be allowlisted on `tauri.conf.json`:
* ```json
* {
* "tauri": {
* "allowlist": {
* "http": {
* "all": true, // enable all http APIs
* "request": true // enable HTTP request API
* }
* }
* }
* }
* ```
* It is recommended to allowlist only the APIs you use for optimal bundle size and security.
*
* ## Security
*
* This API has a scope configuration that forces you to restrict the URLs and paths that can be accessed using glob patterns.
*
* For instance, this scope configuration only allows making HTTP requests to the GitHub API for the `tauri-apps` organization:
* ```json
* {
* "tauri": {
* "allowlist": {
* "http": {
* "scope": ["https://api.github.com/repos/tauri-apps/*"]
* }
* }
* }
* }
* ```
* Trying to execute any API with a URL not configured on the scope results in a promise rejection due to denied access.
*
* @module
*/
import { invoke } from '@tauri-apps/api/tauri'
/**
* @since 1.0.0
*/
interface Duration {
secs: number
nanos: number
}
/**
* @since 1.0.0
*/
interface ClientOptions {
/**
* Defines the maximum number of redirects the client should follow.
* If set to 0, no redirects will be followed.
*/
maxRedirections?: number
connectTimeout?: number | Duration
}
/**
* @since 1.0.0
*/
enum ResponseType {
JSON = 1,
Text = 2,
Binary = 3
}
/**
* @since 1.0.0
*/
interface FilePart<T> {
file: string | T
mime?: string
fileName?: string
}
type Part = string | Uint8Array | FilePart<Uint8Array>
/**
* The body object to be used on POST and PUT requests.
*
* @since 1.0.0
*/
class Body {
type: string
payload: unknown
/** @ignore */
private constructor(type: string, payload: unknown) {
this.type = type
this.payload = payload
}
/**
* Creates a new form data body. The form data is an object where each key is the entry name,
* and the value is either a string or a file object.
*
* By default it sets the `application/x-www-form-urlencoded` Content-Type header,
* but you can set it to `multipart/form-data` if the Cargo feature `http-multipart` is enabled.
*
* Note that a file path must be allowed in the `fs` allowlist scope.
* @example
* ```typescript
* import { Body } from "tauri-plugin-http-api"
* const body = Body.form({
* key: 'value',
* image: {
* file: '/path/to/file', // either a path or an array buffer of the file contents
* mime: 'image/jpeg', // optional
* fileName: 'image.jpg' // optional
* }
* });
*
* // alternatively, use a FormData:
* const form = new FormData();
* form.append('key', 'value');
* form.append('image', file, 'image.png');
* const formBody = Body.form(form);
* ```
*
* @param data The body data.
*
* @returns The body object ready to be used on the POST and PUT requests.
*/
static form(data: Record<string, Part> | FormData): Body {
const form: Record<string, string | number[] | FilePart<number[]>> = {}
const append = (
key: string,
v: string | Uint8Array | FilePart<Uint8Array> | File
): void => {
if (v !== null) {
let r
if (typeof v === 'string') {
r = v
} else if (v instanceof Uint8Array || Array.isArray(v)) {
r = Array.from(v)
} else if (v instanceof File) {
r = { file: v.name, mime: v.type, fileName: v.name }
} else if (typeof v.file === 'string') {
r = { file: v.file, mime: v.mime, fileName: v.fileName }
} else {
r = { file: Array.from(v.file), mime: v.mime, fileName: v.fileName }
}
form[String(key)] = r
}
}
if (data instanceof FormData) {
for (const [key, value] of data) {
append(key, value)
}
} else {
for (const [key, value] of Object.entries(data)) {
append(key, value)
}
}
return new Body('Form', form)
}
/**
* Creates a new JSON body.
* @example
* ```typescript
* import { Body } from "tauri-plugin-http-api"
* Body.json({
* registered: true,
* name: 'tauri'
* });
* ```
*
* @param data The body JSON object.
*
* @returns The body object ready to be used on the POST and PUT requests.
*/
static json<K extends string | number | symbol, V>(data: Record<K, V>): Body {
return new Body('Json', data)
}
/**
* Creates a new UTF-8 string body.
* @example
* ```typescript
* import { Body } from "tauri-plugin-http-api"
* Body.text('The body content as a string');
* ```
*
* @param value The body string.
*
* @returns The body object ready to be used on the POST and PUT requests.
*/
static text(value: string): Body {
return new Body('Text', value)
}
/**
* Creates a new byte array body.
* @example
* ```typescript
* import { Body } from "tauri-plugin-http-api"
* Body.bytes(new Uint8Array([1, 2, 3]));
* ```
*
* @param bytes The body byte array.
*
* @returns The body object ready to be used on the POST and PUT requests.
*/
static bytes(
bytes: Iterable<number> | ArrayLike<number> | ArrayBuffer
): Body {
// stringifying Uint8Array doesn't return an array of numbers, so we create one here
return new Body(
'Bytes',
Array.from(bytes instanceof ArrayBuffer ? new Uint8Array(bytes) : bytes)
)
}
}
/** The request HTTP verb. */
type HttpVerb =
| 'GET'
| 'POST'
| 'PUT'
| 'DELETE'
| 'PATCH'
| 'HEAD'
| 'OPTIONS'
| 'CONNECT'
| 'TRACE'
/**
* Options object sent to the backend.
*
* @since 1.0.0
*/
interface HttpOptions {
method: HttpVerb
url: string
headers?: Record<string, unknown>
query?: Record<string, unknown>
body?: Body
timeout?: number | Duration
responseType?: ResponseType
}
/** Request options. */
type RequestOptions = Omit<HttpOptions, 'method' | 'url'>
/** Options for the `fetch` API. */
type FetchOptions = Omit<HttpOptions, 'url'>
/** @ignore */
interface IResponse<T> {
url: string
status: number
headers: Record<string, string>
rawHeaders: Record<string, string[]>
data: T
}
/**
* Response object.
*
* @since 1.0.0
* */
class Response<T> {
/** The request URL. */
url: string
/** The response status code. */
status: number
/** A boolean indicating whether the response was successful (status in the range 200299) or not. */
ok: boolean
/** The response headers. */
headers: Record<string, string>
/** The response raw headers. */
rawHeaders: Record<string, string[]>
/** The response data. */
data: T
/** @ignore */
constructor(response: IResponse<T>) {
this.url = response.url
this.status = response.status
this.ok = this.status >= 200 && this.status < 300
this.headers = response.headers
this.rawHeaders = response.rawHeaders
this.data = response.data
}
}
/**
* @since 1.0.0
*/
class Client {
id: number
/** @ignore */
constructor(id: number) {
this.id = id
}
/**
* Drops the client instance.
* @example
* ```typescript
* import { getClient } from 'tauri-plugin-http-api';
* const client = await getClient();
* await client.drop();
* ```
*/
async drop(): Promise<void> {
return invoke('plugin:http|drop_client', {
client: this.id
})
}
/**
* Makes an HTTP request.
* @example
* ```typescript
* import { getClient } from 'tauri-plugin-http-api';
* const client = await getClient();
* const response = await client.request({
* method: 'GET',
* url: 'http://localhost:3003/users',
* });
* ```
*/
async request<T>(options: HttpOptions): Promise<Response<T>> {
const jsonResponse =
!options.responseType || options.responseType === ResponseType.JSON
if (jsonResponse) {
options.responseType = ResponseType.Text
}
return invoke<IResponse<T>>('plugin:http|request', {
client: this.id,
options
}).then((res) => {
const response = new Response(res)
if (jsonResponse) {
/* eslint-disable */
try {
response.data = JSON.parse(response.data as string)
} catch (e) {
if (response.ok && (response.data as unknown as string) === '') {
response.data = {} as T
} else if (response.ok) {
throw Error(
`Failed to parse response \`${response.data}\` as JSON: ${e};
try setting the \`responseType\` option to \`ResponseType.Text\` or \`ResponseType.Binary\` if the API does not return a JSON response.`
)
}
}
/* eslint-enable */
return response
}
return response
})
}
/**
* Makes a GET request.
* @example
* ```typescript
* import { getClient, ResponseType } from 'tauri-plugin-http-api';
* const client = await getClient();
* const response = await client.get('http://localhost:3003/users', {
* timeout: 30,
* // the expected response type
* responseType: ResponseType.JSON
* });
* ```
*/
async get<T>(url: string, options?: RequestOptions): Promise<Response<T>> {
return this.request({
method: 'GET',
url,
...options
})
}
/**
* Makes a POST request.
* @example
* ```typescript
* import { getClient, Body, ResponseType } from 'tauri-plugin-http-api';
* const client = await getClient();
* const response = await client.post('http://localhost:3003/users', {
* body: Body.json({
* name: 'tauri',
* password: 'awesome'
* }),
* // in this case the server returns a simple string
* responseType: ResponseType.Text,
* });
* ```
*/
async post<T>(
url: string,
body?: Body,
options?: RequestOptions
): Promise<Response<T>> {
return this.request({
method: 'POST',
url,
body,
...options
})
}
/**
* Makes a PUT request.
* @example
* ```typescript
* import { getClient, Body } from 'tauri-plugin-http-api';
* const client = await getClient();
* const response = await client.put('http://localhost:3003/users/1', {
* body: Body.form({
* file: {
* file: '/home/tauri/avatar.png',
* mime: 'image/png',
* fileName: 'avatar.png'
* }
* })
* });
* ```
*/
async put<T>(
url: string,
body?: Body,
options?: RequestOptions
): Promise<Response<T>> {
return this.request({
method: 'PUT',
url,
body,
...options
})
}
/**
* Makes a PATCH request.
* @example
* ```typescript
* import { getClient, Body } from 'tauri-plugin-http-api';
* const client = await getClient();
* const response = await client.patch('http://localhost:3003/users/1', {
* body: Body.json({ email: 'contact@tauri.app' })
* });
* ```
*/
async patch<T>(url: string, options?: RequestOptions): Promise<Response<T>> {
return this.request({
method: 'PATCH',
url,
...options
})
}
/**
* Makes a DELETE request.
* @example
* ```typescript
* import { getClient } from 'tauri-plugin-http-api';
* const client = await getClient();
* const response = await client.delete('http://localhost:3003/users/1');
* ```
*/
async delete<T>(url: string, options?: RequestOptions): Promise<Response<T>> {
return this.request({
method: 'DELETE',
url,
...options
})
}
}
/**
* Creates a new client using the specified options.
* @example
* ```typescript
* import { getClient } from 'tauri-plugin-http-api';
* const client = await getClient();
* ```
*
* @param options Client configuration.
*
* @returns A promise resolving to the client instance.
*
* @since 1.0.0
*/
async function getClient(options?: ClientOptions): Promise<Client> {
return invoke<number>('plugin:http|create_client', {
options
}).then((id) => new Client(id))
}
/** @internal */
let defaultClient: Client | null = null
/**
* Perform an HTTP request using the default client.
* @example
* ```typescript
* import { fetch } from 'tauri-plugin-http-api';
* const response = await fetch('http://localhost:3003/users/2', {
* method: 'GET',
* timeout: 30,
* });
* ```
*/
async function fetch<T>(
url: string,
options?: FetchOptions
): Promise<Response<T>> {
if (defaultClient === null) {
defaultClient = await getClient()
}
return defaultClient.request({
url,
method: options?.method ?? 'GET',
...options
})
}
export type {
Duration,
ClientOptions,
Part,
HttpVerb,
HttpOptions,
RequestOptions,
FetchOptions
}
export { getClient, fetch, Body, Client, Response, ResponseType, type FilePart }