diff --git a/lib/nile/package.json b/lib/nile/package.json index 74a4d28f..aa8b511e 100644 --- a/lib/nile/package.json +++ b/lib/nile/package.json @@ -23,7 +23,7 @@ "build:readme": "./scripts/lib-readme-shim.sh", "build:types": "tsc -d --declarationDir dist --emitDeclarationOnly", "build:api:merge": "yarn openapi-merge-cli", - "build:api:gen": "yarn openapi-generator-cli generate -t templates -i spec/api.yaml -g typescript --package-name nile -o src/generated/openapi --additional-properties=ngVersion=6.1.7,npmName=theniledev,supportsES6=true,npmVersion=6.9.0,withInterfaces=true,withSeparateModelsAndApi=true,moduleName=Nile,projectName=@theniledev/js", + "build:api:gen": "yarn openapi-generator-cli generate -t templates -i spec/api.yaml -g typescript-fetch --package-name nile -o src/generated/openapi --additional-properties=ngVersion=6.1.7,npmName=theniledev,supportsES6=true,npmVersion=6.9.0,withInterfaces=true,withSeparateModelsAndApi=true,moduleName=Nile,typescriptThreePlus=true,projectName=@theniledev/js", "test": "tsdx test", "prepare": "yarn prebuild && tsdx build && yarn build:types", "size": "size-limit", @@ -68,8 +68,6 @@ "@openapitools/openapi-generator-cli": "^2.4.26", "es6-promise": "^4.2.8", "node-fetch": "^3.2.3", - "sade": "^1.8.1", - "url-parse": "^1.5.10", - "whatwg-fetch": "^3.6.2" + "sade": "^1.8.1" } } diff --git a/lib/nile/scripts/api-cleaner.sh b/lib/nile/scripts/api-cleaner.sh index c509d87d..5bc44bdd 100755 --- a/lib/nile/scripts/api-cleaner.sh +++ b/lib/nile/scripts/api-cleaner.sh @@ -1,4 +1,5 @@ #!/bin/bash # remove login override -sed -i -e '/public login/,/}/d' ./src/generated/openapi/types/PromiseAPI.ts +sed -i -e '1,/async login/ s/async login/async delete-nile-base-login/g' ./src/generated/openapi/src/apis/DefaultApi.ts +sed -i -e '/async delete-nile-base-login/,/}/d' ./src/generated/openapi/src/apis/DefaultApi.ts diff --git a/lib/nile/scripts/lib-readme-shim.sh b/lib/nile/scripts/lib-readme-shim.sh deleted file mode 100755 index 5c14fdc6..00000000 --- a/lib/nile/scripts/lib-readme-shim.sh +++ /dev/null @@ -1,9 +0,0 @@ -#!/bin/bash - -# personalize the open api docs for use with the actual interface -sed -i '' -e "/import \* as fs from 'fs';/,1 d" ./src/generated/openapi/DefaultApi.md -sed -i '' -e "s/Nile.DefaultApi/Nile/" ./src/generated/openapi/DefaultApi.md -sed -i '' -e "s/const apiInstance/const nile/" ./src/generated/openapi/DefaultApi.md -sed -i '' -e "s/apiInstance\./nile\./" ./src/generated/openapi/DefaultApi.md - -cp ./src/generated/openapi/DefaultApi.md ./src/README.md \ No newline at end of file diff --git a/lib/nile/src/Nile.ts b/lib/nile/src/Nile.ts index 2342c34d..b1e61927 100644 --- a/lib/nile/src/Nile.ts +++ b/lib/nile/src/Nile.ts @@ -1,32 +1,17 @@ /** - * the below files need generated with `yarn build:exp` + * dependencies need generated with `yarn build:api:gen` */ -import NileApi from './generated/openapi/types/PromiseAPI'; -import { - createConfiguration, - ConfigurationParameters, -} from './generated/openapi/configuration'; -import { ServerConfiguration } from './generated/openapi/servers'; -import { - DefaultApiRequestFactory, - DefaultApiResponseProcessor, -} from './generated/openapi/apis/DefaultApi'; +import { DefaultApi } from './generated/openapi/src/'; +import { Configuration } from './generated/openapi/src/runtime'; -function ApiImpl( - config?: ConfigurationParameters & { apiUrl: string } -): NileApi { - const server = new ServerConfiguration<{ [key: string]: string }>( - config?.apiUrl ?? '/', - {} - ); - const _config = { - baseServer: server, - ...config, - }; - const cfg = createConfiguration(_config); - const nileService = new DefaultApiRequestFactory(cfg); - const nileProcessor = new DefaultApiResponseProcessor(); - const nile = new NileApi(cfg, nileService, nileProcessor); +type NileConfig = Configuration & { apiUrl: string }; +function ApiImpl(config?: NileConfig): DefaultApi { + if (!config) { + return new DefaultApi(); + } + + const cfg = new Configuration({ ...config, basePath: config.apiUrl }); + const nile = new DefaultApi(cfg); return nile; } export default ApiImpl; diff --git a/lib/nile/src/index.ts b/lib/nile/src/index.ts index 09ae3d13..e9e1058c 100644 --- a/lib/nile/src/index.ts +++ b/lib/nile/src/index.ts @@ -1,7 +1,10 @@ import Nile from './Nile'; -export { default as NileApi } from './generated/openapi/types/PromiseAPI'; +export { + DefaultApi as NileApi, + DefaultApiResults as NileApiResults, +} from './generated/openapi/src/apis/DefaultApi'; export default Nile; -export * from './generated/openapi/models/all'; +export * from './generated/openapi/src/models'; diff --git a/lib/nile/src/test/index.test.ts b/lib/nile/src/test/index.test.ts index f61759ea..49f62fd2 100644 --- a/lib/nile/src/test/index.test.ts +++ b/lib/nile/src/test/index.test.ts @@ -1,120 +1,68 @@ /* eslint-disable @typescript-eslint/ban-ts-comment */ import Nile from '..'; -import { LoginInfo } from '../generated/openapi/models/LoginInfo'; -import { IsomorphicFetchHttpLibrary } from '../generated/openapi/http/isomorphic-fetch'; +import { LoginInfo } from '../generated/openapi/src/models/LoginInfo'; const userPayload = { id: 4, email: 'bob@squarepants.com', }; -jest.mock('../generated/openapi/http/isomorphic-fetch'); -jest.mock('../generated/openapi/http/http', () => { - return { - RequestContext: class RequestContext { - private body: unknown; - private headers: { [key: string]: string } = {}; - constructor() { - // do nothing - } - - public setHeaderParam(key: string, value: string): void { - this.headers[key] = value; - } - - public setBody(body: unknown) { - return (this.body = body); - } - - public getBody() { - return this.body; - } - - public getUrl() { - return null; - } - - public getHeaders() { - return {}; - } - - public getHttpMethod() { - return 'GET'; - } - }, - HttpMethod: { - GET: 'GET', - HEAD: 'HEAD', - POST: 'POST', - PUT: 'PUT', - DELETE: 'DELETE', - CONNECT: 'CONNECT', - OPTIONS: 'OPTIONS', - TRACE: 'TRACE', - PATCH: 'PATCH', - }, - }; -}); - describe('index', () => { describe('login', () => { let payload: LoginInfo; beforeEach(() => { payload = { email: userPayload.email, password: 'super secret' }; // @ts-expect-error - IsomorphicFetchHttpLibrary.mockReset(); + global.fetch = jest.fn(() => + Promise.resolve({ + json: () => Promise.resolve({ rates: { CAD: 1.42 } }), + }) + ); + }); + afterEach(() => { + // @ts-expect-error + fetch.mockClear(); }); it('works', async () => { // @ts-expect-error - IsomorphicFetchHttpLibrary.mockImplementation(() => { - return { - send: () => { - return { - toPromise: () => { - return { - httpStatusCode: 200, - headers: { 'content-type': 'application/json' }, - body: { - text: () => { - return JSON.stringify({ token: 'password123' }); - }, - }, - }; - }, - }; - }, - }; - }); + fetch.mockImplementation(() => ({ + status: 200, + json: () => Promise.resolve({ token: 123 }), + })); const nile = Nile(); - await nile.login(payload); - expect(nile.authToken).not.toBeNull(); + await nile.login({ loginInfo: payload }); + expect(nile.authToken).toBeTruthy(); }); it('does not work', async () => { // @ts-expect-error - IsomorphicFetchHttpLibrary.mockImplementation(() => { - return { - send: () => { - return { - toPromise: () => { - return { - httpStatusCode: 200, - headers: { 'content-type': 'application/json' }, - body: { - text: () => { - return JSON.stringify({}); - }, - }, - }; - }, - }; - }, - }; - }); - + fetch.mockImplementation(() => ({ + status: 200, + json: () => Promise.resolve({}), + })); const nile = Nile(); + await nile.login({ loginInfo: payload }); + + expect(nile.authToken).toBeFalsy(); + }); - await nile.login(payload); + it('cancels', async () => { + const abortSpy = jest.spyOn(AbortController.prototype, 'abort'); + // eat the warning, we're gonna make it happen + jest.spyOn(console, 'warn').mockImplementation(() => null); + + const json = jest.fn(); + + // @ts-expect-error + fetch.mockImplementation(() => ({ + status: 200, + json, + })); + const nile = Nile(); + const request = nile.loginRaw({ loginInfo: payload }); + request.controller.abort(); + expect(abortSpy).toBeCalled(); + expect(json).not.toBeCalled(); expect(nile.authToken).toBeFalsy(); }); }); diff --git a/lib/nile/templates/apis.mustache b/lib/nile/templates/apis.mustache new file mode 100644 index 00000000..03ce6657 --- /dev/null +++ b/lib/nile/templates/apis.mustache @@ -0,0 +1,421 @@ +/* tslint:disable */ +/* eslint-disable */ +{{>licenseInfo}} + +import * as runtime from '../runtime'; +{{#imports.0}} +import { + {{#imports}} + {{className}}, + {{^withoutRuntimeChecks}} + {{className}}FromJSON, + {{className}}ToJSON, + {{/withoutRuntimeChecks}} + {{/imports}} +} from '../models'; +{{/imports.0}} + +{{#operations}} +{{#operation}} +{{#allParams.0}} +export interface {{#prefixParameterInterfaces}}{{classname}}{{/prefixParameterInterfaces}}{{operationIdCamelCase}}Request { +{{#allParams}} + {{paramName}}{{^required}}?{{/required}}: {{#isEnum}}{{{datatypeWithEnum}}}{{/isEnum}}{{^isEnum}}{{{dataType}}}{{#isNullable}} | null{{/isNullable}}{{/isEnum}}; +{{/allParams}} +} + +{{/allParams.0}} +{{/operation}} +{{/operations}} +{{#withInterfaces}} +{{#operations}} +/** + * {{classname}} - interface + * {{#lambda.indented_1}}{{{unescapedDescription}}}{{/lambda.indented_1}} + * @export + * @interface {{classname}}Interface + */ +export interface {{classname}}Interface { +{{#operation}} + /** + * {{¬es}} + {{#summary}} + * @summary {{&summary}} + {{/summary}} + {{#allParams}} + * @param {{=<% %>=}}{<%&dataType%>}<%={{ }}=%> {{^required}}[{{/required}}{{paramName}}{{^required}}]{{/required}} {{description}} + {{/allParams}} + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof {{classname}}Interface + */ + {{nickname}}Raw({{#allParams.0}}requestParameters: {{#prefixParameterInterfaces}}{{classname}}{{/prefixParameterInterfaces}}{{operationIdCamelCase}}Request{{/allParams.0}}): { controller: AbortController, request: () => Promise> }; + + /** + {{#notes}} + * {{¬es}} + {{/notes}} + {{#summary}} + * {{&summary}} + {{/summary}} + */ + {{^useSingleRequestParameter}} + {{nickname}}({{#allParams}}{{paramName}}{{^required}}?{{/required}}: {{#isEnum}}{{{datatypeWithEnum}}}{{/isEnum}}{{^isEnum}}{{{dataType}}}{{#isNullable}} | null{{/isNullable}}{{/isEnum}}{{^-last}}, {{/-last}}{{/allParams}}): Promise<{{#returnType}}void | {{{returnType}}}{{/returnType}}{{^returnType}}void{{/returnType}}>; + {{/useSingleRequestParameter}} + {{#useSingleRequestParameter}} + {{nickname}}({{#allParams.0}}requestParameters: {{#prefixParameterInterfaces}}{{classname}}{{/prefixParameterInterfaces}}{{operationIdCamelCase}}Request{{/allParams.0}}): Promise<{{#returnType}}void | {{{returnType}}}{{/returnType}}{{^returnType}}void{{/returnType}}>; + {{/useSingleRequestParameter}} + +{{/operation}} +} +export type {{classname}}Results = +{{#operation}} + + | { controller: AbortController, request: () => Promise> } + | Promise<{{#returnType}}void | {{{returnType}}}{{/returnType}}{{^returnType}}void{{/returnType}}> + +{{/operation}} + +{{/operations}} +{{/withInterfaces}} +{{#operations}} +/** + * {{#lambda.indented_star_1}}{{{unescapedDescription}}}{{/lambda.indented_star_1}} + */ +{{#withInterfaces}} +export class {{classname}} extends runtime.BaseAPI implements {{classname}}Interface { +{{/withInterfaces}} +{{^withInterfaces}} +export class {{classname}} extends runtime.BaseAPI { +{{/withInterfaces}} + public authToken: string; + + constructor(protected configuration = new runtime.Configuration()) { + super(configuration); + this.authToken = ''; + } + + {{#operation}} + + /** + {{#notes}} + * {{¬es}} + {{/notes}} + {{#summary}} + * {{&summary}} + {{/summary}} + */ + {{^useSingleRequestParameter}} + async {{nickname}}({{#allParams}}{{paramName}}{{^required}}?{{/required}}: {{#isEnum}}{{{datatypeWithEnum}}}{{/isEnum}}{{^isEnum}}{{{dataType}}}{{#isNullable}} | null{{/isNullable}}{{/isEnum}}{{^-last}}, {{/-last}}{{/allParams}}): Promise<{{#returnType}}void | {{{returnType}}}{{/returnType}}{{^returnType}}void{{/returnType}}> { + {{#returnType}} + const response = await this.{{nickname}}Raw({{#allParams.0}}{ {{#allParams}}{{paramName}}: {{paramName}}{{^-last}}, {{/-last}}{{/allParams}} }{{/allParams.0}}); + return await response.value(); + {{/returnType}} + {{^returnType}} + await this.{{nickname}}Raw({{#allParams.0}}{ {{#allParams}}{{paramName}}: {{paramName}}{{^-last}}, {{/-last}}{{/allParams}} }{{/allParams.0}}); + {{/returnType}} + } + {{/useSingleRequestParameter}} + {{#useSingleRequestParameter}} + async {{nickname}}({{#allParams.0}}requestParameters: {{#prefixParameterInterfaces}}{{classname}}{{/prefixParameterInterfaces}}{{operationIdCamelCase}}Request{{/allParams.0}}): Promise<{{#returnType}}void | {{{returnType}}}{{/returnType}}{{^returnType}}void{{/returnType}}> { + {{#returnType}} + const response = await this.{{nickname}}Raw({{#allParams.0}}requestParameters{{/allParams.0}}); + const data = await response.request(); + return await data.value(); + {{/returnType}} + {{^returnType}} + await this.{{nickname}}Raw({{#allParams.0}}requestParameters{{/allParams.0}}); + {{/returnType}} + } + {{/useSingleRequestParameter}} + + /** + {{#notes}} + * {{¬es}} + {{/notes}} + {{#summary}} + * {{&summary}} + {{/summary}} + */ + {{nickname}}Raw({{#allParams.0}}requestParameters: {{#prefixParameterInterfaces}}{{classname}}{{/prefixParameterInterfaces}}{{operationIdCamelCase}}Request{{/allParams.0}}): { controller: AbortController, request: () => Promise> } { + const controller = new AbortController(); + {{#allParams}} + {{#required}} + if (requestParameters.{{paramName}} === null || requestParameters.{{paramName}} === undefined) { + throw new runtime.RequiredError('{{paramName}}','Required parameter requestParameters.{{paramName}} was null or undefined when calling {{nickname}}.'); + } + + {{/required}} + {{/allParams}} + const queryParameters: any = {}; + + {{#queryParams}} + {{#isArray}} + if (requestParameters.{{paramName}}) { + {{#isCollectionFormatMulti}} + queryParameters['{{baseName}}'] = requestParameters.{{paramName}}; + {{/isCollectionFormatMulti}} + {{^isCollectionFormatMulti}} + queryParameters['{{baseName}}'] = {{#uniqueItems}}Array.from({{/uniqueItems}}requestParameters.{{paramName}}{{#uniqueItems}}){{/uniqueItems}}.join(runtime.COLLECTION_FORMATS["{{collectionFormat}}"]); + {{/isCollectionFormatMulti}} + } + + {{/isArray}} + {{^isArray}} + if (requestParameters.{{paramName}} !== undefined) { + {{#isDateTime}} + queryParameters['{{baseName}}'] = (requestParameters.{{paramName}} as any).toISOString(); + {{/isDateTime}} + {{^isDateTime}} + {{#isDate}} + queryParameters['{{baseName}}'] = (requestParameters.{{paramName}} as any).toISOString().substr(0,10); + {{/isDate}} + {{^isDate}} + queryParameters['{{baseName}}'] = requestParameters.{{paramName}}; + {{/isDate}} + {{/isDateTime}} + } + + {{/isArray}} + {{/queryParams}} + const headerParameters: runtime.HTTPHeaders = {}; + {{#bodyParam}} + {{^consumes}} + headerParameters['Content-Type'] = 'application/json'; + {{/consumes}} + + {{#consumes.0}} + headerParameters['Content-Type'] = '{{{mediaType}}}'; + + {{/consumes.0}} + {{/bodyParam}} + {{#headerParams}} + {{#isArray}} + if (requestParameters.{{paramName}}) { + headerParameters['{{baseName}}'] = {{#uniqueItems}}Array.from({{/uniqueItems}}requestParameters.{{paramName}}{{#uniqueItems}}){{/uniqueItems}}.join(runtime.COLLECTION_FORMATS["{{collectionFormat}}"]); + } + + {{/isArray}} + {{^isArray}} + if (requestParameters.{{paramName}} !== undefined && requestParameters.{{paramName}} !== null) { + headerParameters['{{baseName}}'] = String(requestParameters.{{paramName}}); + } + + {{/isArray}} + {{/headerParams}} + {{#authMethods}} + {{#isBasic}} + {{#isBasicBasic}} + if (this.configuration && (this.configuration.username !== undefined || this.configuration.password !== undefined)) { + headerParameters["Authorization"] = "Basic " + btoa(this.configuration.username + ":" + this.configuration.password); + } + {{/isBasicBasic}} + {{#isBasicBearer}} + if (this.configuration && this.configuration.accessToken) { + const token = this.configuration.accessToken; + const tokenString = typeof token === 'function' ? token("{{name}}", [{{#scopes}}"{{{scope}}}"{{^-last}}, {{/-last}}{{/scopes}}]) : token; + + if (tokenString) { + headerParameters["Authorization"] = `Bearer ${tokenString}`; + } + } + {{/isBasicBearer}} + {{/isBasic}} + {{#isApiKey}} + {{#isKeyInHeader}} + if (this.configuration && this.configuration.apiKey) { + headerParameters["{{keyParamName}}"] = this.configuration.apiKey("{{keyParamName}}"); // {{name}} authentication + } + + {{/isKeyInHeader}} + + {{#isKeyInQuery}} + if (this.configuration && this.configuration.apiKey) { + queryParameters["{{keyParamName}}"] = this.configuration.apiKey("{{keyParamName}}"); // {{name}} authentication + } + + {{/isKeyInQuery}} + {{/isApiKey}} + {{#isOAuth}} + if (this.configuration && this.configuration.accessToken) { + // oauth required + if (typeof this.configuration.accessToken === 'function') { + headerParameters["Authorization"] = this.configuration.accessToken("{{name}}", [{{#scopes}}"{{{scope}}}"{{^-last}}, {{/-last}}{{/scopes}}]); + } else { + headerParameters["Authorization"] = this.configuration.accessToken; + } + } + + {{/isOAuth}} + {{/authMethods}} + {{#hasFormParams}} + const consumes: runtime.Consume[] = [ + {{#consumes}} + { contentType: '{{{mediaType}}}' }, + {{/consumes}} + ]; + // @ts-ignore: canConsumeForm may be unused + const canConsumeForm = runtime.canConsumeForm(consumes); + + let formParams: { append(param: string, value: any): any }; + let useForm = false; + {{#formParams}} + {{#isFile}} + // use FormData to transmit files using content-type "multipart/form-data" + useForm = canConsumeForm; + {{/isFile}} + {{/formParams}} + if (useForm) { + formParams = new FormData(); + } else { + formParams = new URLSearchParams(); + } + + {{#formParams}} + {{#isArray}} + if (requestParameters.{{paramName}}) { + {{#isCollectionFormatMulti}} + requestParameters.{{paramName}}.forEach((element) => { + formParams.append('{{baseName}}', element as any); + }) + {{/isCollectionFormatMulti}} + {{^isCollectionFormatMulti}} + formParams.append('{{baseName}}', {{#uniqueItems}}Array.from({{/uniqueItems}}requestParameters.{{paramName}}{{#uniqueItems}}){{/uniqueItems}}.join(runtime.COLLECTION_FORMATS["{{collectionFormat}}"])); + {{/isCollectionFormatMulti}} + } + + {{/isArray}} + {{^isArray}} + if (requestParameters.{{paramName}} !== undefined) { + {{#isPrimitiveType}} + formParams.append('{{baseName}}', requestParameters.{{paramName}} as any); + {{/isPrimitiveType}} + {{^isPrimitiveType}} + {{^withoutRuntimeChecks}} + formParams.append('{{baseName}}', new Blob([JSON.stringify({{{dataType}}}ToJSON(requestParameters.{{paramName}}))], { type: "application/json", })); + {{/withoutRuntimeChecks}}{{#withoutRuntimeChecks}} + formParams.append('{{baseName}}', new Blob([JSON.stringify(requestParameters.{{paramName}})], { type: "application/json", })); + {{/withoutRuntimeChecks}} + {{/isPrimitiveType}} + } + + {{/isArray}} + {{/formParams}} + {{/hasFormParams}} + if (this.authToken) { + headerParameters["Authorization"] = `Bearer ${this.authToken}`; + } + + const response = () => this.request({ + path: `{{{path}}}`{{#pathParams}}.replace(`{${"{{baseName}}"}}`, encodeURIComponent(String(requestParameters.{{paramName}}))){{/pathParams}}, + method: '{{httpMethod}}', + headers: headerParameters, + query: queryParameters, + {{#hasBodyParam}} + {{#bodyParam}} + {{#isContainer}} + {{^withoutRuntimeChecks}} + body: requestParameters.{{paramName}}{{#isArray}}{{#items}}{{^isPrimitiveType}}.map({{datatype}}ToJSON){{/isPrimitiveType}}{{/items}}{{/isArray}}, + {{/withoutRuntimeChecks}} + {{#withoutRuntimeChecks}} + body: requestParameters.{{paramName}}{{#isArray}}{{#items}}{{^isPrimitiveType}}{{/isPrimitiveType}}{{/items}}{{/isArray}}, + {{/withoutRuntimeChecks}} + {{/isContainer}} + {{^isContainer}} + {{^isPrimitiveType}} + {{^withoutRuntimeChecks}} + body: {{dataType}}ToJSON(requestParameters.{{paramName}}), + {{/withoutRuntimeChecks}} + {{#withoutRuntimeChecks}} + body: requestParameters.{{paramName}}, + {{/withoutRuntimeChecks}} + {{/isPrimitiveType}} + {{#isPrimitiveType}} + body: requestParameters.{{paramName}} as any, + {{/isPrimitiveType}} + {{/isContainer}} + {{/bodyParam}} + {{/hasBodyParam}} + {{#hasFormParams}} + body: formParams, + {{/hasFormParams}} + }, { signal: controller.signal }); + + {{#returnType}} + {{#isResponseFile}} + const request = async () => new runtime.BlobApiResponse(await response()); + {{/isResponseFile}} + {{^isResponseFile}} + {{#returnTypeIsPrimitive}} + {{#isMap}} + const request = async () => new runtime.JSONApiResponse(await response()); + {{/isMap}} + {{#isArray}} + const request = async () => new runtime.JSONApiResponse(await response()); + {{/isArray}} + {{#returnSimpleType}} + const request = async () => new runtime.TextApiResponse(await response()) as any; + {{/returnSimpleType}} + {{/returnTypeIsPrimitive}} + {{^returnTypeIsPrimitive}} + {{#isArray}} + const request = async () => new runtime.JSONApiResponse(await response(){{^withoutRuntimeChecks}}, (jsonValue) => {{#uniqueItems}}new Set({{/uniqueItems}}jsonValue.map({{returnBaseType}}FromJSON){{/withoutRuntimeChecks}}){{#uniqueItems}}{{/uniqueItems}}; + {{/isArray}} + {{^isArray}} + {{#isMap}} + const request = async () => new runtime.JSONApiResponse(await response(){{^withoutRuntimeChecks}}, (jsonValue) => runtime.mapValues(jsonValue, {{returnBaseType}}FromJSON){{/withoutRuntimeChecks}}); + {{/isMap}} + {{^isMap}} + const request = async () => new runtime.JSONApiResponse(await response(){{^withoutRuntimeChecks}}, (jsonValue) => {{returnBaseType}}FromJSON(jsonValue){{/withoutRuntimeChecks}}); + {{/isMap}} + {{/isArray}} + {{/returnTypeIsPrimitive}} + {{/isResponseFile}} + {{/returnType}} + {{^returnType}} + const request = async () => new runtime.VoidApiResponse(await response()); + {{/returnType}} + return { + controller, + request + } + } + {{/operation}} + + /** + * Log in to service + */ + async login(requestParameters: LoginRequest): Promise { + const response = await this.loginRaw(requestParameters); + const data = await response.request(); + const token = await data.value(); + if (token) { + this.authToken = token.token; + } + return token; + } +} +{{/operations}} + +{{#hasEnums}} + +{{#operations}} +{{#operation}} +{{#allParams}} +{{#isEnum}} +/** + * @export + * @enum {string} + */ +export enum {{operationIdCamelCase}}{{enumName}} { +{{#allowableValues}} + {{#enumVars}} + {{{name}}} = {{{value}}}{{^-last}},{{/-last}} + {{/enumVars}} +{{/allowableValues}} +} +{{/isEnum}} +{{/allParams}} +{{/operation}} +{{/operations}} +{{/hasEnums}} \ No newline at end of file diff --git a/lib/nile/templates/runtime.mustache b/lib/nile/templates/runtime.mustache new file mode 100644 index 00000000..5b5b4a07 --- /dev/null +++ b/lib/nile/templates/runtime.mustache @@ -0,0 +1,373 @@ +/* tslint:disable */ +/* eslint-disable */ +{{>licenseInfo}} + +export const BASE_PATH = "{{{basePath}}}".replace(/\/+$/, ""); + +export interface ConfigurationParameters { + basePath?: string; // override base path + fetchApi?: FetchAPI; // override for fetch implementation + middleware?: Middleware[]; // middleware to apply before/after fetch requests + queryParamsStringify?: (params: HTTPQuery) => string; // stringify function for query strings + username?: string; // parameter for basic security + password?: string; // parameter for basic security + apiKey?: string | ((name: string) => string); // parameter for apiKey security + accessToken?: string | Promise | ((name?: string, scopes?: string[]) => string | Promise); // parameter for oauth2 security + headers?: HTTPHeaders; //header params we want to use on every request + credentials?: RequestCredentials; //value for the credentials param we want to use on each request +} + +export class Configuration { + constructor(private configuration: ConfigurationParameters = {}) {} + + set config(configuration: Configuration) { + this.configuration = configuration; + } + + get basePath(): string { + return this.configuration.basePath != null ? this.configuration.basePath : BASE_PATH; + } + + get fetchApi(): FetchAPI | undefined { + return this.configuration.fetchApi; + } + + get middleware(): Middleware[] { + return this.configuration.middleware || []; + } + + get queryParamsStringify(): (params: HTTPQuery) => string { + return this.configuration.queryParamsStringify || querystring; + } + + get username(): string | undefined { + return this.configuration.username; + } + + get password(): string | undefined { + return this.configuration.password; + } + + get apiKey(): ((name: string) => string) | undefined { + const apiKey = this.configuration.apiKey; + if (apiKey) { + return typeof apiKey === 'function' ? apiKey : () => apiKey; + } + return undefined; + } + + get accessToken(): ((name?: string, scopes?: string[]) => string | Promise) | undefined { + const accessToken = this.configuration.accessToken; + if (accessToken) { + return typeof accessToken === 'function' ? accessToken : async () => accessToken; + } + return undefined; + } + + get headers(): HTTPHeaders | undefined { + return this.configuration.headers; + } + + get credentials(): RequestCredentials | undefined { + return this.configuration.credentials; + } +} + +export const DefaultConfig = new Configuration(); + +/** + * This is the base class for all generated API classes. + */ +export class BaseAPI { + + private middleware: Middleware[]; + + constructor(protected configuration = DefaultConfig) { + this.middleware = configuration.middleware; + } + + withMiddleware(this: T, ...middlewares: Middleware[]) { + const next = this.clone(); + next.middleware = next.middleware.concat(...middlewares); + return next; + } + + withPreMiddleware(this: T, ...preMiddlewares: Array) { + const middlewares = preMiddlewares.map((pre) => ({ pre })); + return this.withMiddleware(...middlewares); + } + + withPostMiddleware(this: T, ...postMiddlewares: Array) { + const middlewares = postMiddlewares.map((post) => ({ post })); + return this.withMiddleware(...middlewares); + } + + protected async request(context: RequestOpts, initOverrides?: RequestInit | InitOverideFunction): Promise { + const { url, init } = await this.createFetchParams(context, initOverrides); + const response = await this.fetchApi(url, init); + // requests are cancelable, silently fail + if (response) { + if (response.status >= 200 && response.status < 300) { + return response; + } + throw new ResponseError(response, 'Response returned an error code'); + } + + return Promise.resolve(undefined); + } + + private async createFetchParams(context: RequestOpts, initOverrides?: RequestInit | InitOverideFunction) { + let url = this.configuration.basePath + context.path; + if (context.query !== undefined && Object.keys(context.query).length !== 0) { + // only add the querystring to the URL if there are query parameters. + // this is done to avoid urls ending with a "?" character which buggy webservers + // do not handle correctly sometimes. + url += '?' + this.configuration.queryParamsStringify(context.query); + } + + const headers = Object.assign({}, this.configuration.headers, context.headers); + Object.keys(headers).forEach(key => headers[key] === undefined ? delete headers[key] : {}); + + const initOverrideFn = + typeof initOverrides === "function" + ? initOverrides + : async () => initOverrides; + + const initParams = { + method: context.method, + headers, + body: context.body, + credentials: this.configuration.credentials, + }; + + const overridedInit: RequestInit = { + ...initParams, + ...(await initOverrideFn({ + init: initParams, + context, + })) + } + + const init: RequestInit = { + ...overridedInit, + body: + isFormData(overridedInit.body) || + overridedInit.body instanceof URLSearchParams || + isBlob(overridedInit.body) + ? overridedInit.body + : JSON.stringify(overridedInit.body), + }; + + return { url, init }; + } + + private fetchApi = async (url: string, init: RequestInit) => { + let fetchParams = { url, init }; + for (const middleware of this.middleware) { + if (middleware.pre) { + fetchParams = await middleware.pre({ + fetch: this.fetchApi, + ...fetchParams, + }) || fetchParams; + } + } + let response = await (this.configuration.fetchApi || fetch)(fetchParams.url, fetchParams.init).catch(e=> console.warn(e)); + if (response) { + for (const middleware of this.middleware) { + if (middleware.post) { + response = await middleware.post({ + fetch: this.fetchApi, + url: fetchParams.url, + init: fetchParams.init, + response: response.clone(), + }) || response; + } + } + } + return response; + } + + /** + * Create a shallow clone of `this` by constructing a new instance + * and then shallow cloning data members. + */ + private clone(this: T): T { + const constructor = this.constructor as any; + const next = new constructor(this.configuration); + next.middleware = this.middleware.slice(); + return next; + } +}; + +function isBlob(value: any): value is Blob { + return typeof Blob !== 'undefined' && value instanceof Blob +} + +function isFormData(value: any): value is FormData { + return typeof FormData !== "undefined" && value instanceof FormData +} + +export class ResponseError extends Error { + name: "ResponseError" = "ResponseError"; + constructor(public response: Response, msg?: string) { + super(msg); + } +} + +export class RequiredError extends Error { + name: "RequiredError" = "RequiredError"; + constructor(public field: string, msg?: string) { + super(msg); + } +} + +export const COLLECTION_FORMATS = { + csv: ",", + ssv: " ", + tsv: "\t", + pipes: "|", +}; + +export type FetchAPI = WindowOrWorkerGlobalScope['fetch']; + +export type Json = any; +export type HTTPMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'OPTIONS' | 'HEAD'; +export type HTTPHeaders = { [key: string]: string }; +export type HTTPQuery = { [key: string]: string | number | null | boolean | Array | HTTPQuery }; +export type HTTPBody = Json | FormData | URLSearchParams; +export type HTTPRequestInit = { headers?: HTTPHeaders; method: HTTPMethod; credentials?: RequestCredentials; body?: HTTPBody } +export type ModelPropertyNaming = 'camelCase' | 'snake_case' | 'PascalCase' | 'original'; + +export type InitOverideFunction = (requestContext: { init: HTTPRequestInit, context: RequestOpts }) => Promise + +export interface FetchParams { + url: string; + init: RequestInit; +} + +export interface RequestOpts { + path: string; + method: HTTPMethod; + headers: HTTPHeaders; + query?: HTTPQuery; + body?: HTTPBody; +} + +{{^withoutRuntimeChecks}} +export function exists(json: any, key: string) { + const value = json[key]; + return value !== null && value !== undefined; +} +{{/withoutRuntimeChecks}} + +export function querystring(params: HTTPQuery, prefix: string = ''): string { + return Object.keys(params) + .map((key) => { + const fullKey = prefix + (prefix.length ? `[${key}]` : key); + const value = params[key]; + if (value instanceof Array) { + const multiValue = value.map(singleValue => encodeURIComponent(String(singleValue))) + .join(`&${encodeURIComponent(fullKey)}=`); + return `${encodeURIComponent(fullKey)}=${multiValue}`; + } + if (value instanceof Date) { + return `${encodeURIComponent(fullKey)}=${encodeURIComponent(value.toISOString())}`; + } + if (value instanceof Object) { + return querystring(value as HTTPQuery, fullKey); + } + return `${encodeURIComponent(fullKey)}=${encodeURIComponent(String(value))}`; + }) + .filter(part => part.length > 0) + .join('&'); +} + +{{^withoutRuntimeChecks}} +export function mapValues(data: any, fn: (item: any) => any) { + return Object.keys(data).reduce( + (acc, key) => ({ ...acc, [key]: fn(data[key]) }), + {} + ); +} +{{/withoutRuntimeChecks}} + +export function canConsumeForm(consumes: Consume[]): boolean { + for (const consume of consumes) { + if ('multipart/form-data' === consume.contentType) { + return true; + } + } + return false; +} + +export interface Consume { + contentType: string +} + +export interface RequestContext { + fetch: FetchAPI; + url: string; + init: RequestInit; +} + +export interface ResponseContext { + fetch: FetchAPI; + url: string; + init: RequestInit; + response: Response; +} + +export interface Middleware { + pre?(context: RequestContext): Promise; + post?(context: ResponseContext): Promise; +} + +export interface ApiResponse { + raw: void | Response; + value(): Promise; +} + +export interface ResponseTransformer { + (json: any): T; +} + +export class JSONApiResponse { + constructor(public raw: void | Response, private transformer: ResponseTransformer = (jsonValue: any) => jsonValue) {} + + async value(): Promise { + if (this.raw && this.raw.json) { + return this.transformer(await this.raw.json()); + } + return undefined; + } +} + +export class VoidApiResponse { + constructor(public raw: void | Response) {} + + async value(): Promise { + return undefined; + } +} + +export class BlobApiResponse { + constructor(public raw: void | Response) {} + + async value(): Promise { + if (this.raw && this.raw.blob) { + return await this.raw.blob(); + } + return undefined; + }; +} + +export class TextApiResponse { + constructor(public raw: void | Response) {} + + async value(): Promise { + if (this.raw && this.raw.text) { + return await this.raw.text(); + } + return undefined; + }; +} \ No newline at end of file diff --git a/lib/nile/templates/types/PromiseAPI.mustache b/lib/nile/templates/types/PromiseAPI.mustache deleted file mode 100644 index bc8fed1f..00000000 --- a/lib/nile/templates/types/PromiseAPI.mustache +++ /dev/null @@ -1,104 +0,0 @@ -import { ResponseContext, RequestContext, HttpFile } from '../http/http{{extensionForDeno}}'; -import * as models from '../models/all{{extensionForDeno}}'; -import { Configuration} from '../configuration{{extensionForDeno}}' -import { AuthMethods } from '../auth/auth'; -{{#useInversify}} -import { injectable, inject, optional } from "inversify"; -import { AbstractConfiguration } from "../services/configuration"; -{{/useInversify}} - -{{#models}} -{{#model}} -import { {{{ classname }}} } from '../models/{{{ classFilename }}}{{extensionForDeno}}'; -{{/model}} -{{/models}} -{{#apiInfo}} -{{#apis}} -import { Observable{{classname}} } from './ObservableAPI{{extensionForDeno}}'; - -{{#operations}} -import { {{classname}}RequestFactory, {{classname}}ResponseProcessor} from "../apis/{{classname}}{{extensionForDeno}}"; -{{#useInversify}} -import { Abstract{{classname}}RequestFactory, Abstract{{classname}}ResponseProcessor } from "../apis/{{classname}}.service"; - -@injectable() -{{/useInversify}} -export default class Promise{{classname}} { - private api: Observable{{classname}}; - private config: Configuration; - public authToken: string; - public constructor( - {{#useInversify}} - @inject(AbstractConfiguration) configuration: Configuration, - @inject(Abstract{{classname}}RequestFactory) @optional() requestFactory?: Abstract{{classname}}RequestFactory, - @inject(Abstract{{classname}}ResponseProcessor) @optional() responseProcessor?: Abstract{{classname}}ResponseProcessor - {{/useInversify}} - {{^useInversify}} - configuration: Configuration, - requestFactory?: {{classname}}RequestFactory, - responseProcessor?: {{classname}}ResponseProcessor - {{/useInversify}} - ) { - this.api = new Observable{{classname}}(configuration, requestFactory, responseProcessor); - this.config = configuration; - this.authToken = ''; - } - private applyOptions(_options?: Configuration) { - const authentication: AuthMethods = { - default: { - getName: (): string => 'Bearer Authentication', - applySecurityAuthentication: (requestContext: RequestContext): void => { - if (this.authToken) { - requestContext.setHeaderParam( - 'Authorization', - `Bearer ${this.authToken}` - ); - } - }, - }, - }; - return { ..._options, ...this.config, authMethods: authentication }; - } - -{{#operation}} - /** - {{#notes}} - * {{¬es}} - {{/notes}} - {{#summary}} - * {{&summary}} - {{/summary}} - {{#allParams}} - * @param {{paramName}} {{description}} - {{/allParams}} - */ - public {{nickname}}({{#allParams}}{{paramName}}{{^required}}?{{/required}}: {{{dataType}}}, {{/allParams}}_options?: Configuration): Promise<{{{returnType}}}{{^returnType}}void{{/returnType}}> { - const result = this.api.{{nickname}}({{#allParams}}{{paramName}}, {{/allParams}}this.applyOptions(_options)); - return result.toPromise(); - } - -{{/operation}} - /** - * Log in to service - * Saves an auth token for later calls - * @param loginInfo - */ - public async login( - loginInfo: LoginInfo, - _options?: Configuration - ): Promise { - const result = this.api.login(loginInfo, this.applyOptions(_options)); - const res = await result.toPromise(); - const { token } = (res as unknown as { token: string }) ?? {}; - if (token) { - this.authToken = token; - } - return res; - } -} - -{{/operations}} - - -{{/apis}} -{{/apiInfo}} \ No newline at end of file diff --git a/package.json b/package.json index ce8c0e8a..1d1aca88 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "typescript": "^4.6.2" }, "lint-staged": { - "packages/**/**/*.{mjs,js,ts,jsx,tsx}": "yarn lint --cache --fix --ignore-pattern '!packages/react/.storybook'", - "lib/nile/src/*.{mjs,js,ts,jsx,tsx}": "yarn lint --cache --fix" + "packages/**/**/*.{mjs,js,ts,jsx,tsx}": "yarn lint --cache --fix --ignore-pattern '!packages/react/.storybook' --resolve-plugins-relative-to .", + "lib/nile/src/*.{mjs,js,ts,jsx,tsx}": "yarn lint --cache --fix --resolve-plugins-relative-to ." } } \ No newline at end of file diff --git a/packages/examples/pages/org.tsx b/packages/examples/pages/org.tsx index f36fe8bf..e396dc99 100644 --- a/packages/examples/pages/org.tsx +++ b/packages/examples/pages/org.tsx @@ -1,61 +1,45 @@ import React from 'react'; -import { useState } from 'react'; -import { useNile } from '@theniledev/react'; +import { useNile, useNileFetch, LoginForm } from '@theniledev/react'; -import { Button } from '../components/Button'; import { ComponentList } from '../components/ComponentList'; -function Org() { - const [users, setUsers] = useState(null); +const Orgs = React.memo(() => { const nile = useNile(); - async function handleSubmit() { - const email = document.querySelector('#email') as HTMLInputElement; - const password = document.querySelector('#password') as HTMLInputElement; + const [isLoading, orgs] = useNileFetch(() => nile.listOrganizations()); + return ( + <> +
+ {isLoading ? ( + Loading... + ) : ( + <> +

🤩 InstaExpense 🤩

+
{JSON.stringify(orgs, null, 2)}
+ + )} +
+ + + ); +}); - const payload = { - email: email.value, - password: password.value, - }; - const success = await nile - .login(payload) - .catch(() => alert('things went bad')); +Orgs.displayName = 'orgs'; - if (success) { - const users = await nile.listOrganizations(); - setUsers(users); - } - } +function Org() { + const [success, setSuccess] = React.useState(false); - if (users) { - return ( - <> -
-

🤩 InstaExpense 🤩

-
{JSON.stringify(users, null, 2)}
-
- - - ); + if (success) { + return ; } return ( <> -
-

🤩 InstaExpense 🤩

-

Sign in

- -
- -
- -
- -
-
- -
+

🤩 InstaExpense 🤩

+ { + setSuccess(true); + }} + /> ); diff --git a/packages/examples/pages/users.tsx b/packages/examples/pages/users.tsx index 7340b6ed..dc490378 100644 --- a/packages/examples/pages/users.tsx +++ b/packages/examples/pages/users.tsx @@ -1,28 +1,17 @@ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useState } from 'react'; import { useRouter } from 'next/router'; -import { useNile } from '@theniledev/react'; +import { useNile, useNileFetch } from '@theniledev/react'; import { Invite, User } from '@theniledev/js'; function SignIn() { const nile = useNile(); - const [users, setUsers] = useState>(); - const [invites, setInvites] = useState>(); const [joinCode, setJoinCode] = useState(''); const router = useRouter(); - useEffect(() => { - async function listUsers() { - const fetchedUsers = await nile.listUsers(); - if (fetchedUsers) { - setUsers(fetchedUsers); - } - } - async function getInvites() { - setInvites(await nile.listInvites()); - } - listUsers(); - getInvites(); - }, [nile]); + const [isLoading, [users, invites]] = useNileFetch<[User[], Invite[]]>(() => [ + nile.listUsers({}), + nile.listInvites({}), + ]); const handleLogout = useCallback(() => { nile.authToken = ''; @@ -30,8 +19,13 @@ function SignIn() { }, [nile, router]); const submitInvite = useCallback(async () => { - await nile.acceptInvite(Number(joinCode)); + await nile.acceptInvite({ code: Number(joinCode) }); }, [joinCode, nile]); + + if (isLoading) { + return <>Loading...; + } + return ( <>

🤩 InstaExpense 🤩

diff --git a/packages/react/README.md b/packages/react/README.md index 5d36f09e..75de4f90 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -25,26 +25,12 @@ A method exposing the `@theniledev/js` instance created in ``. T ```typescript import React, { useEffect } from 'react'; -import { useNile } from '@theniledev/react'; +import { useNile, useNileFetch } from '@theniledev/react'; export default function UserTable() { const nile = useNile(); const [users, setUsers] = useState(); - - useEffect(() => { - if (!nile.authToken) { - console.error('Request not authenticated, please sign in.'); - } - async function listUsers() { - const fetchedUsers = await nile - .listUsers() - .catch((error) => console.error(error)); - if (fetchedUsers) { - setUsers(fetchedUsers); - } - } - listUsers(); - }, []); + const [, users] = useNileFetch(() => nile.listUsers()); return ( users && diff --git a/packages/react/package.json b/packages/react/package.json index 4365af61..09eea5be 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -66,6 +66,7 @@ "@storybook/react": "^6.4.20", "@testing-library/jest-dom": "^5.16.3", "@testing-library/react": "^12.0.0", + "@testing-library/react-hooks": "^8.0.0", "@types/react": "17.0.43", "@types/react-dom": "17.0.14", "@typescript-eslint/parser": "^5.18.0", diff --git a/packages/react/src/components/LoginForm/index.tsx b/packages/react/src/components/LoginForm/index.tsx index 2d1a65fb..e12b1fbf 100644 --- a/packages/react/src/components/LoginForm/index.tsx +++ b/packages/react/src/components/LoginForm/index.tsx @@ -15,25 +15,35 @@ export default function LoginForm(props: Props) { passwordLabel, passwordInput, handleSuccess, + handleFailure, } = props; - async function handleSubmit() { - const email = document.querySelector('#login #email') as HTMLInputElement; - const password = document.querySelector( - '#login #password' - ) as HTMLInputElement; - - const payload = { - email: email.value, - password: password.value, - }; - const success = await nile.login(payload).catch(() => { - alert('things went bad'); - }); - if (success) { - handleSuccess && handleSuccess(payload); - } - } + const email = + typeof document !== 'undefined' && + (document.querySelector('#login #email') as HTMLInputElement); + const password = + typeof document !== 'undefined' && + (document.querySelector('#login #password') as HTMLInputElement); + const emailValue = email ? email.value : ''; + const passwordValue = password ? password.value : ''; + + const handleSubmit = React.useCallback( + async function () { + const loginInfo = { + email: emailValue, + password: passwordValue, + }; + + const success = await nile.login({ loginInfo }).catch((e) => { + handleFailure && handleFailure(e); + }); + + if (success) { + handleSuccess && handleSuccess(loginInfo); + } + }, + [emailValue, handleFailure, handleSuccess, nile, passwordValue] + ); return (
diff --git a/packages/react/src/components/LoginForm/types.ts b/packages/react/src/components/LoginForm/types.ts index 3d7d6fce..936277bc 100644 --- a/packages/react/src/components/LoginForm/types.ts +++ b/packages/react/src/components/LoginForm/types.ts @@ -8,4 +8,5 @@ export interface Props { emailInput?: React.ReactNode | InputOverride; passwordLabel?: React.ReactNode | LabelOverride; passwordInput?: React.ReactNode | InputOverride; + handleFailure?: (error: Error) => void; } diff --git a/packages/react/src/components/SignUpForm/index.tsx b/packages/react/src/components/SignUpForm/index.tsx index bc812811..13055f96 100644 --- a/packages/react/src/components/SignUpForm/index.tsx +++ b/packages/react/src/components/SignUpForm/index.tsx @@ -22,13 +22,15 @@ export default function SignUpForm(props: Props) { '#signup #password' ) as HTMLInputElement; - const payload = { + const createUserRequest = { email: email.value, password: password.value, }; - await nile.createUser(payload).catch(() => alert('things went bad')); - handleSuccess && handleSuccess(payload); + await nile + .createUser({ createUserRequest }) + .catch(() => alert('things went bad')); + handleSuccess && handleSuccess(createUserRequest); } return ( diff --git a/packages/react/src/hooks/README.md b/packages/react/src/hooks/README.md new file mode 100644 index 00000000..2dafd479 --- /dev/null +++ b/packages/react/src/hooks/README.md @@ -0,0 +1,94 @@ +# hooks + +## useNileFetch + +This hook is a wrapper around the basic fetch functions of the SDK to reduce boilerplate. It also handles cancellation on in the event of unmount. + +### usage + +Each argument passed to the hook will correspond to the payload array. Because the default fetch function returns a promise which executes the network request, a callable function needs to be passed to the hook, unless the cancellable functions are used. + +``` +const [isLoading, fetched] = useNileFetch(() => nile.listUsers()); +``` + +In this case, `fetched` contains the results of the requests made to the nile backend. + +``` +const [isLoading, fetched] = useNileFetch(() => [nile.getMe(), nile.listOrganizations(), ...]); +``` + +If an array is provided in the callback function, `fetched` is an array of the requests made to the nile backend. + +### cancellation + +It is possible to cancel requests via an `AbortController`. If the `[method]Raw` version of the function is passed to the hook, they will be handled automatically. This is especially useful if there are multiple requests for a given component that may take time to resolve. + +```typescript +const [, payloads] = useNileFetch([ + nile.listOrganizationsRaw(), + nile.listUsersRaw({}), + nile.getEntityOpenAPIRaw({ + type: 'mySweetEntity', + }), +]); +// payloads[0] - organizations +// payloads[1] - users +// payloads[2] - mySweetEntities +``` + +### examples + +```typescript +import { useNile, useNileFetch } from '@theniledev/react'; + +function MyNileComponent() { + const nile = useNile(); + const [isLoading, orgs] = useNileFetch(() => nile.listOrganizations()); + if (isLoading) { + return Loading...; + } + + return ( + <>{orgs && orgs.map((org) =>
{org.orgName}
)} + ); +} +``` + +```typescript +import { useNile, useNileFetch } from '@theniledev/react'; + +function MyNileComponent() { + const nile = useNile(); + const [isLoading, orgs] = useNileFetch(nile.listOrganizationsRaw()); + if (isLoading) { + return Loading...; + } + + return ( + <>{orgs && orgs.map((org) =>
{org.orgName}
)} + ); +} +``` + +```typescript +import { User, Organization } from '@theniledev/js'; +import { useNile, useNileFetch } from '@theniledev/react'; + +function MyNileComponent() { + const nile = useNile(); + const [isLoading, [currentUser, orgs]] = useNileFetch<[User, Organization[]]>( + () => [nile.getMe(), nile.listOrganizations()] + ); + if (isLoading) { + return Loading...; + } + + return ( + <> + {currentUser &&
Welcome, {currentUser.email} !
} + {orgs && orgs.map((org) =>
{org.orgName}
)} + + ); +} +``` diff --git a/packages/react/src/hooks/useNileFetch.test.ts b/packages/react/src/hooks/useNileFetch.test.ts new file mode 100644 index 00000000..bde1a961 --- /dev/null +++ b/packages/react/src/hooks/useNileFetch.test.ts @@ -0,0 +1,67 @@ +import { act, renderHook } from '@testing-library/react-hooks'; +import { NileApiResults } from '@theniledev/js'; + +import { useNileFetch } from './useNileFetch'; + +describe('useNileFetch', () => { + it('returns loading and a single item when passed single item', async () => { + const resolver = jest.fn(); + const baseFetch: NileApiResults = Promise.resolve(resolver); + await act(async () => { + const { result } = renderHook(() => useNileFetch(baseFetch)); + await expect(result.current).toEqual([false, null]); + }); + }); + + it('returns loading and an array when multiple args are passed', async () => { + const resolver = jest.fn(); + const baseFetch: NileApiResults = Promise.resolve(resolver); + await act(async () => { + const { result } = renderHook(() => useNileFetch([baseFetch, baseFetch])); + await expect(result.current).toEqual([false, null]); + }); + }); + + it('calls promise functions and values', async () => { + const requestResolver = jest.fn(); + const valueResolver = jest.fn(); + const baseFetch: NileApiResults = { + controller: new AbortController(), + request: () => { + requestResolver(); + return Promise.resolve({ + raw: undefined, + value: () => Promise.resolve(() => valueResolver()), + }); + }, + }; + await act(async () => { + renderHook(() => useNileFetch(baseFetch)); + }); + expect(requestResolver).toHaveBeenCalledTimes(1); + expect(valueResolver).toHaveBeenCalledTimes(1); + }); + + it('calls functions ', async () => { + const requestResolver = jest.fn(); + const baseFetch = () => Promise.resolve(requestResolver()); + await act(async () => { + renderHook(() => useNileFetch(baseFetch)); + }); + expect(requestResolver).toHaveBeenCalledTimes(1); + }); + + it('calls function with an array', async () => { + const requestResolver1 = jest.fn(); + const requestResolver2 = jest.fn(); + const baseFetch = () => [ + Promise.resolve(requestResolver1()), + Promise.resolve(requestResolver2()), + ]; + await act(async () => { + renderHook(() => useNileFetch(baseFetch)); + }); + expect(requestResolver1).toHaveBeenCalledTimes(1); + expect(requestResolver2).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/react/src/hooks/useNileFetch.ts b/packages/react/src/hooks/useNileFetch.ts new file mode 100644 index 00000000..48c0343b --- /dev/null +++ b/packages/react/src/hooks/useNileFetch.ts @@ -0,0 +1,114 @@ +import { NileApiResults } from '@theniledev/js'; +import React from 'react'; + +export interface ApiResponse { + raw: void | Response; + value(): Promise; +} + +type ResultsCanBe = + | Array + | NileApiResults + | (() => NileApiResults) + | (() => Array); +/** + * The primary hook to use when wanting stateful nile requests + * @param fn - the fetch function(s) to use + * @returns a tuple of loading state and the potentially fetched value(s) + */ +export function useNileFetch(args: ResultsCanBe): [boolean, T] { + const [isLoading, setIsLoading] = React.useState(false); + const [fetched, setFetched] = React.useState([]); + + const _fn = React.useMemo(() => { + return args; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const fnsToCall = + React.useMemo((): null | ResultsCanBe => { + if (typeof _fn === 'function') { + return _fn; + } else if (!Array.isArray(_fn) && typeof _fn === 'object' && _fn) { + if (_fn instanceof Promise || 'request' in _fn) { + return [_fn]; + } + } else if (Array.isArray(_fn)) { + return _fn.filter( + (f: NileApiResults) => f instanceof Promise || 'request' in f + ); + } + return null; + }, [_fn]); + + React.useEffect(() => { + async function doFetch() { + if (fnsToCall) { + setIsLoading(true); + const promiseHandler = async (f: ResultsCanBe) => { + if ('request' in f) { + return f.request(); + } else if (typeof f === 'function') { + const fns = f(); + if (Array.isArray(fns)) { + return Promise.all(fns); + } + return fns; + } + return f; + }; + const callers = Array.isArray(fnsToCall) + ? fnsToCall.map(promiseHandler) + : [promiseHandler(fnsToCall)]; + const rawCalls = await Promise.all(callers).catch(() => + setIsLoading(false) + ); + if (!rawCalls || rawCalls.length === 0) { + return; + } + + const vals = await Promise.all( + rawCalls.map(async (c: unknown) => { + if (typeof c === 'object' && c != null && 'value' in c) { + // @ts-expect-error - its def checked + const val = await c.value(); + return val; + } + + return c; + }) + ).catch(() => setIsLoading(false)); + + if (!vals) { + return; + } + + const items = vals.filter(Boolean); + if (items.length) { + if ( + (Array.isArray(fnsToCall) && fnsToCall.length === 1) || + typeof fnsToCall === 'function' + ) { + const [item] = items; + setFetched(item); + } else if (Array.isArray(fnsToCall)) { + setFetched(items); + } + } + setIsLoading(false); + } + } + doFetch(); + return () => { + fnsToCall && + Array.isArray(fnsToCall) && + fnsToCall.forEach((f) => { + if ('controller' in f) { + f.controller.abort(); + } + }); + }; + }, [fnsToCall, _fn]); + + return [isLoading, fetched as T]; +} diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 4044fae0..440b77e4 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -1,5 +1,6 @@ export { default as LoginForm } from './components/LoginForm'; export { default as SignUpForm } from './components/SignUpForm'; +export { useNileFetch } from './hooks/useNileFetch'; export { NileProvider, useNile } from './context'; export { LabelOverride, InputOverride } from './theme'; diff --git a/yarn.lock b/yarn.lock index 8498147d..667ec972 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4196,6 +4196,14 @@ lodash "^4.17.15" redent "^3.0.0" +"@testing-library/react-hooks@^8.0.0": + version "8.0.0" + resolved "https://registry.yarnpkg.com/@testing-library/react-hooks/-/react-hooks-8.0.0.tgz#7d0164bffce4647f506039de0a97f6fcbd20f4bf" + integrity sha512-uZqcgtcUUtw7Z9N32W13qQhVAD+Xki2hxbTR461MKax8T6Jr8nsUvZB+vcBTkzY2nFvsUet434CsgF0ncW2yFw== + dependencies: + "@babel/runtime" "^7.12.5" + react-error-boundary "^3.1.0" + "@testing-library/react@^12.0.0": version "12.1.5" resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-12.1.5.tgz#bb248f72f02a5ac9d949dea07279095fa577963b" @@ -14135,6 +14143,13 @@ react-element-to-jsx-string@^14.0.2, react-element-to-jsx-string@^14.3.4: is-plain-object "5.0.0" react-is "17.0.2" +react-error-boundary@^3.1.0: + version "3.1.4" + resolved "https://registry.yarnpkg.com/react-error-boundary/-/react-error-boundary-3.1.4.tgz#255db92b23197108757a888b01e5b729919abde0" + integrity sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA== + dependencies: + "@babel/runtime" "^7.12.5" + react-fast-compare@^3.0.1, react-fast-compare@^3.2.0: version "3.2.0" resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-3.2.0.tgz#641a9da81b6a6320f270e89724fb45a0b39e43bb" @@ -17209,7 +17224,7 @@ whatwg-encoding@^1.0.1, whatwg-encoding@^1.0.5: dependencies: iconv-lite "0.4.24" -whatwg-fetch@>=0.10.0, whatwg-fetch@^3.6.2: +whatwg-fetch@>=0.10.0: version "3.6.2" resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-3.6.2.tgz#dced24f37f2624ed0281725d51d0e2e3fe677f8c" integrity sha512-bJlen0FcuU/0EMLrdbJ7zOnW6ITZLrZMIarMUVmdKtsGvZna8vxKYaexICWPfZ8qwf9fzNq+UEIZrnSaApt6RA==