diff --git a/invokeai/frontend/web/package.json b/invokeai/frontend/web/package.json index 3abe54f470..1ff0add2f5 100644 --- a/invokeai/frontend/web/package.json +++ b/invokeai/frontend/web/package.json @@ -7,8 +7,8 @@ "dev": "concurrently \"vite dev\" \"yarn run theme:watch\"", "dev:nodes": "concurrently \"vite dev --mode nodes\" \"yarn run theme:watch\"", "build": "yarn run lint && vite build", - "api:web": "openapi -i http://localhost:9090/openapi.json -o src/services/api --client axios --useOptions --useUnionTypes --exportSchemas true --indent 2", - "api:file": "openapi -i openapi.json -o src/services/api --client axios --useOptions --useUnionTypes --exportSchemas true --indent 2", + "api:web": "openapi -i http://localhost:9090/openapi.json -o src/services/api --client axios --useOptions --useUnionTypes --exportSchemas true --indent 2 --request src/services/request.ts", + "api:file": "openapi -i openapi.json -o src/services/api --client axios --useOptions --useUnionTypes --exportSchemas true --indent 2 --request src/services/request.ts", "preview": "vite preview", "lint:madge": "madge --circular src/main.tsx", "lint:eslint": "eslint --max-warnings=0 .", diff --git a/invokeai/frontend/web/src/services/api/core/request.ts b/invokeai/frontend/web/src/services/api/core/request.ts index 0b54a83c10..990b803e4b 100644 --- a/invokeai/frontend/web/src/services/api/core/request.ts +++ b/invokeai/frontend/web/src/services/api/core/request.ts @@ -1,6 +1,15 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ + +/** + * Custom `request.ts` file for OpenAPI code generator. + * + * Patches the request logic in such a way that we can extract headers from requests. + * + * Copied from https://github.com/ferdikoomen/openapi-typescript-codegen/issues/829#issuecomment-1228224477 + */ + import axios from 'axios'; import type { AxiosError, AxiosRequestConfig, AxiosResponse } from 'axios'; import FormData from 'form-data'; @@ -12,6 +21,8 @@ import { CancelablePromise } from './CancelablePromise'; import type { OnCancel } from './CancelablePromise'; import type { OpenAPIConfig } from './OpenAPI'; +export const HEADERS = Symbol("HEADERS") + const isDefined = (value: T | null | undefined): value is Exclude => { return value !== undefined && value !== null; }; @@ -295,7 +306,7 @@ export const request = (config: OpenAPIConfig, options: ApiRequestOptions): C catchErrorCodes(options, result); - resolve(result.body); + resolve({ ...result.body, [HEADERS]: response.headers }); } } catch (error) { reject(error); diff --git a/invokeai/frontend/web/src/services/api/services/ImagesService.ts b/invokeai/frontend/web/src/services/api/services/ImagesService.ts index 248adc1001..d0108dbce6 100644 --- a/invokeai/frontend/web/src/services/api/services/ImagesService.ts +++ b/invokeai/frontend/web/src/services/api/services/ImagesService.ts @@ -89,7 +89,6 @@ export class ImagesService { url: '/api/v1/images/uploads/', formData: formData, mediaType: 'multipart/form-data', - responseHeader: "location", errors: { 404: `Session not found`, 422: `Validation Error`, diff --git a/invokeai/frontend/web/src/services/request.ts b/invokeai/frontend/web/src/services/request.ts new file mode 100644 index 0000000000..7cf57e96b6 --- /dev/null +++ b/invokeai/frontend/web/src/services/request.ts @@ -0,0 +1,349 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +/** + * Custom `request.ts` file for OpenAPI code generator. + * + * Patches the request logic in such a way that we can extract headers from requests. + * + * Copied from https://github.com/ferdikoomen/openapi-typescript-codegen/issues/829#issuecomment-1228224477 + */ + +import axios from 'axios'; +import type { AxiosError, AxiosRequestConfig, AxiosResponse } from 'axios'; +import FormData from 'form-data'; + +import { ApiError } from './ApiError'; +import type { ApiRequestOptions } from './ApiRequestOptions'; +import type { ApiResult } from './ApiResult'; +import { CancelablePromise } from './CancelablePromise'; +import type { OnCancel } from './CancelablePromise'; +import type { OpenAPIConfig } from './OpenAPI'; + +export const HEADERS = Symbol('HEADERS'); + +const isDefined = ( + value: T | null | undefined +): value is Exclude => { + return value !== undefined && value !== null; +}; + +const isString = (value: any): value is string => { + return typeof value === 'string'; +}; + +const isStringWithValue = (value: any): value is string => { + return isString(value) && value !== ''; +}; + +const isBlob = (value: any): value is Blob => { + return ( + typeof value === 'object' && + typeof value.type === 'string' && + typeof value.stream === 'function' && + typeof value.arrayBuffer === 'function' && + typeof value.constructor === 'function' && + typeof value.constructor.name === 'string' && + /^(Blob|File)$/.test(value.constructor.name) && + /^(Blob|File)$/.test(value[Symbol.toStringTag]) + ); +}; + +const isFormData = (value: any): value is FormData => { + return value instanceof FormData; +}; + +const isSuccess = (status: number): boolean => { + return status >= 200 && status < 300; +}; + +const base64 = (str: string): string => { + try { + return btoa(str); + } catch (err) { + // @ts-ignore + return Buffer.from(str).toString('base64'); + } +}; + +const getQueryString = (params: Record): string => { + const qs: string[] = []; + + const append = (key: string, value: any) => { + qs.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`); + }; + + const process = (key: string, value: any) => { + if (isDefined(value)) { + if (Array.isArray(value)) { + value.forEach((v) => { + process(key, v); + }); + } else if (typeof value === 'object') { + Object.entries(value).forEach(([k, v]) => { + process(`${key}[${k}]`, v); + }); + } else { + append(key, value); + } + } + }; + + Object.entries(params).forEach(([key, value]) => { + process(key, value); + }); + + if (qs.length > 0) { + return `?${qs.join('&')}`; + } + + return ''; +}; + +const getUrl = (config: OpenAPIConfig, options: ApiRequestOptions): string => { + const encoder = config.ENCODE_PATH || encodeURI; + + const path = options.url + .replace('{api-version}', config.VERSION) + .replace(/{(.*?)}/g, (substring: string, group: string) => { + if (options.path?.hasOwnProperty(group)) { + return encoder(String(options.path[group])); + } + return substring; + }); + + const url = `${config.BASE}${path}`; + if (options.query) { + return `${url}${getQueryString(options.query)}`; + } + return url; +}; + +const getFormData = (options: ApiRequestOptions): FormData | undefined => { + if (options.formData) { + const formData = new FormData(); + + const process = (key: string, value: any) => { + if (isString(value) || isBlob(value)) { + formData.append(key, value); + } else { + formData.append(key, JSON.stringify(value)); + } + }; + + Object.entries(options.formData) + .filter(([_, value]) => isDefined(value)) + .forEach(([key, value]) => { + if (Array.isArray(value)) { + value.forEach((v) => process(key, v)); + } else { + process(key, value); + } + }); + + return formData; + } + return undefined; +}; + +type Resolver = (options: ApiRequestOptions) => Promise; + +const resolve = async ( + options: ApiRequestOptions, + resolver?: T | Resolver +): Promise => { + if (typeof resolver === 'function') { + return (resolver as Resolver)(options); + } + return resolver; +}; + +const getHeaders = async ( + config: OpenAPIConfig, + options: ApiRequestOptions, + formData?: FormData +): Promise> => { + const token = await resolve(options, config.TOKEN); + const username = await resolve(options, config.USERNAME); + const password = await resolve(options, config.PASSWORD); + const additionalHeaders = await resolve(options, config.HEADERS); + const formHeaders = + (typeof formData?.getHeaders === 'function' && formData?.getHeaders()) || + {}; + + const headers = Object.entries({ + Accept: 'application/json', + ...additionalHeaders, + ...options.headers, + ...formHeaders, + }) + .filter(([_, value]) => isDefined(value)) + .reduce( + (headers, [key, value]) => ({ + ...headers, + [key]: String(value), + }), + {} as Record + ); + + if (isStringWithValue(token)) { + headers['Authorization'] = `Bearer ${token}`; + } + + if (isStringWithValue(username) && isStringWithValue(password)) { + const credentials = base64(`${username}:${password}`); + headers['Authorization'] = `Basic ${credentials}`; + } + + if (options.body) { + if (options.mediaType) { + headers['Content-Type'] = options.mediaType; + } else if (isBlob(options.body)) { + headers['Content-Type'] = options.body.type || 'application/octet-stream'; + } else if (isString(options.body)) { + headers['Content-Type'] = 'text/plain'; + } else if (!isFormData(options.body)) { + headers['Content-Type'] = 'application/json'; + } + } + + return headers; +}; + +const getRequestBody = (options: ApiRequestOptions): any => { + if (options.body) { + return options.body; + } + return undefined; +}; + +const sendRequest = async ( + config: OpenAPIConfig, + options: ApiRequestOptions, + url: string, + body: any, + formData: FormData | undefined, + headers: Record, + onCancel: OnCancel +): Promise> => { + const source = axios.CancelToken.source(); + + const requestConfig: AxiosRequestConfig = { + url, + headers, + data: body ?? formData, + method: options.method, + withCredentials: config.WITH_CREDENTIALS, + cancelToken: source.token, + }; + + onCancel(() => source.cancel('The user aborted a request.')); + + try { + return await axios.request(requestConfig); + } catch (error) { + const axiosError = error as AxiosError; + if (axiosError.response) { + return axiosError.response; + } + throw error; + } +}; + +const getResponseHeader = ( + response: AxiosResponse, + responseHeader?: string +): string | undefined => { + if (responseHeader) { + const content = response.headers[responseHeader]; + if (isString(content)) { + return content; + } + } + return undefined; +}; + +const getResponseBody = (response: AxiosResponse): any => { + if (response.status !== 204) { + return response.data; + } + return undefined; +}; + +const catchErrorCodes = ( + options: ApiRequestOptions, + result: ApiResult +): void => { + const errors: Record = { + 400: 'Bad Request', + 401: 'Unauthorized', + 403: 'Forbidden', + 404: 'Not Found', + 500: 'Internal Server Error', + 502: 'Bad Gateway', + 503: 'Service Unavailable', + ...options.errors, + }; + + const error = errors[result.status]; + if (error) { + throw new ApiError(options, result, error); + } + + if (!result.ok) { + throw new ApiError(options, result, 'Generic Error'); + } +}; + +/** + * Request method + * @param config The OpenAPI configuration object + * @param options The request options from the service + * @returns CancelablePromise + * @throws ApiError + */ +export const request = ( + config: OpenAPIConfig, + options: ApiRequestOptions +): CancelablePromise => { + return new CancelablePromise(async (resolve, reject, onCancel) => { + try { + const url = getUrl(config, options); + const formData = getFormData(options); + const body = getRequestBody(options); + const headers = await getHeaders(config, options, formData); + + if (!onCancel.isCancelled) { + const response = await sendRequest( + config, + options, + url, + body, + formData, + headers, + onCancel + ); + const responseBody = getResponseBody(response); + const responseHeader = getResponseHeader( + response, + options.responseHeader + ); + + const result: ApiResult = { + url, + ok: isSuccess(response.status), + status: response.status, + statusText: response.statusText, + body: responseHeader ?? responseBody, + }; + + catchErrorCodes(options, result); + + resolve({ ...result.body, [HEADERS]: response.headers }); + } + } catch (error) { + reject(error); + } + }); +}; diff --git a/invokeai/frontend/web/src/services/thunks/image.ts b/invokeai/frontend/web/src/services/thunks/image.ts index 654ec5cc8d..c8c7a2b404 100644 --- a/invokeai/frontend/web/src/services/thunks/image.ts +++ b/invokeai/frontend/web/src/services/thunks/image.ts @@ -1,5 +1,6 @@ import { createAppAsyncThunk } from 'app/storeUtils'; import { ImagesService } from 'services/api'; +import { getHeaders } from 'services/util/getHeaders'; type GetImageArg = Parameters<(typeof ImagesService)['getImage']>[0]; @@ -18,6 +19,7 @@ export const uploadImage = createAppAsyncThunk( 'api/uploadImage', async (arg: UploadImageArg, _thunkApi) => { const response = await ImagesService.uploadImage(arg); - return response; + const { location } = getHeaders(response); + return location; } ); diff --git a/invokeai/frontend/web/src/services/util/getHeaders.ts b/invokeai/frontend/web/src/services/util/getHeaders.ts new file mode 100644 index 0000000000..f648271023 --- /dev/null +++ b/invokeai/frontend/web/src/services/util/getHeaders.ts @@ -0,0 +1,12 @@ +import { HEADERS } from '../api/core/request'; + +/** + * Returns the headers of a given response object + */ +export const getHeaders = (response: any): Record => { + if (!(HEADERS in response)) { + throw new Error('Response does not have headers'); + } + + return response[HEADERS]; +};