├── .gitkeep ├── CONTRIBUTING.md ├── .yarnrc.yml ├── assets ├── idp.gif ├── labs.gif └── mutate.png ├── .prettierrc ├── docs ├── long-poll-flow.png └── contiamo-long-poll.md ├── .github ├── PULL_REQUEST_TEMPLATE.md ├── workflows │ ├── tests.yaml │ └── npm-publish.yaml └── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── src ├── bin │ ├── config.ts │ ├── restful-react.ts │ └── restful-react-import.ts ├── scripts │ ├── swagger2openapi.d.ts │ ├── ibm-openapi-validator.d.ts │ ├── tests │ │ ├── petstore-expanded.yaml │ │ └── __snapshots__ │ │ │ └── import-open-api.test.ts.snap │ └── import-open-api.ts ├── types.ts ├── index.tsx ├── useAbort.ts ├── util │ ├── resolveData.ts │ ├── constructUrl.tsx │ ├── processResponse.ts │ ├── composeUrl.ts │ ├── useDeepCompareEffect.ts │ ├── composeUrl.test.ts │ └── constructUrl.test.ts ├── Context.tsx ├── useMutate.tsx ├── useGet.tsx ├── Mutate.tsx ├── Poll.tsx ├── Get.tsx └── Poll.test.tsx ├── .eslintrc.js ├── rollup.config.js ├── github-release.hbs ├── tsconfig.json ├── LICENSE ├── publish-without-cli.js ├── examples ├── fetchers.ts ├── restful-react.config.js └── petstore.yaml ├── .gitignore ├── CODE_OF_CONDUCT.md └── package.json /.gitkeep: -------------------------------------------------------------------------------- 1 | tsconfig.json 2 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Open an issue or submit a PR. :D 2 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | yarnPath: .yarn/releases/yarn-1.21.1.js 2 | -------------------------------------------------------------------------------- /assets/idp.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/contiamo/restful-react/HEAD/assets/idp.gif -------------------------------------------------------------------------------- /assets/labs.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/contiamo/restful-react/HEAD/assets/labs.gif -------------------------------------------------------------------------------- /assets/mutate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/contiamo/restful-react/HEAD/assets/mutate.png -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "printWidth": 120, 4 | "trailingComma": "all" 5 | } 6 | -------------------------------------------------------------------------------- /docs/long-poll-flow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/contiamo/restful-react/HEAD/docs/long-poll-flow.png -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | # Why 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/bin/config.ts: -------------------------------------------------------------------------------- 1 | import { AdvancedOptions } from "./restful-react-import"; 2 | 3 | export interface RestfulReactAdvancedConfiguration { 4 | [backend: string]: AdvancedOptions; 5 | } 6 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "extends": [ 3 | "react-app", 4 | "prettier/@typescript-eslint", 5 | "plugin:prettier/recommended" 6 | ], 7 | "settings": { 8 | "react": { 9 | "version": "detect" 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/scripts/swagger2openapi.d.ts: -------------------------------------------------------------------------------- 1 | declare module "swagger2openapi" { 2 | import { OpenAPIObject } from "openapi3-ts"; 3 | interface ConverObjCallbackData { 4 | openapi: OpenAPIObject; 5 | } 6 | function convertObj(schema: unknown, options: {}, callback: (err: Error, data: ConverObjCallbackData) => void): void; 7 | } 8 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | // Shared types across exported components and utils 2 | // 3 | /** 4 | * A function that resolves returned data from 5 | * a fetch call. 6 | */ 7 | export type ResolveFunction = ((data: any) => T) | ((data: any) => Promise); 8 | 9 | export interface GetDataError { 10 | message: string; 11 | data: TError | string; 12 | } 13 | -------------------------------------------------------------------------------- /.github/workflows/tests.yaml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: [push] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v1 10 | - uses: actions/setup-node@v1 11 | with: 12 | node-version: 12.16.1 # workaround https://github.com/babel/babel/issues/11216 13 | - run: yarn 14 | - run: yarn test 15 | - run: yarn build 16 | -------------------------------------------------------------------------------- /src/bin/restful-react.ts: -------------------------------------------------------------------------------- 1 | import program from "commander"; 2 | import { readFileSync } from "fs"; 3 | import { join } from "path"; 4 | 5 | const { version } = JSON.parse(readFileSync(join(__dirname, "../../package.json"), "utf-8")); 6 | 7 | program 8 | .version(version) 9 | .command("import [open-api-file]", "generate restful-react type-safe components from OpenAPI specs") 10 | .parse(process.argv); 11 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | const { readdirSync } = require("fs"); 2 | 3 | /** 4 | * Rollup configuration to build correctly our scripts (nodejs scripts need to be cjs) 5 | */ 6 | module.exports = readdirSync("lib/bin") 7 | .filter(file => file.endsWith(".js")) 8 | .map(file => ({ 9 | input: `lib/bin/${file}`, 10 | output: { 11 | file: `dist/bin/${file}`, 12 | format: "cjs", 13 | banner: "#!/usr/bin/env node", 14 | }, 15 | })); 16 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import Get, { GetDataError, GetMethod, GetProps } from "./Get"; 2 | 3 | export { default as RestfulProvider, RestfulReactProviderProps } from "./Context"; 4 | export { default as Poll, PollProps } from "./Poll"; 5 | export { default as Mutate, MutateProps, MutateMethod } from "./Mutate"; 6 | 7 | export { useGet, UseGetProps, UseGetReturn } from "./useGet"; 8 | export { useMutate, UseMutateProps, UseMutateReturn } from "./useMutate"; 9 | 10 | export { Get, GetDataError, GetProps, GetMethod }; 11 | 12 | export default Get; 13 | -------------------------------------------------------------------------------- /github-release.hbs: -------------------------------------------------------------------------------- 1 | {{#each releases}} 2 | {{title}} 3 | 4 | {{#if tag}} 5 | > {{niceDate}} 6 | {{/if}} 7 | 8 | {{#each merges}} 9 | - {{#if commit.breaking}}**Breaking change:** {{/if}}{{message}}{{#if href}} [`#{{id}}`]({{href}}){{/if}} 10 | {{/each}} 11 | {{#each fixes}} 12 | - {{#if commit.breaking}}**Breaking change:** {{/if}}{{commit.subject}}{{#each fixes}}{{#if href}} [`#{{id}}`]({{href}}){{/if}}{{/each}} 13 | {{/each}} 14 | {{#each commits}} 15 | - {{#if breaking}}**Breaking change:** {{/if}}{{subject}}{{#if href}} [`{{shorthash}}`]({{href}}){{/if}} 16 | {{/each}} 17 | 18 | {{/each}} -------------------------------------------------------------------------------- /src/useAbort.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useRef } from "react"; 2 | 3 | function createAbortController() { 4 | try { 5 | return new AbortController(); 6 | } catch { 7 | return undefined; 8 | } 9 | } 10 | 11 | export function useAbort() { 12 | const instance = useRef(createAbortController()); 13 | 14 | const abort = useCallback(() => { 15 | if (instance && instance.current) { 16 | instance.current.abort(); 17 | instance.current = createAbortController(); 18 | } 19 | }, [instance]); 20 | 21 | return { 22 | abort, 23 | getAbortSignal() { 24 | return instance?.current?.signal; 25 | }, 26 | }; 27 | } 28 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | 5 | --- 6 | 7 | **Is your feature request related to a problem? Please describe.** 8 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 9 | 10 | **Describe the solution you'd like** 11 | A clear and concise description of what you want to happen. 12 | 13 | **Describe alternatives you've considered** 14 | A clear and concise description of any alternative solutions or features you've considered. 15 | 16 | **Additional context** 17 | Add any other context or screenshots about the feature request here. 18 | -------------------------------------------------------------------------------- /src/scripts/ibm-openapi-validator.d.ts: -------------------------------------------------------------------------------- 1 | declare module "ibm-openapi-validator" { 2 | interface OpenAPIError { 3 | path: string; 4 | message: string; 5 | } 6 | 7 | interface ValidatorResults { 8 | errors: OpenAPIError[]; 9 | warnings: OpenAPIError[]; 10 | } 11 | 12 | /** 13 | * Returns a Promise with the validation results. 14 | * 15 | * @param openApiDoc An object that represents an OpenAPI document. 16 | * @param defaultMode If set to true, the validator will ignore the .validaterc file and will use the configuration defaults. 17 | */ 18 | function validator(openApiDoc: any, defaultMode = false): Promise; 19 | 20 | export default validator; 21 | } 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | 5 | --- 6 | 7 | **Describe the bug** 8 | A clear and concise description of what the bug is. 9 | 10 | **To Reproduce** 11 | Steps to reproduce the behavior: 12 | 1. Go to '...' 13 | 2. Click on '....' 14 | 3. Scroll down to '....' 15 | 4. See error 16 | 17 | **Expected behavior** 18 | A clear and concise description of what you expected to happen. 19 | 20 | **Screenshots** 21 | If applicable, add screenshots to help explain your problem. 22 | 23 | **Desktop (please complete the following information):** 24 | - OS: [e.g. iOS] 25 | - Browser [e.g. chrome, safari] 26 | - Version [e.g. 22] 27 | 28 | **Smartphone (please complete the following information):** 29 | - Device: [e.g. iPhone6] 30 | - OS: [e.g. iOS8.1] 31 | - Browser [e.g. stock browser, safari] 32 | - Version [e.g. 22] 33 | 34 | **Additional context** 35 | Add any other context about the problem here. 36 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src"], 3 | "compilerOptions": { 4 | "target": "es5", 5 | "module": "esnext", 6 | "lib": ["es2015", "dom", "es2016.array.include", "es2017.object"], 7 | "importHelpers": true, 8 | "declaration": true, 9 | "inlineSourceMap": true, 10 | "rootDir": "./src", 11 | "strict": true, 12 | "noImplicitAny": true, 13 | "strictNullChecks": true, 14 | "strictFunctionTypes": true, 15 | "strictPropertyInitialization": true, 16 | "noImplicitThis": true, 17 | "alwaysStrict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "noImplicitReturns": true, 21 | "noFallthroughCasesInSwitch": true, 22 | "moduleResolution": "node", 23 | "jsx": "react", 24 | "esModuleInterop": true, 25 | "outDir": "./lib", 26 | "skipLibCheck": true, 27 | "declarationMap": true, 28 | "downlevelIteration": true, 29 | "allowSyntheticDefaultImports": true 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/util/resolveData.ts: -------------------------------------------------------------------------------- 1 | import { GetDataError, ResolveFunction } from "../types"; 2 | 3 | export const resolveData = async ({ 4 | data, 5 | resolve, 6 | }: { 7 | data: any; 8 | resolve?: ResolveFunction; 9 | }): Promise<{ data: TData | null; error: GetDataError | null }> => { 10 | let resolvedData: TData | null = null; 11 | let resolveError: GetDataError | null = null; 12 | try { 13 | if (resolve) { 14 | const resolvedDataOrPromise: TData | Promise = resolve(data); 15 | resolvedData = (resolvedDataOrPromise as { then?: any }).then 16 | ? ((await resolvedDataOrPromise) as TData) 17 | : (resolvedDataOrPromise as TData); 18 | } else { 19 | resolvedData = data; 20 | } 21 | } catch (err) { 22 | resolvedData = null; 23 | resolveError = { 24 | message: "RESOLVE_ERROR", 25 | data: JSON.stringify(err), 26 | }; 27 | } 28 | return { 29 | data: resolvedData, 30 | error: resolveError, 31 | }; 32 | }; 33 | -------------------------------------------------------------------------------- /src/util/constructUrl.tsx: -------------------------------------------------------------------------------- 1 | import qs, { IStringifyOptions } from "qs"; 2 | import url from "url"; 3 | 4 | type ResolvePathOptions = { 5 | queryParamOptions?: IStringifyOptions; 6 | stripTrailingSlash?: boolean; 7 | }; 8 | 9 | export function constructUrl( 10 | base: string, 11 | path: string, 12 | queryParams?: TQueryParams, 13 | resolvePathOptions: ResolvePathOptions = {}, 14 | ) { 15 | const { queryParamOptions, stripTrailingSlash } = resolvePathOptions; 16 | 17 | const normalizedBase = base.endsWith("/") ? base : `${base}/`; 18 | const trimmedPath = path.startsWith("/") ? path.slice(1) : path; 19 | 20 | const encodedPathWithParams = Object.keys(queryParams || {}).length 21 | ? `${trimmedPath}?${qs.stringify(queryParams, queryParamOptions)}` 22 | : trimmedPath; 23 | 24 | const composed = Boolean(encodedPathWithParams) ? url.resolve(normalizedBase, encodedPathWithParams) : normalizedBase; 25 | 26 | return stripTrailingSlash && composed.endsWith("/") ? composed.slice(0, -1) : composed; 27 | } 28 | -------------------------------------------------------------------------------- /src/util/processResponse.ts: -------------------------------------------------------------------------------- 1 | export const processResponse = async (response: Response) => { 2 | if (response.status === 204) { 3 | return { data: undefined, responseError: false }; 4 | } 5 | if ((response.headers.get("content-type") || "").includes("application/json")) { 6 | try { 7 | return { 8 | data: await response.json(), 9 | responseError: false, 10 | }; 11 | } catch (e) { 12 | return { 13 | data: e.message, 14 | responseError: true, 15 | }; 16 | } 17 | } else if ( 18 | (response.headers.get("content-type") || "").includes("text/plain") || 19 | (response.headers.get("content-type") || "").includes("text/html") 20 | ) { 21 | try { 22 | return { 23 | data: await response.text(), 24 | responseError: false, 25 | }; 26 | } catch (e) { 27 | return { 28 | data: e.message, 29 | responseError: true, 30 | }; 31 | } 32 | } else { 33 | return { 34 | data: response, 35 | responseError: false, 36 | }; 37 | } 38 | }; 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Contiamo GmbH 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/util/composeUrl.ts: -------------------------------------------------------------------------------- 1 | import url from "url"; 2 | 3 | export const composeUrl = (base: string = "", parentPath: string = "", path: string = ""): string => { 4 | const composedPath = composePath(parentPath, path); 5 | /* If the base is empty, preceding slash will be trimmed during composition */ 6 | if (base === "" && composedPath.startsWith("/")) { 7 | return composedPath; 8 | } 9 | 10 | /* If the base contains a trailing slash, it will be trimmed during composition */ 11 | return base!.endsWith("/") ? `${base!.slice(0, -1)}${composedPath}` : `${base}${composedPath}`; 12 | }; 13 | 14 | /** 15 | * If the path starts with slash, it is considered as absolute url. 16 | * If not, it is considered as relative url. 17 | * For example, 18 | * parentPath = "/someBasePath" and path = "/absolute" resolves to "/absolute" 19 | * whereas, 20 | * parentPath = "/someBasePath" and path = "relative" resolves to "/someBasePath/relative" 21 | */ 22 | export const composePath = (parentPath: string = "", path: string = ""): string => { 23 | if (path.startsWith("/") && path.length > 1) { 24 | return url.resolve(parentPath, path); 25 | } else if (path !== "" && path !== "/") { 26 | return `${parentPath}/${path}`; 27 | } else { 28 | return parentPath; 29 | } 30 | }; 31 | -------------------------------------------------------------------------------- /src/util/useDeepCompareEffect.ts: -------------------------------------------------------------------------------- 1 | import isEqualWith from "lodash/isEqualWith"; 2 | import React, { useCallback, useEffect, useRef } from "react"; 3 | 4 | /** 5 | * Custom version of isEqual to handle function comparison 6 | */ 7 | const isEqual = (x: any, y: any) => 8 | isEqualWith(x, y, (a, b) => { 9 | // Deal with the function comparison case 10 | if (typeof a === "function" && typeof b === "function") { 11 | return a.toString() === b.toString(); 12 | } 13 | // Fallback on the method 14 | return undefined; 15 | }); 16 | 17 | function useDeepCompareMemoize(value: Readonly) { 18 | const ref = useRef(); 19 | 20 | if (!isEqual(value, ref.current)) { 21 | ref.current = value; 22 | } 23 | 24 | return ref.current; 25 | } 26 | 27 | /** 28 | * Accepts a function that contains imperative, possibly effectful code. 29 | * 30 | * This is the deepCompare version of the `React.useEffect` hooks (that is shallowed compare) 31 | * 32 | * @param effect Imperative function that can return a cleanup function 33 | * @param deps If present, effect will only activate if the values in the list change. 34 | * 35 | * @see https://gist.github.com/kentcdodds/fb8540a05c43faf636dd68647747b074#gistcomment-2830503 36 | */ 37 | export function useDeepCompareEffect(effect: React.EffectCallback, deps: T) { 38 | useEffect(effect, useDeepCompareMemoize(deps)); 39 | } 40 | 41 | export function useDeepCompareCallback any>(callback: T, deps: readonly any[]) { 42 | return useCallback(callback, useDeepCompareMemoize(deps)); 43 | } 44 | -------------------------------------------------------------------------------- /publish-without-cli.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Script to produce a `-without-cli` restful-react version. 3 | * 4 | * This produce a very lightweight build, without the cli script, 5 | * this is for easier security auditing and projects that don't use 6 | * our amazing open-api generator. 7 | * 8 | * This is executed just after `yarn build`, so the `/dist` is present. 9 | */ 10 | const { readFileSync, writeFileSync } = require("fs"); 11 | const util = require("util"); 12 | const pick = require("lodash/pick"); 13 | const omit = require("lodash/omit"); 14 | const rimraf = util.promisify(require("rimraf")); 15 | 16 | const restfulReactDeps = ["lodash", "lodash-es", "qs", "react-fast-compare", "url"]; 17 | const packageJSON = JSON.parse(readFileSync("package.json", "utf-8")); 18 | 19 | // Dummy check to be sure we don't forgot a new package in the `-without-cli` version. 20 | if (Object.keys(packageJSON.dependencies).length !== 16) { 21 | throw new Error("The number of dependencies has changed! Please update `publish-without-cli`"); 22 | } 23 | 24 | // Create a package.json without the cli dependencies 25 | const lightPackageJSON = omit(packageJSON, "bin", "scripts", "husky", "devDependencies"); 26 | lightPackageJSON.dependencies = pick(packageJSON.dependencies, ...restfulReactDeps); 27 | lightPackageJSON.version = packageJSON.version + "-without-cli"; 28 | 29 | // Delete cli folders 30 | Promise.all([rimraf("dist/bin"), rimraf("dist/scripts")]).then(() => { 31 | // Replace the package.json 32 | writeFileSync("package.json", JSON.stringify(lightPackageJSON, null, 2)); 33 | 34 | // npm publish --tag without-cli 35 | }); 36 | -------------------------------------------------------------------------------- /examples/fetchers.ts: -------------------------------------------------------------------------------- 1 | import qs from "qs"; 2 | 3 | export interface CustomGetProps< 4 | _TData = any, 5 | _TError = any, 6 | TQueryParams = { 7 | [key: string]: any; 8 | } 9 | > { 10 | queryParams?: TQueryParams; 11 | } 12 | 13 | export const customGet = < 14 | TData = any, 15 | TError = any, 16 | TQueryParams = { 17 | [key: string]: any; 18 | } 19 | >( 20 | path: string, 21 | props: { queryParams?: TQueryParams }, 22 | signal?: RequestInit["signal"], 23 | ): Promise => { 24 | let url = path; 25 | if (props.queryParams && Object.keys(props.queryParams).length) { 26 | url += `?${qs.stringify(props.queryParams)}`; 27 | } 28 | return fetch(url, { 29 | headers: { 30 | "content-type": "application/json", 31 | }, 32 | signal, 33 | }).then(res => res.json()); 34 | }; 35 | 36 | export interface CustomMutateProps< 37 | _TData = any, 38 | _TError = any, 39 | TQueryParams = { 40 | [key: string]: any; 41 | }, 42 | TRequestBody = any 43 | > { 44 | body: TRequestBody; 45 | queryParams?: TQueryParams; 46 | } 47 | 48 | export const customMutate = < 49 | TData = any, 50 | TError = any, 51 | TQueryParams = { 52 | [key: string]: any; 53 | }, 54 | TRequestBody = any 55 | >( 56 | method: string, 57 | path: string, 58 | props: { body: TRequestBody; queryParams?: TQueryParams }, 59 | signal?: RequestInit["signal"], 60 | ): Promise => { 61 | let url = path; 62 | if (method === "DELETE" && typeof props.body === "string") { 63 | url += `/${props.body}`; 64 | } 65 | if (props.queryParams && Object.keys(props.queryParams).length) { 66 | url += `?${qs.stringify(props.queryParams)}`; 67 | } 68 | return fetch(url, { 69 | method, 70 | body: JSON.stringify(props.body), 71 | headers: { 72 | "content-type": "application/json", 73 | }, 74 | signal, 75 | }).then(res => res.json()); 76 | }; 77 | -------------------------------------------------------------------------------- /examples/restful-react.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Example config for `yarn example:advanced` 3 | */ 4 | 5 | const { camel } = require("case"); 6 | 7 | /** 8 | * @type {import("../src/bin/config").RestfulReactAdvancedConfiguration} 9 | */ 10 | module.exports = { 11 | "petstore-file": { 12 | file: "examples/petstore.yaml", 13 | output: "examples/petstoreFromFileSpecWithConfig.tsx", 14 | }, 15 | "petstore-github": { 16 | github: "OAI:OpenAPI-Specification:master:examples/v3.0/petstore.yaml", 17 | output: "examples/petstoreFromGithubSpecWithConfig.tsx", 18 | customImport: "/* a custom import */", 19 | customProps: { 20 | base: `"http://my-pet-store.com"`, 21 | }, 22 | }, 23 | "petstore-custom-fetch": { 24 | file: "examples/petstore.yaml", 25 | output: "examples/petstoreFromFileSpecWithCustomFetch.tsx", 26 | customImport: `import { customGet, customMutate, CustomGetProps, CustomMutateProps } from "./fetchers"`, 27 | customGenerator: ({ componentName, verb, route, description, genericsTypes, paramsInPath, paramsTypes }) => { 28 | const propsType = type => 29 | `Custom${type}Props<${genericsTypes}>${paramsInPath.length ? ` & {${paramsTypes}}` : ""}`; 30 | 31 | return verb === "get" 32 | ? `${description}export const ${camel(componentName)} = (${ 33 | paramsInPath.length ? `{${paramsInPath.join(", ")}, ...props}` : "props" 34 | }: ${propsType( 35 | "Get", 36 | )}, signal?: RequestInit["signal"]) => customGet<${genericsTypes}>(\`http://petstore.swagger.io/v1${route}\`, props, signal);\n\n` 37 | : `${description}export const ${camel(componentName)} = (${ 38 | paramsInPath.length ? `{${paramsInPath.join(", ")}, ...props}` : "props" 39 | }: ${propsType( 40 | "Mutate", 41 | )}, signal?: RequestInit["signal"]) => customMutate<${genericsTypes}>("${verb.toUpperCase()}", \`http://petstore.swagger.io/v1${route}\`, props, signal);\n\n`; 42 | }, 43 | }, 44 | }; 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | mock-backend 2 | dist 3 | .rpt2_cache 4 | 5 | ### macOS ### 6 | *.DS_Store 7 | .AppleDouble 8 | .LSOverride 9 | 10 | # Icon must end with two \r 11 | Icon 12 | 13 | # Thumbnails 14 | ._* 15 | 16 | # Files that might appear in the root of a volume 17 | .DocumentRevisions-V100 18 | .fseventsd 19 | .Spotlight-V100 20 | .TemporaryItems 21 | .Trashes 22 | .VolumeIcon.icns 23 | .com.apple.timemachine.donotpresent 24 | 25 | # Directories potentially created on remote AFP share 26 | .AppleDB 27 | .AppleDesktop 28 | Network Trash Folder 29 | Temporary Items 30 | .apdisk 31 | 32 | ### Node ### 33 | # Logs 34 | logs 35 | *.log 36 | npm-debug.log* 37 | yarn-debug.log* 38 | yarn-error.log* 39 | 40 | # Runtime data 41 | pids 42 | *.pid 43 | *.seed 44 | *.pid.lock 45 | 46 | # Directory for instrumented libs generated by jscoverage/JSCover 47 | lib-cov 48 | 49 | # Coverage directory used by tools like istanbul 50 | coverage 51 | 52 | # nyc test coverage 53 | .nyc_output 54 | 55 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 56 | .grunt 57 | 58 | # Bower dependency directory (https://bower.io/) 59 | bower_components 60 | 61 | # node-waf configuration 62 | .lock-wscript 63 | 64 | # Compiled binary addons (http://nodejs.org/api/addons.html) 65 | build/Release 66 | 67 | # Dependency directories 68 | node_modules/ 69 | jspm_packages/ 70 | 71 | # Typescript v1 declaration files 72 | typings/ 73 | 74 | # Optional npm cache directory 75 | .npm 76 | 77 | # Optional eslint cache 78 | .eslintcache 79 | 80 | # Optional REPL history 81 | .node_repl_history 82 | 83 | # Output of 'npm pack' 84 | *.tgz 85 | 86 | # Yarn Integrity file 87 | .yarn-integrity 88 | 89 | # dotenv environment variables file 90 | .env 91 | 92 | public 93 | lib 94 | .cache 95 | 96 | # End of https://www.gitignore.io/api/node,macos,vscode 97 | 98 | # Cache for parcel serverl in example 99 | .cache 100 | 101 | package-lock.json 102 | 103 | # examples artefacts 104 | examples/*.tsx 105 | 106 | # Repros 107 | repros/* -------------------------------------------------------------------------------- /.github/workflows/npm-publish.yaml: -------------------------------------------------------------------------------- 1 | name: Npm publish 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | paths: 8 | - package.json 9 | 10 | jobs: 11 | npm-publish: 12 | name: Publish on npm 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout code 16 | uses: actions/checkout@v2 17 | - name: Build & publish to npm 18 | uses: actions/setup-node@v1 19 | with: 20 | node-version: 12.16.1 # workaround https://github.com/babel/babel/issues/11216 21 | registry-url: https://registry.npmjs.org/ 22 | - run: yarn 23 | - run: yarn build 24 | - run: npm publish 25 | env: 26 | NODE_AUTH_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }} 27 | npm-publish-without-cli: 28 | name: Publish on npm (without-cli version) 29 | runs-on: ubuntu-latest 30 | steps: 31 | - name: Checkout code 32 | uses: actions/checkout@v2 33 | - name: Build & publish a without-cli version to npm 34 | uses: actions/setup-node@v1 35 | with: 36 | node-version: 12.16.1 # workaround https://github.com/babel/babel/issues/11216 37 | registry-url: https://registry.npmjs.org/ 38 | - run: yarn 39 | - run: yarn build 40 | - run: node publish-without-cli.js 41 | - run: npm publish --tag without-cli 42 | env: 43 | NODE_AUTH_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }} 44 | github-release: 45 | name: Create a github release 46 | runs-on: ubuntu-latest 47 | steps: 48 | - name: Checkout code 49 | uses: actions/checkout@v2 50 | with: 51 | fetch-depth: 0 # Fetch all history for all tags and branches 52 | - name: Retrieve current version 53 | id: package-version 54 | uses: martinbeentjes/npm-get-version-action@master 55 | - name: Generate release changelog 56 | uses: actions/setup-node@v1 57 | with: 58 | node-version: 12.16.1 # workaround https://github.com/babel/babel/issues/11216 59 | registry-url: https://registry.npmjs.org/ 60 | - run: yarn 61 | - run: yarn auto-changelog --starting-version v${{ steps.package-version.outputs.current-version}} --template github-release.hbs -o releasenotes.md 62 | - name: Create Release 63 | uses: actions/create-release@v1 64 | env: 65 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 66 | with: 67 | tag_name: v${{ steps.package-version.outputs.current-version}} 68 | release_name: v${{ steps.package-version.outputs.current-version}} 69 | body_path: releasenotes.md 70 | draft: false 71 | prerelease: false 72 | -------------------------------------------------------------------------------- /examples/petstore.yaml: -------------------------------------------------------------------------------- 1 | openapi: "3.0.0" 2 | info: 3 | version: 1.0.0 4 | title: Swagger Petstore 5 | license: 6 | name: MIT 7 | servers: 8 | - url: http://petstore.swagger.io/v1 9 | paths: 10 | /pets: 11 | get: 12 | summary: List all pets 13 | operationId: listPets 14 | tags: 15 | - pets 16 | parameters: 17 | - name: limit 18 | in: query 19 | description: How many items to return at one time (max 100) 20 | required: false 21 | schema: 22 | type: integer 23 | format: int32 24 | responses: 25 | '200': 26 | description: A paged array of pets 27 | headers: 28 | x-next: 29 | description: A link to the next page of responses 30 | schema: 31 | type: string 32 | content: 33 | application/json: 34 | schema: 35 | $ref: "#/components/schemas/Pets" 36 | default: 37 | description: unexpected error 38 | content: 39 | application/json: 40 | schema: 41 | $ref: "#/components/schemas/Error" 42 | post: 43 | summary: Create a pet 44 | operationId: createPets 45 | tags: 46 | - pets 47 | responses: 48 | '201': 49 | description: Null response 50 | default: 51 | description: unexpected error 52 | content: 53 | application/json: 54 | schema: 55 | $ref: "#/components/schemas/Error" 56 | /pets/{petId}: 57 | get: 58 | summary: Info for a specific pet 59 | operationId: showPetById 60 | tags: 61 | - pets 62 | parameters: 63 | - name: petId 64 | in: path 65 | required: true 66 | description: The id of the pet to retrieve 67 | schema: 68 | type: string 69 | responses: 70 | '200': 71 | description: Expected response to a valid request 72 | content: 73 | application/json: 74 | schema: 75 | $ref: "#/components/schemas/Pet" 76 | default: 77 | description: unexpected error 78 | content: 79 | application/json: 80 | schema: 81 | $ref: "#/components/schemas/Error" 82 | components: 83 | schemas: 84 | Pet: 85 | type: object 86 | required: 87 | - id 88 | - name 89 | properties: 90 | id: 91 | type: integer 92 | format: int64 93 | name: 94 | type: string 95 | tag: 96 | type: string 97 | Pets: 98 | type: array 99 | items: 100 | $ref: "#/components/schemas/Pet" 101 | Error: 102 | type: object 103 | required: 104 | - code 105 | - message 106 | properties: 107 | code: 108 | type: integer 109 | format: int32 110 | message: 111 | type: string -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at tejas@contiamo.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /src/util/composeUrl.test.ts: -------------------------------------------------------------------------------- 1 | import { composePath, composeUrl } from "./composeUrl"; 2 | 3 | describe("compose paths and urls", () => { 4 | it("should handle empty parentPath with absolute path", () => { 5 | const parentPath = ""; 6 | const path = "/absolute"; 7 | expect(composePath(parentPath, path)).toBe("/absolute"); 8 | 9 | const base = "https://my-awesome-api.fake"; 10 | expect(composeUrl(base, parentPath, path)).toBe("https://my-awesome-api.fake/absolute"); 11 | 12 | const baseWithSubpath = "https://my-awesome-api.fake/MY_SUBROUTE"; 13 | expect(composeUrl(baseWithSubpath, parentPath, path)).toBe("https://my-awesome-api.fake/MY_SUBROUTE/absolute"); 14 | }); 15 | 16 | it("should handle empty parentPath with relative path", () => { 17 | const parentPath = ""; 18 | const path = "relative"; 19 | expect(composePath(parentPath, path)).toBe("/relative"); 20 | 21 | const base = "https://my-awesome-api.fake"; 22 | expect(composeUrl(base, parentPath, path)).toBe("https://my-awesome-api.fake/relative"); 23 | 24 | const baseWithSubpath = "https://my-awesome-api.fake/MY_SUBROUTE"; 25 | expect(composeUrl(baseWithSubpath, parentPath, path)).toBe("https://my-awesome-api.fake/MY_SUBROUTE/relative"); 26 | }); 27 | 28 | it("should ignore empty string from path", () => { 29 | const parentPath = "/someBasePath"; 30 | const path = ""; 31 | expect(composePath(parentPath, path)).toBe("/someBasePath"); 32 | 33 | const base = "https://my-awesome-api.fake"; 34 | expect(composeUrl(base, parentPath, path)).toBe("https://my-awesome-api.fake/someBasePath"); 35 | 36 | const baseWithSubpath = "https://my-awesome-api.fake/MY_SUBROUTE"; 37 | expect(composeUrl(baseWithSubpath, parentPath, path)).toBe("https://my-awesome-api.fake/MY_SUBROUTE/someBasePath"); 38 | }); 39 | 40 | it("should ignore lone forward slash from path", () => { 41 | const parentPath = "/someBasePath"; 42 | const path = "/"; 43 | expect(composePath(parentPath, path)).toBe("/someBasePath"); 44 | 45 | const base = "https://my-awesome-api.fake"; 46 | expect(composeUrl(base, parentPath, path)).toBe("https://my-awesome-api.fake/someBasePath"); 47 | 48 | const baseWithSubpath = "https://my-awesome-api.fake/MY_SUBROUTE"; 49 | expect(composeUrl(baseWithSubpath, parentPath, path)).toBe("https://my-awesome-api.fake/MY_SUBROUTE/someBasePath"); 50 | }); 51 | 52 | it("should not include parentPath value when path is absolute", () => { 53 | const parentPath = "/someBasePath"; 54 | const path = "/absolute"; 55 | expect(composePath(parentPath, path)).toBe("/absolute"); 56 | 57 | const base = "https://my-awesome-api.fake"; 58 | expect(composeUrl(base, parentPath, path)).toBe("https://my-awesome-api.fake/absolute"); 59 | 60 | const baseWithSubpath = "https://my-awesome-api.fake/MY_SUBROUTE"; 61 | expect(composeUrl(baseWithSubpath, parentPath, path)).toBe("https://my-awesome-api.fake/MY_SUBROUTE/absolute"); 62 | }); 63 | 64 | it("should include parentPath value when path is relative", () => { 65 | const parentPath = "/someBasePath"; 66 | const path = "relative"; 67 | expect(composePath(parentPath, path)).toBe("/someBasePath/relative"); 68 | 69 | const base = "https://my-awesome-api.fake"; 70 | expect(composeUrl(base, parentPath, path)).toBe("https://my-awesome-api.fake/someBasePath/relative"); 71 | 72 | const baseWithSubpath = "https://my-awesome-api.fake/MY_SUBROUTE"; 73 | expect(composeUrl(baseWithSubpath, parentPath, path)).toBe( 74 | "https://my-awesome-api.fake/MY_SUBROUTE/someBasePath/relative", 75 | ); 76 | }); 77 | }); 78 | -------------------------------------------------------------------------------- /src/Context.tsx: -------------------------------------------------------------------------------- 1 | import noop from "lodash/noop"; 2 | import * as React from "react"; 3 | import { IStringifyOptions } from "qs"; 4 | import { ResolveFunction } from "./Get"; 5 | 6 | export interface RestfulReactProviderProps { 7 | /** The backend URL where the RESTful resources live. */ 8 | base: string; 9 | /** 10 | * The path that gets accumulated from each level of nesting 11 | * taking the absolute and relative nature of each path into consideration 12 | */ 13 | parentPath?: string; 14 | /** 15 | * A function to resolve data return from the backend, most typically 16 | * used when the backend response needs to be adapted in some way. 17 | */ 18 | resolve?: ResolveFunction; 19 | /** 20 | * Options passed to the fetch request. 21 | */ 22 | requestOptions?: 23 | | (( 24 | url: string, 25 | method: string, 26 | requestBody?: TRequestBody, 27 | ) => Partial | Promise>) 28 | | Partial; 29 | /** 30 | * Trigger on each error. 31 | * For `Get` and `Mutation` calls, you can also call `retry` to retry the exact same request. 32 | * Please note that it's quite hard to retrieve the response data after a retry mutation in this case. 33 | * Depending of your case, it can be easier to add a `localErrorOnly` on your `Mutate` component 34 | * to deal with your retry locally instead of in the provider scope. 35 | */ 36 | onError?: ( 37 | err: { 38 | message: string; 39 | data: TData | string; 40 | status?: number; 41 | }, 42 | retry: () => Promise, 43 | response?: Response, 44 | ) => void; 45 | /** 46 | * Trigger on each request 47 | */ 48 | onRequest?: (req: Request) => void; 49 | /** 50 | * Trigger on each response 51 | */ 52 | onResponse?: (res: Response) => void; 53 | /** 54 | * Any global level query params? 55 | * **Warning:** it's probably not a good idea to put API keys here. Consider headers instead. 56 | */ 57 | queryParams?: { [key: string]: any }; 58 | /** 59 | * Query parameter stringify options applied for each request. 60 | */ 61 | queryParamStringifyOptions?: IStringifyOptions; 62 | } 63 | 64 | export const Context = React.createContext>({ 65 | base: "", 66 | parentPath: "", 67 | resolve: (data: any) => data, 68 | requestOptions: {}, 69 | onError: noop, 70 | onRequest: noop, 71 | onResponse: noop, 72 | queryParams: {}, 73 | queryParamStringifyOptions: {}, 74 | }); 75 | 76 | export interface InjectedProps { 77 | onError: RestfulReactProviderProps["onError"]; 78 | onRequest: RestfulReactProviderProps["onRequest"]; 79 | onResponse: RestfulReactProviderProps["onResponse"]; 80 | } 81 | 82 | export default class RestfulReactProvider extends React.Component> { 83 | public static displayName = "RestfulProviderContext"; 84 | 85 | public render() { 86 | const { children, ...value } = this.props; 87 | return ( 88 | data, 94 | requestOptions: {}, 95 | parentPath: "", 96 | queryParams: {}, 97 | queryParamStringifyOptions: {}, 98 | ...value, 99 | }} 100 | > 101 | {children} 102 | 103 | ); 104 | } 105 | } 106 | 107 | export const RestfulReactConsumer = Context.Consumer; 108 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "restful-react", 3 | "version": "15.9.4", 4 | "description": "A consistent, declarative way of interacting with RESTful backends, featuring code-generation from Swagger and OpenAPI specs", 5 | "keywords": [ 6 | "rest", 7 | "restful", 8 | "react", 9 | "react-component", 10 | "fetch", 11 | "data fetching" 12 | ], 13 | "homepage": "https://github.com/contiamo/restful-react", 14 | "bugs": "https://github.com/contiamo/restful-react/issues", 15 | "license": "MIT", 16 | "contributors": [ 17 | { 18 | "name": "Tejas Kumar", 19 | "email": "tejas@tejas.qa", 20 | "url": "https://twitter.com/tejaskumar_" 21 | }, 22 | { 23 | "name": "Fabien Bernard", 24 | "email": "fabien@contiamo.com", 25 | "url": "https://fabien0102.com/en" 26 | } 27 | ], 28 | "files": [ 29 | "dist" 30 | ], 31 | "bin": { 32 | "restful-react": "dist/bin/restful-react.js" 33 | }, 34 | "main": "dist/index.js", 35 | "typings": "dist/index.d.ts", 36 | "module": "dist/restful-react.esm.js", 37 | "repository": { 38 | "type": "git", 39 | "url": "https://github.com/contiamo/restful-react" 40 | }, 41 | "scripts": { 42 | "start": "tsdx watch", 43 | "test": "tsdx test", 44 | "lint": "tsdx lint", 45 | "build": "run-p build:*", 46 | "build:project": "tsdx build", 47 | "build:bin": "tsc && rollup -c rollup.config.js", 48 | "postbuild": "rimraf dist/**/*.test.*", 49 | "version": "auto-changelog -p && git add CHANGELOG.md", 50 | "examples": "run-p example:*", 51 | "example:github": "node dist/bin/restful-react.js import --github OAI:OpenAPI-Specification:master:examples/v3.0/petstore.yaml --output examples/petstoreFromGithubSpec.tsx", 52 | "example:url": "node dist/bin/restful-react.js import --url https://petstore.swagger.io/v2/swagger.json --output examples/petstoreFromUrlSpec.tsx", 53 | "example:file": "node dist/bin/restful-react.js import --file examples/petstore.yaml --output examples/petstoreFromFileSpec.tsx", 54 | "example:advanced": "node dist/bin/restful-react.js import --config examples/restful-react.config.js" 55 | }, 56 | "dependencies": { 57 | "case": "^1.6.2", 58 | "chalk": "^3.0.0", 59 | "commander": "^4.1.0", 60 | "ibm-openapi-validator": "^0.46.4", 61 | "inquirer": "^7.0.3", 62 | "js-yaml": "^3.14.0", 63 | "lodash": "^4.17.15", 64 | "lodash-es": "^4.17.15", 65 | "openapi3-ts": "^1.3.0", 66 | "qs": "^6.9.1", 67 | "react-fast-compare": "^2.0.4", 68 | "request": "^2.88.0", 69 | "slash": "^3.0.0", 70 | "swagger2openapi": "^5.3.2", 71 | "tslib": "^2.1.0", 72 | "url": "^0.11.0" 73 | }, 74 | "devDependencies": { 75 | "@testing-library/jest-dom": "^4.2.4", 76 | "@testing-library/react": "^9.4.0", 77 | "@testing-library/react-hooks": "^3.2.1", 78 | "@types/chalk": "^2.2.0", 79 | "@types/commander": "^2.12.2", 80 | "@types/inquirer": "6.5.0", 81 | "@types/jest": "^24.0.25", 82 | "@types/js-yaml": "^3.12.5", 83 | "@types/lodash": "^4.14.149", 84 | "@types/nock": "^11.1.0", 85 | "@types/node": "^13.1.6", 86 | "@types/qs": "^6.9.0", 87 | "@types/react": "^16.8.8", 88 | "@types/react-dom": "^16.8.5", 89 | "@types/request": "^2.48.4", 90 | "@types/yamljs": "^0.2.30", 91 | "auto-changelog": "^2.2.1", 92 | "doctoc": "^1.4.0", 93 | "husky": "^4.0.7", 94 | "isomorphic-fetch": "^2.2.1", 95 | "nock": "^11.7.2", 96 | "npm-run-all": "^4.1.5", 97 | "prettier": "^1.19.1", 98 | "react": "^16.8.5", 99 | "react-dom": "^16.8.5", 100 | "react-test-renderer": "^16.8.5", 101 | "rimraf": "^3.0.2", 102 | "rollup": "^1.29.0", 103 | "rollup-plugin-typescript2": "^0.25.3", 104 | "tsdx": "^0.12.1", 105 | "tslint": "^5.20.1", 106 | "typescript": "^3.7.4" 107 | }, 108 | "peerDependencies": { 109 | "react": "^16.8.5 || ^17" 110 | }, 111 | "husky": { 112 | "hooks": { 113 | "pre-commit": "tsdx lint" 114 | } 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/util/constructUrl.test.ts: -------------------------------------------------------------------------------- 1 | import { composePath } from "./composeUrl"; 2 | import { constructUrl } from "./constructUrl"; 3 | 4 | describe("construct url utility", () => { 5 | describe("should be compatible with old resolveUrl behavior", () => { 6 | it("should handle empty parentPath with absolute path", () => { 7 | const parentPath = ""; 8 | const path = "/absolute"; 9 | expect(constructUrl(parentPath, path)).toBe("/absolute"); 10 | 11 | const base = "https://my-awesome-api.fake"; 12 | expect(constructUrl(base, composePath(parentPath, path))).toBe("https://my-awesome-api.fake/absolute"); 13 | 14 | const baseWithSubpath = "https://my-awesome-api.fake/MY_SUBROUTE"; 15 | expect(constructUrl(baseWithSubpath, composePath(parentPath, path))).toBe( 16 | "https://my-awesome-api.fake/MY_SUBROUTE/absolute", 17 | ); 18 | }); 19 | 20 | it("should handle empty parentPath with relative path", () => { 21 | const parentPath = ""; 22 | const path = "relative"; 23 | expect(composePath(parentPath, path)).toBe("/relative"); 24 | 25 | const base = "https://my-awesome-api.fake"; 26 | expect(constructUrl(base, composePath(parentPath, path))).toBe("https://my-awesome-api.fake/relative"); 27 | 28 | const baseWithSubpath = "https://my-awesome-api.fake/MY_SUBROUTE"; 29 | expect(constructUrl(baseWithSubpath, composePath(parentPath, path))).toBe( 30 | "https://my-awesome-api.fake/MY_SUBROUTE/relative", 31 | ); 32 | }); 33 | 34 | it("should ignore empty string from path", () => { 35 | const parentPath = "/someBasePath"; 36 | const path = ""; 37 | expect(composePath(parentPath, path)).toBe("/someBasePath"); 38 | 39 | const base = "https://my-awesome-api.fake"; 40 | expect(constructUrl(base, composePath(parentPath, path))).toBe("https://my-awesome-api.fake/someBasePath"); 41 | 42 | const baseWithSubpath = "https://my-awesome-api.fake/MY_SUBROUTE"; 43 | expect(constructUrl(baseWithSubpath, composePath(parentPath, path))).toBe( 44 | "https://my-awesome-api.fake/MY_SUBROUTE/someBasePath", 45 | ); 46 | }); 47 | 48 | it("should ignore lone forward slash from path", () => { 49 | const parentPath = "/someBasePath"; 50 | const path = "/"; 51 | expect(composePath(parentPath, path)).toBe("/someBasePath"); 52 | 53 | const base = "https://my-awesome-api.fake"; 54 | expect(constructUrl(base, composePath(parentPath, path))).toBe("https://my-awesome-api.fake/someBasePath"); 55 | 56 | const baseWithSubpath = "https://my-awesome-api.fake/MY_SUBROUTE"; 57 | expect(constructUrl(baseWithSubpath, composePath(parentPath, path))).toBe( 58 | "https://my-awesome-api.fake/MY_SUBROUTE/someBasePath", 59 | ); 60 | }); 61 | 62 | it("should not include parentPath value when path is absolute", () => { 63 | const parentPath = "/someBasePath"; 64 | const path = "/absolute"; 65 | expect(composePath(parentPath, path)).toBe("/absolute"); 66 | 67 | const base = "https://my-awesome-api.fake"; 68 | expect(constructUrl(base, composePath(parentPath, path))).toBe("https://my-awesome-api.fake/absolute"); 69 | 70 | const baseWithSubpath = "https://my-awesome-api.fake/MY_SUBROUTE"; 71 | expect(constructUrl(baseWithSubpath, composePath(parentPath, path))).toBe( 72 | "https://my-awesome-api.fake/MY_SUBROUTE/absolute", 73 | ); 74 | }); 75 | 76 | it("should include parentPath value when path is relative", () => { 77 | const parentPath = "/someBasePath"; 78 | const path = "relative"; 79 | expect(composePath(parentPath, path)).toBe("/someBasePath/relative"); 80 | 81 | const base = "https://my-awesome-api.fake"; 82 | expect(constructUrl(base, composePath(parentPath, path))).toBe( 83 | "https://my-awesome-api.fake/someBasePath/relative", 84 | ); 85 | 86 | const baseWithSubpath = "https://my-awesome-api.fake/MY_SUBROUTE"; 87 | expect(constructUrl(baseWithSubpath, composePath(parentPath, path))).toBe( 88 | "https://my-awesome-api.fake/MY_SUBROUTE/someBasePath/relative", 89 | ); 90 | }); 91 | }); 92 | }); 93 | -------------------------------------------------------------------------------- /docs/contiamo-long-poll.md: -------------------------------------------------------------------------------- 1 | # Motivation 2 | 3 | Streaming events from servers to browsers is a big messy non-standardized problem. This spec will define _how_ Contiamo sends events from backend services to web-browsers. This can be used for streaming new logs from long running process or notify that the object being viewed has been changed. 4 | 5 | In short, servers will implement long-polling [[1](https://en.wikipedia.org/wiki/Push_technology#Long_polling), [2](https://realtimeapi.io/hub/http-long-polling/)]. Clients will indicate a long-poll request using the [`Prefer` header](https://tools.ietf.org/html/rfc7240#section-4.3), servers will indicate the polling timeout with a [304 status code](https://httpstatuses.com/304), and payloads will be JSON. 6 | 7 | Alternative implementations that a service and can support _in addition_ to long-polling are grpc streams [[1](https://grpc.io/docs/guides/concepts.html#server-streaming-rpc), [2](https://grpc.io/docs/tutorials/basic/node.html#streaming-rpcs)] or http streaming [[1](https://realtimeapi.io/hub/http-streaming/), [2](https://tools.ietf.org/id/draft-loreto-http-bidirectional-07.html#streaming)] with [new-line-delimited-json](http://ndjson.org/). 8 | 9 | 10 | 11 | 12 | 13 | - [Implementation](#implementation) 14 | - [Endpoints](#endpoints) 15 | - [Flow](#flow) 16 | - [Request Headers](#request-headers) 17 | - [Response Headers](#response-headers) 18 | - [Response](#response) 19 | - [Status Codes](#status-codes) 20 | - [Research Examples](#research-examples) 21 | - [Specifications, Blogs, and other Documents](#specifications-blogs-and-other-documents) 22 | 23 | 24 | 25 | # Implementation 26 | 27 | ## Endpoints 28 | 29 | Long polling will be implemented as an enhancement of existing REST/RPC endpoints, not as new polling specific endpoints. The API documentation must specify if the endpoint supports long-polling. 30 | 31 | ## Flow 32 | 33 | HTTP long polling is a variation on the standard polling but with the distinction that the polling request are "long-lived". At Contiamo, the flow looks like this: 34 | 35 | ![Long Poll Flow](long-poll-flow.png) 36 | 37 | ## Request Headers 38 | 39 | To start a long-polling request, Restful React makes an `HTTP GET` request and set the `Prefer` header with a wait value and an index value, e.g. `wait=60;index=abad123`. This is a number of seconds and a query "position". This value can be set at a maximum of 60 seconds, the minimum can be defined by the backend server, but is typically >5 seconds. The index will be a server defined and supplied value. 40 | 41 | The `index` value is optional, if omitted, the request is processed exactly the same as a standard web request (meaning based on any `GET` parameters supplied). This `index` value should be read from the `X-Polling-Index` header in a previous response. When it is set, the server will use the value to wait for any changes subsequent to that index. 42 | 43 | ## Response Headers 44 | 45 | On a successful `GET`, the server must set a head `X-Polling-Index`, this value is a unique identifier representing the current state of the resource. It is not required to have any specific structure or meaning for the client. Meaning that the client should not inspect the value for any specific information or structure. For example, this value could be datetime string of the last update, an int64 of the last object, or it could be a base64 encoded datetime string like `MjAxOC0wMS0wMVQwMDowMDowMFo=`. Whatever it is, the server is responsible for encoding and decoding this value to filter the query for changes from that index point. 46 | 47 | ### Response 48 | 49 | In the absence of the `Prefer` header, the request will behave as normal, the backend service will immediately process and return a response as soon as it can. 50 | 51 | When the `Prefer` header is set, the server will parse (and potentially normalize the value). It will process the request. The server will wait until a maximum of the `wait` value has elapsed _or_ it can fulfill the request. If the `wait` time elapses, it will send a `304` status code indicating that the request did not fail, but contains no data. If the server decides that it can fulfill the request, it response with a `200` status code and a JSON payload, as defined by the API docs for the endpoint. 52 | 53 | Once the request has finished the client can then open a new request for more data. 54 | 55 | ## Status Codes 56 | 57 | - `200` success 58 | - `304` long poll timeout 59 | - `4xx` request error 60 | - `5xx` server error 61 | 62 | This list is provided to highlight the distinction between the polling timeout response and other timeout responses like 63 | 64 | - `[408 Request Timeout](https://httpstatuses.com/408)` 65 | - `[504 Gateway Timeout](https://httpstatuses.com/504)` 66 | - `[599 Network Connect Timeout Error](https://httpstatuses.com/599)` 67 | 68 | These other statuses should be treated as errors. 69 | 70 | # Research Examples 71 | 72 | - [Console blocking queries](https://www.consul.io/api/index.html#blocking-queries) 73 | - [Dropbox longpoll endpoints](https://www.dropbox.com/developers/documentation/http/documentation#files-list_folder-longpoll) 74 | 75 | # Specifications, Blogs, and other Documents 76 | 77 | - [Prefer header RFC](https://tools.ietf.org/html/rfc7240#section-4.3) 78 | - [Realtime API hub docs](https://realtimeapi.io/hub/http-long-polling/) 79 | -------------------------------------------------------------------------------- /src/scripts/tests/petstore-expanded.yaml: -------------------------------------------------------------------------------- 1 | openapi: "3.0.0" 2 | info: 3 | version: 1.0.0 4 | title: Swagger Petstore 5 | description: A sample API that uses a petstore as an example to demonstrate features in the OpenAPI 3.0 specification 6 | termsOfService: http://swagger.io/terms/ 7 | contact: 8 | name: Swagger API Team 9 | email: apiteam@swagger.io 10 | url: http://swagger.io 11 | license: 12 | name: Apache 2.0 13 | url: https://www.apache.org/licenses/LICENSE-2.0.html 14 | servers: 15 | - url: http://petstore.swagger.io/api 16 | paths: 17 | /pets: 18 | get: 19 | description: | 20 | Returns all pets from the system that the user has access to 21 | Nam sed condimentum est. Maecenas tempor sagittis sapien, nec rhoncus sem sagittis sit amet. Aenean at gravida augue, ac iaculis sem. Curabitur odio lorem, ornare eget elementum nec, cursus id lectus. Duis mi turpis, pulvinar ac eros ac, tincidunt varius justo. In hac habitasse platea dictumst. Integer at adipiscing ante, a sagittis ligula. Aenean pharetra tempor ante molestie imperdiet. Vivamus id aliquam diam. Cras quis velit non tortor eleifend sagittis. Praesent at enim pharetra urna volutpat venenatis eget eget mauris. In eleifend fermentum facilisis. Praesent enim enim, gravida ac sodales sed, placerat id erat. Suspendisse lacus dolor, consectetur non augue vel, vehicula interdum libero. Morbi euismod sagittis libero sed lacinia. 22 | 23 | Sed tempus felis lobortis leo pulvinar rutrum. Nam mattis velit nisl, eu condimentum ligula luctus nec. Phasellus semper velit eget aliquet faucibus. In a mattis elit. Phasellus vel urna viverra, condimentum lorem id, rhoncus nibh. Ut pellentesque posuere elementum. Sed a varius odio. Morbi rhoncus ligula libero, vel eleifend nunc tristique vitae. Fusce et sem dui. Aenean nec scelerisque tortor. Fusce malesuada accumsan magna vel tempus. Quisque mollis felis eu dolor tristique, sit amet auctor felis gravida. Sed libero lorem, molestie sed nisl in, accumsan tempor nisi. Fusce sollicitudin massa ut lacinia mattis. Sed vel eleifend lorem. Pellentesque vitae felis pretium, pulvinar elit eu, euismod sapien. 24 | operationId: findPets 25 | parameters: 26 | - name: tags 27 | in: query 28 | description: tags to filter by 29 | required: false 30 | style: form 31 | schema: 32 | type: array 33 | items: 34 | type: string 35 | - name: limit 36 | in: query 37 | description: maximum number of results to return 38 | required: false 39 | schema: 40 | type: integer 41 | format: int32 42 | responses: 43 | "200": 44 | description: pet response 45 | content: 46 | application/json: 47 | schema: 48 | type: array 49 | items: 50 | $ref: "#/components/schemas/Pet" 51 | default: 52 | description: unexpected error 53 | content: 54 | application/json: 55 | schema: 56 | $ref: "#/components/schemas/Error" 57 | post: 58 | description: Creates a new pet in the store. Duplicates are allowed 59 | operationId: addPet 60 | requestBody: 61 | description: Pet to add to the store 62 | required: true 63 | content: 64 | application/json: 65 | schema: 66 | $ref: "#/components/schemas/NewPet" 67 | responses: 68 | "200": 69 | description: pet response 70 | content: 71 | application/json: 72 | schema: 73 | $ref: "#/components/schemas/Pet" 74 | default: 75 | description: unexpected error 76 | content: 77 | application/json: 78 | schema: 79 | $ref: "#/components/schemas/Error" 80 | /pets/{id}: 81 | get: 82 | description: Returns a user based on a single ID, if the user does not have access to the pet 83 | operationId: find pet by id 84 | parameters: 85 | - name: id 86 | in: path 87 | description: ID of pet to fetch 88 | required: true 89 | schema: 90 | type: integer 91 | format: int64 92 | responses: 93 | "200": 94 | description: pet response 95 | content: 96 | application/json: 97 | schema: 98 | $ref: "#/components/schemas/Pet" 99 | default: 100 | description: unexpected error 101 | content: 102 | application/json: 103 | schema: 104 | $ref: "#/components/schemas/Error" 105 | delete: 106 | description: deletes a single pet based on the ID supplied 107 | operationId: deletePet 108 | parameters: 109 | - name: id 110 | in: path 111 | description: ID of pet to delete 112 | required: true 113 | schema: 114 | type: integer 115 | format: int64 116 | responses: 117 | "204": 118 | description: pet deleted 119 | default: 120 | description: unexpected error 121 | content: 122 | application/json: 123 | schema: 124 | $ref: "#/components/schemas/Error" 125 | patch: 126 | description: Updates a pet in the store. 127 | operationId: updatePet 128 | parameters: 129 | - name: id 130 | in: path 131 | description: ID of pet to update 132 | required: true 133 | schema: 134 | type: integer 135 | format: int64 136 | requestBody: 137 | $ref: "#/components/requestBodies/updatePetRequest" 138 | responses: 139 | "200": 140 | description: pet response 141 | content: 142 | application/json: 143 | schema: 144 | $ref: "#/components/schemas/Pet" 145 | default: 146 | description: unexpected error 147 | content: 148 | application/json: 149 | schema: 150 | $ref: "#/components/schemas/Error" 151 | components: 152 | requestBodies: 153 | updatePetRequest: 154 | content: 155 | application/json: 156 | schema: 157 | $ref: "#/components/schemas/NewPet" 158 | required: true 159 | schemas: 160 | Pet: 161 | description: A pet. 162 | allOf: 163 | - $ref: "#/components/schemas/NewPet" 164 | - required: 165 | - id 166 | properties: 167 | id: 168 | type: integer 169 | format: int64 170 | 171 | NewPet: 172 | description: A new pet. 173 | required: 174 | - name 175 | properties: 176 | name: 177 | type: string 178 | tag: 179 | type: string 180 | 181 | CatOrDog: 182 | description: A discriminator example. 183 | oneOf: 184 | - $ref: "#/components/schemas/Cat" 185 | - $ref: "#/components/schemas/Dog" 186 | discriminator: 187 | propertyName: type 188 | mapping: 189 | cat: "#/components/schemas/Cat" 190 | dog: "#/components/schemas/Dog" 191 | 192 | Cat: 193 | description: A cat, meow. 194 | type: object 195 | properties: 196 | type: 197 | type: string 198 | breed: 199 | type: string 200 | enum: 201 | - labrador 202 | - carlin 203 | - beagle 204 | required: 205 | - type 206 | - breed 207 | 208 | Dog: 209 | description: A dog, wooof. 210 | type: object 211 | properties: 212 | type: 213 | type: string 214 | breed: 215 | type: string 216 | enum: 217 | - saimois 218 | - bengal 219 | - british shorthair 220 | required: 221 | - type 222 | - breed 223 | 224 | Error: 225 | description: An error :( 226 | required: 227 | - code 228 | - message 229 | properties: 230 | code: 231 | type: integer 232 | format: int32 233 | message: 234 | type: string 235 | Request: 236 | description: Request description 237 | properties: 238 | action: 239 | type: array 240 | items: 241 | type: string 242 | enum: 243 | - create 244 | - read 245 | - update 246 | - delete 247 | -------------------------------------------------------------------------------- /src/useMutate.tsx: -------------------------------------------------------------------------------- 1 | import merge from "lodash/merge"; 2 | import { useContext, useEffect, useState } from "react"; 3 | import { Context } from "./Context"; 4 | import { MutateMethod, MutateState, MutateRequestOptions } from "./Mutate"; 5 | import { Omit, UseGetProps } from "./useGet"; 6 | import { constructUrl } from "./util/constructUrl"; 7 | import { processResponse } from "./util/processResponse"; 8 | import { useAbort } from "./useAbort"; 9 | import { useDeepCompareCallback, useDeepCompareEffect } from "./util/useDeepCompareEffect"; 10 | 11 | export interface UseMutateProps 12 | extends Omit, "lazy" | "debounce" | "mock"> { 13 | /** 14 | * What HTTP verb are we using? 15 | */ 16 | verb: "POST" | "PUT" | "PATCH" | "DELETE"; 17 | /** 18 | * Callback called after the mutation is done. 19 | * 20 | * @param body - Body given to mutate 21 | * @param data - Response data 22 | */ 23 | onMutate?: (body: TRequestBody, data: TData) => void; 24 | /** 25 | * Developer mode 26 | * Override the state with some mocks values and avoid to fetch 27 | */ 28 | mock?: { 29 | mutate?: MutateMethod; 30 | loading?: boolean; 31 | }; 32 | /** 33 | * A function to encode body of DELETE requests when appending it 34 | * to an existing path 35 | */ 36 | pathInlineBodyEncode?: typeof encodeURIComponent; 37 | } 38 | 39 | export interface UseMutateReturn 40 | extends MutateState { 41 | /** 42 | * Cancel the current fetch 43 | */ 44 | cancel: () => void; 45 | /** 46 | * Call the mutate endpoint 47 | */ 48 | mutate: MutateMethod; 49 | } 50 | 51 | export function useMutate< 52 | TData = any, 53 | TError = any, 54 | TQueryParams = { [key: string]: any }, 55 | TRequestBody = any, 56 | TPathParams = unknown 57 | >( 58 | props: UseMutateProps, 59 | ): UseMutateReturn; 60 | 61 | export function useMutate< 62 | TData = any, 63 | TError = any, 64 | TQueryParams = { [key: string]: any }, 65 | TRequestBody = any, 66 | TPathParams = unknown 67 | >( 68 | verb: UseMutateProps["verb"], 69 | path: UseMutateProps["path"], 70 | props?: Omit, "path" | "verb">, 71 | ): UseMutateReturn; 72 | 73 | export function useMutate< 74 | TData = any, 75 | TError = any, 76 | TQueryParams = { [key: string]: any }, 77 | TRequestBody = any, 78 | TPathParams = unknown 79 | >(): UseMutateReturn { 80 | const props: UseMutateProps = 81 | typeof arguments[0] === "object" ? arguments[0] : { ...arguments[2], path: arguments[1], verb: arguments[0] }; 82 | 83 | const context = useContext(Context); 84 | const { 85 | verb, 86 | base = context.base, 87 | path, 88 | queryParams = EMPTY_OBJECT, 89 | resolve = context.resolve, 90 | pathParams = EMPTY_OBJECT, 91 | } = props; 92 | const isDelete = verb === "DELETE"; 93 | 94 | const [state, setState] = useState>({ 95 | error: null, 96 | loading: false, 97 | }); 98 | 99 | const { abort, getAbortSignal } = useAbort(); 100 | 101 | // Cancel the fetch on unmount 102 | useEffect(() => () => abort(), [abort]); 103 | 104 | const { pathInlineBodyEncode, queryParamStringifyOptions, requestOptions, localErrorOnly, onMutate } = props; 105 | 106 | const effectDependencies = [ 107 | path, 108 | pathParams, 109 | queryParams, 110 | verb, 111 | isDelete, 112 | base, 113 | context, 114 | queryParamStringifyOptions, 115 | requestOptions, 116 | onMutate, 117 | abort, 118 | pathInlineBodyEncode, 119 | localErrorOnly, 120 | resolve, 121 | ]; 122 | const mutate = useDeepCompareCallback>( 123 | async (body: TRequestBody, mutateRequestOptions?: MutateRequestOptions) => { 124 | const signal = getAbortSignal(); 125 | 126 | setState(prevState => { 127 | if (prevState.error || !prevState.loading) { 128 | return { ...prevState, loading: true, error: null }; 129 | } 130 | return prevState; 131 | }); 132 | 133 | const pathStr = 134 | typeof path === "function" ? path(mutateRequestOptions?.pathParams || (pathParams as TPathParams)) : path; 135 | 136 | const pathParts = [pathStr]; 137 | 138 | const options: RequestInit = { 139 | method: verb, 140 | }; 141 | 142 | // don't set content-type when body is of type FormData 143 | if (!(body instanceof FormData)) { 144 | options.headers = { "content-type": typeof body === "object" ? "application/json" : "text/plain" }; 145 | } 146 | 147 | if (body instanceof FormData) { 148 | options.body = body; 149 | } else if (typeof body === "object") { 150 | options.body = JSON.stringify(body); 151 | } else if (isDelete && body !== undefined) { 152 | const possiblyEncodedBody = pathInlineBodyEncode ? pathInlineBodyEncode(String(body)) : String(body); 153 | 154 | pathParts.push(possiblyEncodedBody); 155 | } else { 156 | options.body = (body as unknown) as string; 157 | } 158 | 159 | const url = constructUrl( 160 | base, 161 | pathParts.join("/"), 162 | { ...context.queryParams, ...queryParams, ...mutateRequestOptions?.queryParams }, 163 | { 164 | queryParamOptions: { ...context.queryParamStringifyOptions, ...queryParamStringifyOptions }, 165 | }, 166 | ); 167 | 168 | const propsRequestOptions = 169 | (typeof requestOptions === "function" ? await requestOptions(url, verb, body) : requestOptions) || {}; 170 | 171 | const contextRequestOptions = 172 | (typeof context.requestOptions === "function" 173 | ? await context.requestOptions(url, verb, body) 174 | : context.requestOptions) || {}; 175 | 176 | const request = new Request( 177 | url, 178 | merge({}, contextRequestOptions, options, propsRequestOptions, mutateRequestOptions, { signal }), 179 | ); 180 | if (context.onRequest) context.onRequest(request); 181 | 182 | let response: Response; 183 | try { 184 | response = await fetch(request); 185 | if (context.onResponse) context.onResponse(response.clone()); 186 | } catch (e) { 187 | const error = { 188 | message: `Failed to fetch: ${e.message}`, 189 | data: "", 190 | }; 191 | 192 | setState({ 193 | error, 194 | loading: false, 195 | }); 196 | 197 | if (!localErrorOnly && context.onError) { 198 | context.onError(error, () => mutate(body, mutateRequestOptions)); 199 | } 200 | 201 | throw error; 202 | } 203 | 204 | const { data: rawData, responseError } = await processResponse(response); 205 | 206 | let data: TData | any; // `any` -> data in error case 207 | try { 208 | data = resolve ? resolve(rawData) : rawData; 209 | } catch (e) { 210 | // avoid state updates when component has been unmounted 211 | // and when fetch/processResponse threw an error 212 | if (signal && signal.aborted) { 213 | return; 214 | } 215 | 216 | const error = { 217 | data: e.message, 218 | message: `Failed to resolve: ${e.message}`, 219 | }; 220 | 221 | setState(prevState => ({ 222 | ...prevState, 223 | error, 224 | loading: false, 225 | })); 226 | throw e; 227 | } 228 | 229 | if (signal && signal.aborted) { 230 | return; 231 | } 232 | 233 | if (!response.ok || responseError) { 234 | const error = { 235 | data, 236 | message: `Failed to fetch: ${response.status} ${response.statusText}`, 237 | status: response.status, 238 | }; 239 | 240 | setState(prevState => ({ 241 | ...prevState, 242 | error, 243 | loading: false, 244 | })); 245 | 246 | if (!localErrorOnly && context.onError) { 247 | context.onError(error, () => mutate(body), response); 248 | } 249 | 250 | throw error; 251 | } 252 | 253 | setState(prevState => ({ ...prevState, loading: false })); 254 | 255 | if (onMutate) { 256 | onMutate(body, data); 257 | } 258 | 259 | return data; 260 | }, 261 | effectDependencies, 262 | ); 263 | useDeepCompareEffect(() => { 264 | if (state.loading) { 265 | abort(); 266 | } 267 | }, effectDependencies); 268 | 269 | return { 270 | ...state, 271 | mutate, 272 | ...props.mock, 273 | cancel: () => { 274 | setState(prevState => ({ 275 | ...prevState, 276 | loading: false, 277 | })); 278 | abort(); 279 | }, 280 | }; 281 | } 282 | 283 | // Declaring this in order to have a thing with stable identity 284 | const EMPTY_OBJECT = {}; 285 | -------------------------------------------------------------------------------- /src/bin/restful-react-import.ts: -------------------------------------------------------------------------------- 1 | import chalk from "chalk"; 2 | import program from "commander"; 3 | import { existsSync, readFileSync, unlinkSync, writeFileSync } from "fs"; 4 | import inquirer from "inquirer"; 5 | import difference from "lodash/difference"; 6 | import pick from "lodash/pick"; 7 | import { join, parse } from "path"; 8 | import request from "request"; 9 | import { homedir } from "os"; 10 | import slash from "slash"; 11 | 12 | import importOpenApi from "../scripts/import-open-api"; 13 | import { OperationObject } from "openapi3-ts"; 14 | import { UseGetProps } from "../useGet"; 15 | 16 | const log = console.log; // tslint:disable-line:no-console 17 | 18 | export interface Options { 19 | output: string; 20 | file?: string; 21 | url?: string; 22 | github?: string; 23 | transformer?: string; 24 | validation?: boolean; 25 | skipReact?: boolean; 26 | } 27 | 28 | export type AdvancedOptions = Options & { 29 | customImport?: string; 30 | customProps?: { 31 | [props in keyof Omit, "lazy" | "debounce" | "path">]: 32 | | string 33 | | ((meta: { responseType: string }) => string); 34 | }; 35 | 36 | pathParametersEncodingMode?: "uriComponent" | "rfc3986"; 37 | 38 | customGenerator?: (data: { 39 | componentName: string; 40 | verb: string; 41 | route: string; 42 | description: string; 43 | genericsTypes: string; 44 | operation: OperationObject; 45 | paramsInPath: string[]; 46 | paramsTypes: string; 47 | }) => string; 48 | }; 49 | 50 | export interface ExternalConfigFile { 51 | [backend: string]: AdvancedOptions; 52 | } 53 | 54 | program.option("-o, --output [value]", "output file destination"); 55 | program.option("-f, --file [value]", "input file (yaml or json openapi specs)"); 56 | program.option("-u, --url [value]", "url to spec (yaml or json openapi specs)"); 57 | program.option("-g, --github [value]", "github path (format: `owner:repo:branch:path`)"); 58 | program.option("-t, --transformer [value]", "transformer function path"); 59 | program.option("--validation", "add the validation step (provided by ibm-openapi-validator)"); 60 | program.option("--skip-react", "skip the generation of react components/hooks"); 61 | program.option("--config [value]", "override flags by a config file"); 62 | program.parse(process.argv); 63 | 64 | const createSuccessMessage = (backend?: string) => 65 | chalk.green( 66 | `${ 67 | backend ? `[${backend}] ` : "" 68 | }🎉 Your OpenAPI spec has been converted into ready to use restful-react components!`, 69 | ); 70 | 71 | const successWithoutOutputMessage = chalk.yellow("Success! No output path specified; printed to standard output."); 72 | 73 | const importSpecs = async (options: AdvancedOptions) => { 74 | const transformer = options.transformer ? require(join(process.cwd(), options.transformer)) : undefined; 75 | 76 | const optionsKeys: Array = [ 77 | "validation", 78 | "customImport", 79 | "customProps", 80 | "customGenerator", 81 | "pathParametersEncodingMode", 82 | "skipReact", 83 | ]; 84 | const importOptions = pick(options, optionsKeys); 85 | 86 | if (!options.file && !options.url && !options.github) { 87 | throw new Error("You need to provide an input specification with `--file`, '--url', or `--github`"); 88 | } 89 | 90 | if (options.file) { 91 | const data = readFileSync(join(process.cwd(), options.file), "utf-8"); 92 | const { ext } = parse(options.file); 93 | const format = [".yaml", ".yml"].includes(ext.toLowerCase()) ? "yaml" : "json"; 94 | 95 | return importOpenApi({ 96 | data, 97 | format, 98 | transformer, 99 | ...importOptions, 100 | }); 101 | } else if (options.url) { 102 | const { url } = options; 103 | 104 | const urlSpecReq = { 105 | method: "GET", 106 | url, 107 | headers: { 108 | "user-agent": "restful-react-importer", 109 | }, 110 | }; 111 | 112 | return new Promise((resolve, reject) => { 113 | request(urlSpecReq, async (error, response, body) => { 114 | if (error) { 115 | return reject(error); 116 | } 117 | 118 | // Attempt to determine format 119 | // will default to yaml as it 120 | // also supports json fully 121 | let format: "json" | "yaml" = "yaml"; 122 | if (url.endsWith(".json") || response.headers["content-type"] === "application/json") { 123 | format = "json"; 124 | } 125 | 126 | resolve( 127 | importOpenApi({ 128 | data: body, 129 | format, 130 | transformer, 131 | ...importOptions, 132 | }), 133 | ); 134 | }); 135 | }); 136 | } else if (options.github) { 137 | const { github } = options; 138 | 139 | let accessToken = process.env.GITHUB_TOKEN; 140 | const githubTokenPath = join(homedir(), ".restful-react"); 141 | if (!accessToken && existsSync(githubTokenPath)) { 142 | accessToken = readFileSync(githubTokenPath, "utf-8"); 143 | } else if (!accessToken) { 144 | const answers = await inquirer.prompt<{ githubToken: string; saveToken: boolean }>([ 145 | { 146 | type: "input", 147 | name: "githubToken", 148 | message: 149 | "Please provide a GitHub token with `repo` rules checked ( https://help.github.com/en/github/authenticating-to-github/creating-a-personal-access-token-for-the-command-line )", 150 | }, 151 | { 152 | type: "confirm", 153 | name: "saveToken", 154 | message: `Would you like to store your token for the next time? (stored in your ${slash(githubTokenPath)})`, 155 | }, 156 | ]); 157 | if (answers.saveToken) { 158 | writeFileSync(githubTokenPath, answers.githubToken); 159 | } 160 | accessToken = answers.githubToken; 161 | } 162 | const [owner, repo, branch, path] = github.split(":"); 163 | 164 | const githubSpecReq = { 165 | method: "POST", 166 | url: "https://api.github.com/graphql", 167 | headers: { 168 | "content-type": "application/json", 169 | "user-agent": "restful-react-importer", 170 | authorization: `bearer ${accessToken}`, 171 | }, 172 | body: JSON.stringify({ 173 | query: `query { 174 | repository(name: "${repo}", owner: "${owner}") { 175 | object(expression: "${branch}:${path}") { 176 | ... on Blob { 177 | text 178 | } 179 | } 180 | } 181 | }`, 182 | }), 183 | }; 184 | 185 | return new Promise((resolve, reject) => { 186 | request(githubSpecReq, async (error, _, rawBody) => { 187 | if (error) { 188 | return reject(error); 189 | } 190 | 191 | const body = JSON.parse(rawBody); 192 | if (!body.data) { 193 | if (body.message === "Bad credentials") { 194 | const answers = await inquirer.prompt<{ removeToken: boolean }>([ 195 | { 196 | type: "confirm", 197 | name: "removeToken", 198 | message: "Your token doesn't have the correct permissions, should we remove it?", 199 | }, 200 | ]); 201 | if (answers.removeToken) { 202 | unlinkSync(githubTokenPath); 203 | } 204 | } 205 | return reject(body.message); 206 | } 207 | 208 | const format = 209 | github.toLowerCase().includes(".yaml") || github.toLowerCase().includes(".yml") ? "yaml" : "json"; 210 | resolve( 211 | importOpenApi({ 212 | data: body.data.repository.object.text, 213 | format, 214 | transformer, 215 | ...importOptions, 216 | }), 217 | ); 218 | }); 219 | }); 220 | } else { 221 | return Promise.reject("Please provide a file (--file), a url (--url), or a github (--github) input"); 222 | } 223 | }; 224 | 225 | if (program.config) { 226 | // Use config file as configuration (advanced usage) 227 | 228 | // tslint:disable-next-line: no-var-requires 229 | const config: ExternalConfigFile = require(join(process.cwd(), program.config)); 230 | 231 | const mismatchArgs = difference(program.args, Object.keys(config)); 232 | if (mismatchArgs.length) { 233 | log( 234 | chalk.yellow( 235 | `${mismatchArgs.join(", ")} ${mismatchArgs.length === 1 ? "is" : "are"} not defined in your configuration!`, 236 | ), 237 | ); 238 | } 239 | 240 | Object.entries(config) 241 | .filter(([backend]) => (program.args.length === 0 ? true : program.args.includes(backend))) 242 | .forEach(([backend, options]) => { 243 | importSpecs(options) 244 | .then(data => { 245 | if (options.output) { 246 | writeFileSync(join(process.cwd(), options.output), data); 247 | log(createSuccessMessage(backend)); 248 | } else { 249 | log(data); 250 | log(successWithoutOutputMessage); 251 | } 252 | }) 253 | .catch(err => { 254 | log(chalk.red(err)); 255 | process.exit(1); 256 | }); 257 | }); 258 | } else { 259 | // Use flags as configuration 260 | importSpecs((program as any) as Options) 261 | .then(data => { 262 | if (program.output) { 263 | writeFileSync(join(process.cwd(), program.output), data); 264 | log(createSuccessMessage()); 265 | } else { 266 | log(data); 267 | log(successWithoutOutputMessage); 268 | } 269 | }) 270 | .catch(err => { 271 | log(chalk.red(err)); 272 | process.exit(1); 273 | }); 274 | } 275 | -------------------------------------------------------------------------------- /src/useGet.tsx: -------------------------------------------------------------------------------- 1 | import { useContext, useState, useCallback, useEffect } from "react"; 2 | import { Cancelable, DebounceSettings } from "lodash"; 3 | import debounce from "lodash/debounce"; 4 | import merge from "lodash/merge"; 5 | import { IStringifyOptions } from "qs"; 6 | 7 | import { Context, RestfulReactProviderProps } from "./Context"; 8 | import { GetState } from "./Get"; 9 | import { processResponse } from "./util/processResponse"; 10 | import { useDeepCompareCallback } from "./util/useDeepCompareEffect"; 11 | import { useAbort } from "./useAbort"; 12 | import { constructUrl } from "./util/constructUrl"; 13 | 14 | export type Omit = Pick>; 15 | 16 | export interface UseGetProps { 17 | /** 18 | * The path at which to request data, 19 | * typically composed by parent Gets or the RestfulProvider. 20 | */ 21 | path: string | ((pathParams: TPathParams) => string); 22 | /** 23 | * Path Parameters 24 | */ 25 | pathParams?: TPathParams; 26 | /** Options passed into the fetch call. */ 27 | requestOptions?: RestfulReactProviderProps["requestOptions"]; 28 | /** 29 | * Query parameters 30 | */ 31 | queryParams?: TQueryParams; 32 | /** 33 | * Query parameter stringify options 34 | */ 35 | queryParamStringifyOptions?: IStringifyOptions; 36 | /** 37 | * Don't send the error to the Provider 38 | */ 39 | localErrorOnly?: boolean; 40 | /** 41 | * A function to resolve data return from the backend, most typically 42 | * used when the backend response needs to be adapted in some way. 43 | */ 44 | resolve?: (data: any) => TData; 45 | /** 46 | * Developer mode 47 | * Override the state with some mocks values and avoid to fetch 48 | */ 49 | mock?: { data?: TData; error?: TError; loading?: boolean; response?: Response }; 50 | /** 51 | * Should we fetch data at a later stage? 52 | */ 53 | lazy?: boolean; 54 | /** 55 | * An escape hatch and an alternative to `path` when you'd like 56 | * to fetch from an entirely different URL. 57 | * 58 | */ 59 | base?: string; 60 | /** 61 | * How long do we wait between subsequent requests? 62 | * Uses [lodash's debounce](https://lodash.com/docs/4.17.10#debounce) under the hood. 63 | */ 64 | debounce?: 65 | | { 66 | wait?: number; 67 | options: DebounceSettings; 68 | } 69 | | boolean 70 | | number; 71 | } 72 | 73 | type FetchData = ( 74 | props: UseGetProps, 75 | context: RestfulReactProviderProps, 76 | abort: () => void, 77 | getAbortSignal: () => AbortSignal | undefined, 78 | ) => Promise; 79 | type CancellableFetchData = 80 | | FetchData 81 | | (FetchData & Cancelable); 82 | type RefetchOptions = Partial< 83 | Omit, "lazy"> 84 | >; 85 | 86 | const isCancellable = any>(func: T): func is T & Cancelable => { 87 | return typeof (func as any).cancel === "function" && typeof (func as any).flush === "function"; 88 | }; 89 | 90 | export interface UseGetReturn extends GetState { 91 | /** 92 | * Absolute path resolved from `base` and `path` (context & local) 93 | */ 94 | absolutePath: string; 95 | /** 96 | * Cancel the current fetch 97 | */ 98 | cancel: () => void; 99 | /** 100 | * Refetch 101 | */ 102 | refetch: (options?: RefetchOptions) => Promise; 103 | } 104 | 105 | export function useGet( 106 | path: UseGetProps["path"], 107 | props?: Omit, "path">, 108 | ): UseGetReturn; 109 | 110 | export function useGet( 111 | props: UseGetProps, 112 | ): UseGetReturn; 113 | 114 | export function useGet() { 115 | const props: UseGetProps = 116 | typeof arguments[0] === "object" ? arguments[0] : { ...arguments[1], path: arguments[0] }; 117 | 118 | const context = useContext(Context); 119 | const { path, pathParams = {} } = props; 120 | 121 | const [state, setState] = useState>({ 122 | data: null, 123 | response: null, 124 | loading: !props.lazy, 125 | error: null, 126 | }); 127 | 128 | const { abort, getAbortSignal } = useAbort(); 129 | 130 | const pathStr = typeof path === "function" ? path(pathParams as TPathParams) : path; 131 | 132 | const _fetchData = useDeepCompareCallback>( 133 | async (props, context, abort, getAbortSignal) => { 134 | const { 135 | base = context.base, 136 | path, 137 | resolve = context.resolve || ((d: any) => d as TData), 138 | queryParams = {}, 139 | queryParamStringifyOptions = {}, 140 | requestOptions, 141 | pathParams = {}, 142 | } = props; 143 | 144 | setState(prev => { 145 | if (prev.error || !prev.loading) { 146 | return { ...prev, error: null, loading: true }; 147 | } 148 | return prev; 149 | }); 150 | 151 | const pathStr = typeof path === "function" ? path(pathParams as TPathParams) : path; 152 | 153 | const url = constructUrl( 154 | base, 155 | pathStr, 156 | { ...context.queryParams, ...queryParams }, 157 | { 158 | queryParamOptions: { ...context.queryParamStringifyOptions, ...queryParamStringifyOptions }, 159 | }, 160 | ); 161 | 162 | const propsRequestOptions = 163 | (typeof requestOptions === "function" ? await requestOptions(url, "GET") : requestOptions) || {}; 164 | 165 | const contextRequestOptions = 166 | (typeof context.requestOptions === "function" 167 | ? await context.requestOptions(url, "GET") 168 | : context.requestOptions) || {}; 169 | 170 | const signal = getAbortSignal(); 171 | 172 | const request = new Request(url, merge({}, contextRequestOptions, propsRequestOptions, { signal })); 173 | if (context.onRequest) context.onRequest(request); 174 | 175 | try { 176 | const response = await fetch(request); 177 | const originalResponse = response.clone(); 178 | if (context.onResponse) context.onResponse(originalResponse); 179 | const { data, responseError } = await processResponse(response); 180 | 181 | if (signal && signal.aborted) { 182 | return; 183 | } 184 | 185 | if (!response.ok || responseError) { 186 | const error = { 187 | message: `Failed to fetch: ${response.status} ${response.statusText}${responseError ? " - " + data : ""}`, 188 | data, 189 | status: response.status, 190 | }; 191 | 192 | setState(prev => ({ 193 | ...prev, 194 | loading: false, 195 | data: null, 196 | error, 197 | response: originalResponse, 198 | })); 199 | 200 | if (!props.localErrorOnly && context.onError) { 201 | context.onError(error, () => _fetchData(props, context, abort, getAbortSignal), response); 202 | } 203 | return; 204 | } 205 | 206 | const resolvedData = resolve(data); 207 | setState(prev => ({ 208 | ...prev, 209 | error: null, 210 | loading: false, 211 | data: resolvedData, 212 | response: originalResponse, 213 | })); 214 | return resolvedData; 215 | } catch (e) { 216 | // avoid state updates when component has been unmounted 217 | // and when fetch/processResponse threw an error 218 | if (signal && signal.aborted) { 219 | return; 220 | } 221 | 222 | const error = { 223 | message: `Failed to fetch: ${e.message}`, 224 | data: e.message, 225 | }; 226 | 227 | setState(prev => ({ 228 | ...prev, 229 | data: null, 230 | loading: false, 231 | error, 232 | })); 233 | 234 | if (!props.localErrorOnly && context.onError) { 235 | context.onError(error, () => _fetchData(props, context, abort, getAbortSignal)); 236 | } 237 | 238 | return; 239 | } 240 | }, 241 | [ 242 | props.lazy, 243 | props.mock, 244 | props.path, 245 | props.base, 246 | props.resolve, 247 | props.queryParams, 248 | props.requestOptions, 249 | props.pathParams, 250 | context.base, 251 | context.parentPath, 252 | context.queryParams, 253 | context.requestOptions, 254 | abort, 255 | ], 256 | ); 257 | const fetchData = useCallback>( 258 | typeof props.debounce === "object" 259 | ? debounce>( 260 | _fetchData, 261 | props.debounce.wait, 262 | props.debounce.options, 263 | ) 264 | : typeof props.debounce === "number" 265 | ? debounce>(_fetchData, props.debounce) 266 | : props.debounce 267 | ? debounce>(_fetchData) 268 | : _fetchData, 269 | [_fetchData, props.debounce], 270 | ); 271 | 272 | useEffect(() => { 273 | if (!props.lazy && !props.mock) { 274 | fetchData(props, context, abort, getAbortSignal); 275 | } 276 | 277 | return () => { 278 | if (isCancellable(fetchData)) { 279 | fetchData.cancel(); 280 | } 281 | abort(); 282 | }; 283 | // eslint-disable-next-line react-hooks/exhaustive-deps 284 | }, [fetchData, props.lazy, props.mock]); 285 | 286 | const refetch = useCallback( 287 | (options: RefetchOptions = {}) => 288 | fetchData({ ...props, ...options }, context, abort, getAbortSignal), 289 | // eslint-disable-next-line react-hooks/exhaustive-deps 290 | [fetchData], 291 | ); 292 | 293 | return { 294 | ...state, 295 | ...props.mock, // override the state 296 | absolutePath: constructUrl( 297 | props.base || context.base, 298 | pathStr, 299 | { 300 | ...context.queryParams, 301 | ...props.queryParams, 302 | }, 303 | { 304 | queryParamOptions: { 305 | ...context.queryParamStringifyOptions, 306 | ...props.queryParamStringifyOptions, 307 | }, 308 | }, 309 | ), 310 | cancel: () => { 311 | setState({ 312 | ...state, 313 | loading: false, 314 | }); 315 | abort(); 316 | }, 317 | refetch, 318 | }; 319 | } 320 | -------------------------------------------------------------------------------- /src/Mutate.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import RestfulReactProvider, { InjectedProps, RestfulReactConsumer, RestfulReactProviderProps } from "./Context"; 3 | import { GetState, ResolveFunction } from "./Get"; 4 | import { composePath, composeUrl } from "./util/composeUrl"; 5 | import { processResponse } from "./util/processResponse"; 6 | import { constructUrl } from "./util/constructUrl"; 7 | import { IStringifyOptions } from "qs"; 8 | 9 | /** 10 | * An enumeration of states that a fetchable 11 | * view could possibly have. 12 | */ 13 | export interface States { 14 | /** Is our view currently loading? */ 15 | loading: boolean; 16 | /** Do we have an error in the view? */ 17 | error?: GetState["error"]; 18 | } 19 | 20 | export interface MutateRequestOptions extends RequestInit { 21 | /** 22 | * Query parameters 23 | */ 24 | queryParams?: TQueryParams; 25 | /** 26 | * Path parameters 27 | */ 28 | pathParams?: TPathParams; 29 | } 30 | 31 | export type MutateMethod = ( 32 | data: TRequestBody, 33 | mutateRequestOptions?: MutateRequestOptions, 34 | ) => Promise; 35 | 36 | /** 37 | * Meta information returned to the fetchable 38 | * view. 39 | */ 40 | export interface Meta { 41 | /** The absolute path of this request. */ 42 | absolutePath: string; 43 | } 44 | 45 | /** 46 | * Props for the component. 47 | */ 48 | export interface MutateProps { 49 | /** 50 | * The path at which to request data, 51 | * typically composed by parents or the RestfulProvider. 52 | */ 53 | path?: string; 54 | /** 55 | * @private This is an internal implementation detail in restful-react, not meant to be used externally. 56 | * This helps restful-react correctly override `path`s when a new `base` property is provided. 57 | */ 58 | __internal_hasExplicitBase?: boolean; 59 | /** 60 | * What HTTP verb are we using? 61 | */ 62 | verb: "POST" | "PUT" | "PATCH" | "DELETE"; 63 | /** 64 | * Query parameters 65 | */ 66 | queryParams?: TQueryParams; 67 | /** 68 | * Query parameter stringify options 69 | */ 70 | queryParamStringifyOptions?: IStringifyOptions; 71 | /** 72 | * An escape hatch and an alternative to `path` when you'd like 73 | * to fetch from an entirely different URL. 74 | * 75 | */ 76 | base?: string; 77 | /** 78 | * The accumulated path from each level of parent GETs 79 | * taking the absolute and relative nature of each path into consideration 80 | */ 81 | parentPath?: string; 82 | /** Options passed into the fetch call. */ 83 | requestOptions?: RestfulReactProviderProps["requestOptions"]; 84 | /** 85 | * Don't send the error to the Provider 86 | */ 87 | localErrorOnly?: boolean; 88 | /** 89 | * A function that recieves a mutation function, along with 90 | * some metadata. 91 | * 92 | * @param actions - a key/value map of HTTP verbs, aliasing destroy to DELETE. 93 | */ 94 | children: ( 95 | mutate: MutateMethod, 96 | states: States, 97 | meta: Meta, 98 | ) => React.ReactNode; 99 | /** 100 | * Callback called after the mutation is done. 101 | * 102 | * @param body - Body given to mutate 103 | * @param data - Response data 104 | */ 105 | onMutate?: (body: TRequestBody, data: TData) => void; 106 | /** 107 | * A function to encode body of DELETE requests when appending it 108 | * to an existing path 109 | */ 110 | pathInlineBodyEncode?: typeof encodeURIComponent; 111 | /** 112 | * A function to resolve data return from the backend, most typically 113 | * used when the backend response needs to be adapted in some way. 114 | */ 115 | resolve?: ResolveFunction; 116 | } 117 | 118 | /** 119 | * State for the component. These 120 | * are implementation details and should be 121 | * hidden from any consumers. 122 | */ 123 | export interface MutateState { 124 | error: GetState["error"]; 125 | loading: boolean; 126 | } 127 | 128 | /** 129 | * The component without Context. This 130 | * is a named class because it is useful in 131 | * debugging. 132 | */ 133 | class ContextlessMutate extends React.Component< 134 | MutateProps & InjectedProps, 135 | MutateState 136 | > { 137 | public readonly state: Readonly> = { 138 | loading: false, 139 | error: null, 140 | }; 141 | 142 | public static defaultProps = { 143 | base: "", 144 | parentPath: "", 145 | path: "", 146 | queryParams: {}, 147 | }; 148 | 149 | /** 150 | * Abort controller to cancel the current fetch query 151 | */ 152 | private abortController = new AbortController(); 153 | private signal = this.abortController.signal; 154 | 155 | public componentWillUnmount() { 156 | this.abortController.abort(); 157 | } 158 | 159 | public mutate = async ( 160 | body: TRequestBody, 161 | mutateRequestOptions?: MutateRequestOptions, 162 | ) => { 163 | const { 164 | __internal_hasExplicitBase, 165 | base, 166 | parentPath, 167 | path, 168 | verb, 169 | requestOptions: providerRequestOptions, 170 | onError, 171 | onRequest, 172 | onResponse, 173 | pathInlineBodyEncode, 174 | resolve, 175 | } = this.props; 176 | this.setState(() => ({ error: null, loading: true })); 177 | 178 | const makeRequestPath = () => { 179 | const pathWithPossibleBody = 180 | verb === "DELETE" && typeof body === "string" 181 | ? composePath(path, pathInlineBodyEncode ? pathInlineBodyEncode(body) : body) 182 | : path; 183 | 184 | const concatPath = __internal_hasExplicitBase 185 | ? pathWithPossibleBody || "" 186 | : composePath(parentPath, pathWithPossibleBody); 187 | 188 | return constructUrl(base!, concatPath, this.props.queryParams, { 189 | stripTrailingSlash: true, 190 | queryParamOptions: this.props.queryParamStringifyOptions, 191 | }); 192 | }; 193 | 194 | const request = new Request(makeRequestPath(), { 195 | method: verb, 196 | body: body instanceof FormData ? body : typeof body === "object" ? JSON.stringify(body) : body, 197 | ...(typeof providerRequestOptions === "function" 198 | ? await providerRequestOptions(makeRequestPath(), verb, body) 199 | : providerRequestOptions), 200 | ...mutateRequestOptions, 201 | headers: { 202 | ...(typeof providerRequestOptions === "function" 203 | ? (await providerRequestOptions(makeRequestPath(), verb, body)).headers 204 | : (providerRequestOptions || {}).headers), 205 | ...(mutateRequestOptions ? mutateRequestOptions.headers : {}), 206 | }, 207 | } as RequestInit); // Type assertion for version of TypeScript that can't yet discriminate. 208 | 209 | // only set default content-type if body is not of type FormData and there is no content-type already defined on mutateRequestOptions.headers 210 | if (!(body instanceof FormData) && !request.headers.has("content-type")) { 211 | request.headers.set("content-type", typeof body === "object" ? "application/json" : "text/plain"); 212 | } 213 | 214 | if (onRequest) onRequest(request); 215 | 216 | let response: Response; 217 | try { 218 | response = await fetch(request, { signal: this.signal }); 219 | if (onResponse) onResponse(response.clone()); 220 | } catch (e) { 221 | const error = { 222 | message: `Failed to fetch: ${e.message}`, 223 | data: "", 224 | }; 225 | 226 | this.setState({ 227 | error, 228 | loading: false, 229 | }); 230 | 231 | if (!this.props.localErrorOnly && onError) { 232 | onError(error, () => this.mutate(body, mutateRequestOptions)); 233 | } 234 | 235 | throw error; 236 | } 237 | 238 | const { data: rawData, responseError } = await processResponse(response); 239 | 240 | let data: TData | any; // `any` -> data in error case 241 | try { 242 | data = resolve ? resolve(rawData) : rawData; 243 | } catch (e) { 244 | if (this.signal.aborted) { 245 | return; 246 | } 247 | const error = { 248 | data: e.message, 249 | message: `Failed to resolve: ${e.message}`, 250 | }; 251 | 252 | this.setState({ 253 | error, 254 | loading: false, 255 | }); 256 | throw e; 257 | } 258 | 259 | // avoid state updates when component has been unmounted 260 | if (this.signal.aborted) { 261 | return; 262 | } 263 | if (!response.ok || responseError) { 264 | const error = { 265 | data, 266 | message: `Failed to fetch: ${response.status} ${response.statusText}`, 267 | status: response.status, 268 | }; 269 | 270 | this.setState({ 271 | error, 272 | loading: false, 273 | }); 274 | 275 | if (!this.props.localErrorOnly && onError) { 276 | onError(error, () => this.mutate(body, mutateRequestOptions), response); 277 | } 278 | 279 | throw error; 280 | } 281 | 282 | this.setState({ loading: false }); 283 | 284 | if (this.props.onMutate) { 285 | this.props.onMutate(body, data); 286 | } 287 | 288 | return data; 289 | }; 290 | 291 | public render() { 292 | const { children, path, base, parentPath } = this.props; 293 | const { error, loading } = this.state; 294 | 295 | return children(this.mutate, { loading, error }, { absolutePath: composeUrl(base!, parentPath!, path!) }); 296 | } 297 | } 298 | 299 | /** 300 | * The component _with_ context. 301 | * Context is used to compose path props, 302 | * and to maintain the base property against 303 | * which all requests will be made. 304 | * 305 | * We compose Consumers immediately with providers 306 | * in order to provide new `parentPath` props that contain 307 | * a segment of the path, creating composable URLs. 308 | */ 309 | function Mutate< 310 | TData = any, 311 | TError = any, 312 | TQueryParams = { [key: string]: any }, 313 | TRequestBody = any, 314 | TPathParams = unknown 315 | >(props: MutateProps) { 316 | return ( 317 | 318 | {contextProps => ( 319 | 320 | 321 | {...contextProps} 322 | {...props} 323 | queryParams={{ ...contextProps.queryParams, ...props.queryParams } as TQueryParams} 324 | queryParamStringifyOptions={{ 325 | ...contextProps.queryParamStringifyOptions, 326 | ...props.queryParamStringifyOptions, 327 | }} 328 | __internal_hasExplicitBase={Boolean(props.base)} 329 | /> 330 | 331 | )} 332 | 333 | ); 334 | } 335 | 336 | export default Mutate; 337 | -------------------------------------------------------------------------------- /src/Poll.tsx: -------------------------------------------------------------------------------- 1 | import merge from "lodash/merge"; 2 | import * as React from "react"; 3 | import equal from "react-fast-compare"; 4 | 5 | import { InjectedProps, RestfulReactConsumer } from "./Context"; 6 | import { GetProps, GetState, Meta as GetComponentMeta } from "./Get"; 7 | import { composeUrl } from "./util/composeUrl"; 8 | import { processResponse } from "./util/processResponse"; 9 | import { constructUrl } from "./util/constructUrl"; 10 | import { IStringifyOptions } from "qs"; 11 | 12 | /** 13 | * Meta information returned from the poll. 14 | */ 15 | interface Meta extends GetComponentMeta { 16 | /** 17 | * The entire response object. 18 | */ 19 | response: Response | null; 20 | } 21 | 22 | /** 23 | * States of the current poll 24 | */ 25 | interface States { 26 | /** 27 | * Is the component currently polling? 28 | */ 29 | polling: PollState["polling"]; 30 | /** 31 | * Is the initial request loading? 32 | */ 33 | loading: PollState["loading"]; 34 | /** 35 | * Has the poll concluded? 36 | */ 37 | finished: PollState["finished"]; 38 | /** 39 | * Is there an error? What is it? 40 | */ 41 | error: PollState["error"]; 42 | } 43 | 44 | /** 45 | * Actions that can be executed within the 46 | * component. 47 | */ 48 | interface Actions { 49 | start: () => void; 50 | stop: () => void; 51 | } 52 | 53 | /** 54 | * Props that can control the Poll component. 55 | */ 56 | export interface PollProps { 57 | /** 58 | * What path are we polling on? 59 | */ 60 | path: GetProps["path"]; 61 | /** 62 | * A function that gets polled data, the current 63 | * states, meta information, and various actions 64 | * that can be executed at the poll-level. 65 | */ 66 | children: (data: TData | null, states: States, actions: Actions, meta: Meta) => React.ReactNode; 67 | /** 68 | * How long do we wait between repeating a request? 69 | * Value in milliseconds. 70 | * 71 | * Defaults to 1000. 72 | */ 73 | interval?: number; 74 | /** 75 | * How long should a request stay open? 76 | * Value in seconds. 77 | * 78 | * Defaults to 60. 79 | */ 80 | wait?: number; 81 | /** 82 | * A stop condition for the poll that expects 83 | * a boolean. 84 | * 85 | * @param data - The data returned from the poll. 86 | * @param response - The full response object. This could be useful in order to stop polling when !response.ok, for example. 87 | */ 88 | until?: (data: TData | null, response: Response | null) => boolean; 89 | /** 90 | * Are we going to wait to start the poll? 91 | * Use this with { start, stop } actions. 92 | */ 93 | lazy?: GetProps["lazy"]; 94 | /** 95 | * Should the data be transformed in any way? 96 | */ 97 | resolve?: (data: any, prevData: TData | null) => TData; 98 | /** 99 | * We can request foreign URLs with this prop. 100 | */ 101 | base?: GetProps["base"]; 102 | /** 103 | * Any options to be passed to this request. 104 | */ 105 | requestOptions?: GetProps["requestOptions"]; 106 | /** 107 | * Query parameters 108 | */ 109 | queryParams?: TQueryParams; 110 | /** 111 | * Query parameter stringify options 112 | */ 113 | queryParamStringifyOptions?: IStringifyOptions; 114 | /** 115 | * Don't send the error to the Provider 116 | */ 117 | localErrorOnly?: boolean; 118 | } 119 | 120 | /** 121 | * The state of the Poll component. This should contain 122 | * implementation details not necessarily exposed to 123 | * consumers. 124 | */ 125 | export interface PollState { 126 | /** 127 | * Are we currently polling? 128 | */ 129 | polling: boolean; 130 | /** 131 | * Have we finished polling? 132 | */ 133 | finished: boolean; 134 | /** 135 | * What was the last response? 136 | */ 137 | lastResponse: Response | null; 138 | /** 139 | * What data are we holding in here? 140 | */ 141 | data: GetState["data"]; 142 | /** 143 | * What data did we had before? 144 | */ 145 | previousData: GetState["data"]; 146 | /** 147 | * Are we loading? 148 | */ 149 | loading: GetState["loading"]; 150 | /** 151 | * Do we currently have an error? 152 | */ 153 | error: GetState["error"]; 154 | /** 155 | * Index of the last polled response. 156 | */ 157 | lastPollIndex?: string; 158 | } 159 | 160 | /** 161 | * The component without context. 162 | */ 163 | class ContextlessPoll extends React.Component< 164 | PollProps & InjectedProps, 165 | Readonly> 166 | > { 167 | public readonly state: Readonly> = { 168 | data: null, 169 | previousData: null, 170 | loading: !this.props.lazy, 171 | lastResponse: null, 172 | polling: !this.props.lazy, 173 | finished: false, 174 | error: null, 175 | }; 176 | 177 | public static defaultProps = { 178 | interval: 1000, 179 | wait: 60, 180 | base: "", 181 | resolve: (data: any) => data, 182 | queryParams: {}, 183 | }; 184 | 185 | private keepPolling = !this.props.lazy; 186 | 187 | /** 188 | * Abort controller to cancel the current fetch query 189 | */ 190 | private abortController = new AbortController(); 191 | private signal = this.abortController.signal; 192 | 193 | private isModified = (response: Response, nextData: TData) => { 194 | if (response.status === 304) { 195 | return false; 196 | } 197 | if (equal(this.state.data, nextData)) { 198 | return false; 199 | } 200 | return true; 201 | }; 202 | 203 | private getRequestOptions = (url: string) => 204 | typeof this.props.requestOptions === "function" 205 | ? this.props.requestOptions(url, "GET") 206 | : this.props.requestOptions || {}; 207 | 208 | // 304 is not a OK status code but is green in Chrome 🤦🏾‍♂️ 209 | private isResponseOk = (response: Response) => response.ok || response.status === 304; 210 | 211 | /** 212 | * This thing does the actual poll. 213 | */ 214 | public cycle = async () => { 215 | // Have we stopped? 216 | if (!this.keepPolling) { 217 | return; // stop. 218 | } 219 | 220 | // Should we stop? 221 | if (this.props.until && this.props.until(this.state.data, this.state.lastResponse)) { 222 | this.stop(); // stop. 223 | return; 224 | } 225 | 226 | // If we should keep going, 227 | const { base, path, interval, wait, onError, onRequest, onResponse } = this.props; 228 | const { lastPollIndex } = this.state; 229 | 230 | const url = constructUrl(base!, path, this.props.queryParams, { 231 | queryParamOptions: this.props.queryParamStringifyOptions, 232 | stripTrailingSlash: true, 233 | }); 234 | 235 | const requestOptions = await this.getRequestOptions(url); 236 | 237 | const request = new Request(url, { 238 | ...requestOptions, 239 | headers: { 240 | Prefer: `wait=${wait}s;${lastPollIndex ? `index=${lastPollIndex}` : ""}`, 241 | ...requestOptions.headers, 242 | }, 243 | }); 244 | if (onRequest) onRequest(request); 245 | 246 | try { 247 | const response = await fetch(request, { signal: this.signal }); 248 | if (onResponse) onResponse(response.clone()); 249 | const { data, responseError } = await processResponse(response); 250 | 251 | if (!this.keepPolling || this.signal.aborted) { 252 | // Early return if we have stopped polling or component was unmounted 253 | // to avoid memory leaks 254 | return; 255 | } 256 | 257 | if (!this.isResponseOk(response) || responseError) { 258 | const error = { 259 | message: `Failed to poll: ${response.status} ${response.statusText}${responseError ? " - " + data : ""}`, 260 | data, 261 | status: response.status, 262 | }; 263 | this.setState({ loading: false, lastResponse: response, error }); 264 | 265 | if (!this.props.localErrorOnly && onError) { 266 | onError(error, () => Promise.resolve(), response); 267 | } 268 | } else if (this.isModified(response, data)) { 269 | this.setState(prevState => ({ 270 | loading: false, 271 | lastResponse: response, 272 | previousData: prevState.data, 273 | data, 274 | error: null, 275 | lastPollIndex: response.headers.get("x-polling-index") || undefined, 276 | })); 277 | } 278 | 279 | // Wait for interval to pass. 280 | await new Promise(resolvePromise => setTimeout(resolvePromise, interval)); 281 | this.cycle(); // Do it all again! 282 | } catch (e) { 283 | // the only error not catched is the `fetch`, this means that we have cancelled the fetch 284 | } 285 | }; 286 | 287 | public start = () => { 288 | this.keepPolling = true; 289 | if (!this.state.polling) { 290 | this.setState(() => ({ polling: true })); // let everyone know we're done here. 291 | } 292 | this.cycle(); 293 | }; 294 | 295 | public stop = () => { 296 | this.keepPolling = false; 297 | this.setState(() => ({ polling: false, finished: true })); // let everyone know we're done here. 298 | }; 299 | 300 | public componentDidMount() { 301 | const { path, lazy } = this.props; 302 | 303 | if (path === undefined) { 304 | throw new Error( 305 | `[restful-react]: You're trying to poll something without a path. Please specify a "path" prop on your Poll component.`, 306 | ); 307 | } 308 | 309 | if (!lazy) { 310 | this.start(); 311 | } 312 | } 313 | 314 | public componentWillUnmount() { 315 | // Cancel the current query 316 | this.abortController.abort(); 317 | 318 | // Stop the polling cycle 319 | this.stop(); 320 | } 321 | 322 | public render() { 323 | const { lastResponse: response, previousData, data, polling, loading, error, finished } = this.state; 324 | const { children, base, path, resolve } = this.props; 325 | 326 | const meta: Meta = { 327 | response, 328 | absolutePath: composeUrl(base!, "", path), 329 | }; 330 | 331 | const states: States = { 332 | polling, 333 | loading, 334 | error, 335 | finished, 336 | }; 337 | 338 | const actions: Actions = { 339 | stop: this.stop, 340 | start: this.start, 341 | }; 342 | // data is parsed only when poll has already resolved so response is defined 343 | const resolvedData = response && resolve ? resolve(data, previousData) : data; 344 | return children(resolvedData, states, actions, meta); 345 | } 346 | } 347 | 348 | function Poll( 349 | props: PollProps, 350 | ) { 351 | // Compose Contexts to allow for URL nesting 352 | return ( 353 | 354 | {contextProps => { 355 | return ( 356 | { 361 | const contextRequestOptions = 362 | typeof contextProps.requestOptions === "function" 363 | ? await contextProps.requestOptions(url, method) 364 | : contextProps.requestOptions || {}; 365 | const propsRequestOptions = 366 | typeof props.requestOptions === "function" 367 | ? await props.requestOptions(url, method) 368 | : props.requestOptions || {}; 369 | 370 | return merge(contextRequestOptions, propsRequestOptions); 371 | }} 372 | queryParamStringifyOptions={{ 373 | ...contextProps.queryParamStringifyOptions, 374 | ...props.queryParamStringifyOptions, 375 | }} 376 | /> 377 | ); 378 | }} 379 | 380 | ); 381 | } 382 | 383 | export default Poll; 384 | -------------------------------------------------------------------------------- /src/Get.tsx: -------------------------------------------------------------------------------- 1 | import { DebounceSettings } from "lodash"; 2 | import debounce from "lodash/debounce"; 3 | import isEqual from "lodash/isEqual"; 4 | import * as React from "react"; 5 | 6 | import RestfulReactProvider, { InjectedProps, RestfulReactConsumer, RestfulReactProviderProps } from "./Context"; 7 | import { composePath, composeUrl } from "./util/composeUrl"; 8 | import { processResponse } from "./util/processResponse"; 9 | import { resolveData } from "./util/resolveData"; 10 | import { constructUrl } from "./util/constructUrl"; 11 | import { IStringifyOptions } from "qs"; 12 | 13 | /** 14 | * A function that resolves returned data from 15 | * a fetch call. 16 | */ 17 | export type ResolveFunction = (data: any) => TData; 18 | 19 | export interface GetDataError { 20 | message: string; 21 | data: TError | string; 22 | status?: number; 23 | } 24 | 25 | /** 26 | * An enumeration of states that a fetchable 27 | * view could possibly have. 28 | */ 29 | export interface States { 30 | /** Is our view currently loading? */ 31 | loading: boolean; 32 | /** Do we have an error in the view? */ 33 | error?: GetState["error"]; 34 | } 35 | 36 | export type GetMethod = () => Promise; 37 | 38 | /** 39 | * An interface of actions that can be performed 40 | * within Get 41 | */ 42 | export interface Actions { 43 | /** Refetches the same path */ 44 | refetch: GetMethod; 45 | } 46 | 47 | /** 48 | * Meta information returned to the fetchable 49 | * view. 50 | */ 51 | export interface Meta { 52 | /** The entire response object passed back from the request. */ 53 | response: Response | null; 54 | /** The absolute path of this request. */ 55 | absolutePath: string; 56 | } 57 | 58 | /** 59 | * Props for the component. 60 | */ 61 | export interface GetProps { 62 | /** 63 | * The path at which to request data, 64 | * typically composed by parent Gets or the RestfulProvider. 65 | */ 66 | path: string; 67 | /** 68 | * @private This is an internal implementation detail in restful-react, not meant to be used externally. 69 | * This helps restful-react correctly override `path`s when a new `base` property is provided. 70 | */ 71 | __internal_hasExplicitBase?: boolean; 72 | /** 73 | * A function that recieves the returned, resolved 74 | * data. 75 | * 76 | * @param data - data returned from the request. 77 | * @param actions - a key/value map of HTTP verbs, aliasing destroy to DELETE. 78 | */ 79 | children: (data: TData | null, states: States, actions: Actions, meta: Meta) => React.ReactNode; 80 | /** Options passed into the fetch call. */ 81 | requestOptions?: RestfulReactProviderProps["requestOptions"]; 82 | /** 83 | * Path parameters 84 | */ 85 | pathParams?: TPathParams; 86 | /** 87 | * Query parameters 88 | */ 89 | queryParams?: TQueryParams; 90 | /** 91 | * Query parameter stringify options 92 | */ 93 | queryParamStringifyOptions?: IStringifyOptions; 94 | /** 95 | * Don't send the error to the Provider 96 | */ 97 | localErrorOnly?: boolean; 98 | /** 99 | * A function to resolve data return from the backend, most typically 100 | * used when the backend response needs to be adapted in some way. 101 | */ 102 | resolve?: ResolveFunction; 103 | /** 104 | * Should we wait until we have data before rendering? 105 | * This is useful in cases where data is available too quickly 106 | * to display a spinner or some type of loading state. 107 | */ 108 | wait?: boolean; 109 | /** 110 | * Should we fetch data at a later stage? 111 | */ 112 | lazy?: boolean; 113 | /** 114 | * An escape hatch and an alternative to `path` when you'd like 115 | * to fetch from an entirely different URL. 116 | * 117 | */ 118 | base?: string; 119 | /** 120 | * The accumulated path from each level of parent GETs 121 | * taking the absolute and relative nature of each path into consideration 122 | */ 123 | parentPath?: string; 124 | /** 125 | * How long do we wait between subsequent requests? 126 | * Uses [lodash's debounce](https://lodash.com/docs/4.17.10#debounce) under the hood. 127 | */ 128 | debounce?: 129 | | { 130 | wait?: number; 131 | options: DebounceSettings; 132 | } 133 | | boolean 134 | | number; 135 | } 136 | 137 | /** 138 | * State for the component. These 139 | * are implementation details and should be 140 | * hidden from any consumers. 141 | */ 142 | export interface GetState { 143 | data: TData | null; 144 | response: Response | null; 145 | error: GetDataError | null; 146 | loading: boolean; 147 | } 148 | 149 | /** 150 | * The component without Context. This 151 | * is a named class because it is useful in 152 | * debugging. 153 | */ 154 | class ContextlessGet extends React.Component< 155 | GetProps & InjectedProps, 156 | Readonly> 157 | > { 158 | constructor(props: GetProps & InjectedProps) { 159 | super(props); 160 | 161 | if (typeof props.debounce === "object") { 162 | this.fetch = debounce(this.fetch, props.debounce.wait, props.debounce.options); 163 | } else if (typeof props.debounce === "number") { 164 | this.fetch = debounce(this.fetch, props.debounce); 165 | } else if (props.debounce) { 166 | this.fetch = debounce(this.fetch); 167 | } 168 | } 169 | 170 | /** 171 | * Abort controller to cancel the current fetch query 172 | */ 173 | private abortController = new AbortController(); 174 | private signal = this.abortController.signal; 175 | 176 | public readonly state: Readonly> = { 177 | data: null, // Means we don't _yet_ have data. 178 | response: null, 179 | loading: !this.props.lazy, 180 | error: null, 181 | }; 182 | 183 | public static defaultProps = { 184 | base: "", 185 | parentPath: "", 186 | resolve: (unresolvedData: any) => unresolvedData, 187 | queryParams: {}, 188 | }; 189 | 190 | public componentDidMount() { 191 | if (!this.props.lazy) { 192 | this.fetch(); 193 | } 194 | } 195 | 196 | public componentDidUpdate(prevProps: GetProps) { 197 | const { base, parentPath, path, resolve, queryParams, requestOptions } = prevProps; 198 | if ( 199 | base !== this.props.base || 200 | parentPath !== this.props.parentPath || 201 | path !== this.props.path || 202 | !isEqual(queryParams, this.props.queryParams) || 203 | // both `resolve` props need to _exist_ first, and then be equivalent. 204 | (resolve && this.props.resolve && resolve.toString() !== this.props.resolve.toString()) || 205 | (requestOptions && 206 | this.props.requestOptions && 207 | requestOptions.toString() !== this.props.requestOptions.toString()) 208 | ) { 209 | if (!this.props.lazy) { 210 | this.fetch(); 211 | } 212 | } 213 | } 214 | 215 | public componentWillUnmount() { 216 | this.abortController.abort(); 217 | } 218 | 219 | public getRequestOptions = async ( 220 | url: string, 221 | extraOptions?: Partial, 222 | extraHeaders?: boolean | { [key: string]: string }, 223 | ) => { 224 | const { requestOptions } = this.props; 225 | 226 | if (typeof requestOptions === "function") { 227 | const options = (await requestOptions(url, "GET")) || {}; 228 | return { 229 | ...extraOptions, 230 | ...options, 231 | headers: new Headers({ 232 | ...(typeof extraHeaders !== "boolean" ? extraHeaders : {}), 233 | ...(extraOptions || {}).headers, 234 | ...options.headers, 235 | }), 236 | }; 237 | } 238 | 239 | return { 240 | ...extraOptions, 241 | ...requestOptions, 242 | headers: new Headers({ 243 | ...(typeof extraHeaders !== "boolean" ? extraHeaders : {}), 244 | ...(extraOptions || {}).headers, 245 | ...(requestOptions || {}).headers, 246 | }), 247 | }; 248 | }; 249 | 250 | public fetch = async (requestPath?: string, thisRequestOptions?: RequestInit) => { 251 | const { base, __internal_hasExplicitBase, parentPath, path, resolve, onError, onRequest, onResponse } = this.props; 252 | 253 | if (this.state.error || !this.state.loading) { 254 | this.setState(() => ({ error: null, loading: true })); 255 | } 256 | 257 | const makeRequestPath = () => { 258 | const concatPath = __internal_hasExplicitBase ? path : composePath(parentPath, path); 259 | 260 | return constructUrl(base!, concatPath, this.props.queryParams, { 261 | stripTrailingSlash: true, 262 | queryParamOptions: this.props.queryParamStringifyOptions, 263 | }); 264 | }; 265 | 266 | const request = new Request(makeRequestPath(), await this.getRequestOptions(makeRequestPath(), thisRequestOptions)); 267 | if (onRequest) onRequest(request); 268 | try { 269 | const response = await fetch(request, { signal: this.signal }); 270 | const originalResponse = response.clone(); 271 | if (onResponse) onResponse(response.clone()); 272 | const { data, responseError } = await processResponse(response); 273 | 274 | // avoid state updates when component has been unmounted 275 | if (this.signal.aborted) { 276 | return; 277 | } 278 | 279 | if (!response.ok || responseError) { 280 | const error = { 281 | message: `Failed to fetch: ${response.status} ${response.statusText}${responseError ? " - " + data : ""}`, 282 | data, 283 | status: response.status, 284 | }; 285 | 286 | this.setState({ 287 | loading: false, 288 | error, 289 | data: null, 290 | response: originalResponse, 291 | }); 292 | 293 | if (!this.props.localErrorOnly && onError) { 294 | onError(error, () => this.fetch(requestPath, thisRequestOptions), response); 295 | } 296 | 297 | return null; 298 | } 299 | 300 | const resolved = await resolveData({ data, resolve }); 301 | 302 | this.setState({ loading: false, data: resolved.data, error: resolved.error, response: originalResponse }); 303 | return data; 304 | } catch (e) { 305 | // avoid state updates when component has been unmounted 306 | // and when fetch/processResponse threw an error 307 | if (this.signal.aborted) { 308 | return; 309 | } 310 | 311 | this.setState({ 312 | loading: false, 313 | data: null, 314 | error: { 315 | message: `Failed to fetch: ${e.message}`, 316 | data: e, 317 | }, 318 | }); 319 | } 320 | }; 321 | 322 | public render() { 323 | const { children, wait, path, base, parentPath } = this.props; 324 | const { data, error, loading, response } = this.state; 325 | 326 | if (wait && data === null && !error) { 327 | return <>; // Show nothing until we have data. 328 | } 329 | 330 | return children( 331 | data, 332 | { loading, error }, 333 | { refetch: this.fetch }, 334 | { response, absolutePath: composeUrl(base!, parentPath!, path) }, 335 | ); 336 | } 337 | } 338 | 339 | /** 340 | * The component _with_ context. 341 | * Context is used to compose path props, 342 | * and to maintain the base property against 343 | * which all requests will be made. 344 | * 345 | * We compose Consumers immediately with providers 346 | * in order to provide new `parentPath` props that contain 347 | * a segment of the path, creating composable URLs. 348 | */ 349 | function Get( 350 | props: GetProps, 351 | ) { 352 | return ( 353 | 354 | {contextProps => ( 355 | 356 | 366 | 367 | )} 368 | 369 | ); 370 | } 371 | 372 | export default Get; 373 | -------------------------------------------------------------------------------- /src/scripts/tests/__snapshots__/import-open-api.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`scripts/import-open-api should parse correctly petstore-expanded.yaml (with encode function) 1`] = ` 4 | "/* Generated by restful-react */ 5 | 6 | import React from \\"react\\"; 7 | import { Get, GetProps, useGet, UseGetProps, Mutate, MutateProps, useMutate, UseMutateProps } from \\"restful-react\\"; 8 | (uriComponent: string | number | boolean) => { 9 | return encodeURIComponent(uriComponent).replace( 10 | /[!'()*]/g, 11 | (c: string) => \`%\${c.charCodeAt(0).toString(16)}\`, 12 | ); 13 | }; 14 | 15 | const encodingTagFactory = (encodingFn: typeof encodeURIComponent) => ( 16 | strings: TemplateStringsArray, 17 | ...params: (string | number | boolean)[] 18 | ) => 19 | strings.reduce( 20 | (accumulatedPath, pathPart, idx) => 21 | \`\${accumulatedPath}\${pathPart}\${ 22 | idx < params.length ? encodingFn(params[idx]) : '' 23 | }\`, 24 | '', 25 | ); 26 | 27 | const encode = encodingTagFactory(encodingFn); 28 | 29 | export const SPEC_VERSION = \\"1.0.0\\"; 30 | /** 31 | * A pet. 32 | */ 33 | export type Pet = NewPet & { 34 | id: number; 35 | }; 36 | 37 | /** 38 | * A new pet. 39 | */ 40 | export interface NewPet { 41 | name: string; 42 | tag?: string; 43 | } 44 | 45 | /** 46 | * A discriminator example. 47 | */ 48 | export type CatOrDog = Cat | Dog; 49 | 50 | /** 51 | * A cat, meow. 52 | */ 53 | export interface Cat { 54 | type: \\"cat\\"; 55 | breed: \\"labrador\\" | \\"carlin\\" | \\"beagle\\"; 56 | } 57 | 58 | /** 59 | * A dog, wooof. 60 | */ 61 | export interface Dog { 62 | type: \\"dog\\"; 63 | breed: \\"saimois\\" | \\"bengal\\" | \\"british shorthair\\"; 64 | } 65 | 66 | /** 67 | * An error :( 68 | */ 69 | export interface Error { 70 | code: number; 71 | message: string; 72 | } 73 | 74 | /** 75 | * Request description 76 | */ 77 | export interface Request { 78 | action?: (\\"create\\" | \\"read\\" | \\"update\\" | \\"delete\\")[]; 79 | } 80 | 81 | export type UpdatePetRequestRequestBody = NewPet; 82 | 83 | export interface FindPetsQueryParams { 84 | /** 85 | * tags to filter by 86 | */ 87 | tags?: string[]; 88 | /** 89 | * maximum number of results to return 90 | */ 91 | limit?: number; 92 | } 93 | 94 | export type FindPetsProps = Omit, \\"path\\">; 95 | 96 | /** 97 | * Returns all pets from the system that the user has access to 98 | * Nam sed condimentum est. Maecenas tempor sagittis sapien, nec rhoncus sem sagittis sit amet. Aenean at gravida augue, ac iaculis sem. Curabitur odio lorem, ornare eget elementum nec, cursus id lectus. Duis mi turpis, pulvinar ac eros ac, tincidunt varius justo. In hac habitasse platea dictumst. Integer at adipiscing ante, a sagittis ligula. Aenean pharetra tempor ante molestie imperdiet. Vivamus id aliquam diam. Cras quis velit non tortor eleifend sagittis. Praesent at enim pharetra urna volutpat venenatis eget eget mauris. In eleifend fermentum facilisis. Praesent enim enim, gravida ac sodales sed, placerat id erat. Suspendisse lacus dolor, consectetur non augue vel, vehicula interdum libero. Morbi euismod sagittis libero sed lacinia. 99 | * 100 | * Sed tempus felis lobortis leo pulvinar rutrum. Nam mattis velit nisl, eu condimentum ligula luctus nec. Phasellus semper velit eget aliquet faucibus. In a mattis elit. Phasellus vel urna viverra, condimentum lorem id, rhoncus nibh. Ut pellentesque posuere elementum. Sed a varius odio. Morbi rhoncus ligula libero, vel eleifend nunc tristique vitae. Fusce et sem dui. Aenean nec scelerisque tortor. Fusce malesuada accumsan magna vel tempus. Quisque mollis felis eu dolor tristique, sit amet auctor felis gravida. Sed libero lorem, molestie sed nisl in, accumsan tempor nisi. Fusce sollicitudin massa ut lacinia mattis. Sed vel eleifend lorem. Pellentesque vitae felis pretium, pulvinar elit eu, euismod sapien. 101 | * 102 | */ 103 | export const FindPets = (props: FindPetsProps) => ( 104 | 105 | path={encode\`/pets\`} 106 | 107 | {...props} 108 | /> 109 | ); 110 | 111 | export type UseFindPetsProps = Omit, \\"path\\">; 112 | 113 | /** 114 | * Returns all pets from the system that the user has access to 115 | * Nam sed condimentum est. Maecenas tempor sagittis sapien, nec rhoncus sem sagittis sit amet. Aenean at gravida augue, ac iaculis sem. Curabitur odio lorem, ornare eget elementum nec, cursus id lectus. Duis mi turpis, pulvinar ac eros ac, tincidunt varius justo. In hac habitasse platea dictumst. Integer at adipiscing ante, a sagittis ligula. Aenean pharetra tempor ante molestie imperdiet. Vivamus id aliquam diam. Cras quis velit non tortor eleifend sagittis. Praesent at enim pharetra urna volutpat venenatis eget eget mauris. In eleifend fermentum facilisis. Praesent enim enim, gravida ac sodales sed, placerat id erat. Suspendisse lacus dolor, consectetur non augue vel, vehicula interdum libero. Morbi euismod sagittis libero sed lacinia. 116 | * 117 | * Sed tempus felis lobortis leo pulvinar rutrum. Nam mattis velit nisl, eu condimentum ligula luctus nec. Phasellus semper velit eget aliquet faucibus. In a mattis elit. Phasellus vel urna viverra, condimentum lorem id, rhoncus nibh. Ut pellentesque posuere elementum. Sed a varius odio. Morbi rhoncus ligula libero, vel eleifend nunc tristique vitae. Fusce et sem dui. Aenean nec scelerisque tortor. Fusce malesuada accumsan magna vel tempus. Quisque mollis felis eu dolor tristique, sit amet auctor felis gravida. Sed libero lorem, molestie sed nisl in, accumsan tempor nisi. Fusce sollicitudin massa ut lacinia mattis. Sed vel eleifend lorem. Pellentesque vitae felis pretium, pulvinar elit eu, euismod sapien. 118 | * 119 | */ 120 | export const useFindPets = (props: UseFindPetsProps) => useGet(encode\`/pets\`, props); 121 | 122 | 123 | export type AddPetProps = Omit, \\"path\\" | \\"verb\\">; 124 | 125 | /** 126 | * Creates a new pet in the store. Duplicates are allowed 127 | */ 128 | export const AddPet = (props: AddPetProps) => ( 129 | 130 | verb=\\"POST\\" 131 | path={encode\`/pets\`} 132 | 133 | {...props} 134 | /> 135 | ); 136 | 137 | export type UseAddPetProps = Omit, \\"path\\" | \\"verb\\">; 138 | 139 | /** 140 | * Creates a new pet in the store. Duplicates are allowed 141 | */ 142 | export const useAddPet = (props: UseAddPetProps) => useMutate(\\"POST\\", encode\`/pets\`, props); 143 | 144 | 145 | export interface FindPetByIdPathParams { 146 | /** 147 | * ID of pet to fetch 148 | */ 149 | id: number 150 | } 151 | 152 | export type FindPetByIdProps = Omit, \\"path\\"> & FindPetByIdPathParams; 153 | 154 | /** 155 | * Returns a user based on a single ID, if the user does not have access to the pet 156 | */ 157 | export const FindPetById = ({id, ...props}: FindPetByIdProps) => ( 158 | 159 | path={encode\`/pets/\${id}\`} 160 | 161 | {...props} 162 | /> 163 | ); 164 | 165 | export type UseFindPetByIdProps = Omit, \\"path\\"> & FindPetByIdPathParams; 166 | 167 | /** 168 | * Returns a user based on a single ID, if the user does not have access to the pet 169 | */ 170 | export const useFindPetById = ({id, ...props}: UseFindPetByIdProps) => useGet((paramsInPath: FindPetByIdPathParams) => encode\`/pets/\${paramsInPath.id}\`, { pathParams: { id }, ...props }); 171 | 172 | 173 | export type DeletePetProps = Omit, \\"path\\" | \\"verb\\">; 174 | 175 | /** 176 | * deletes a single pet based on the ID supplied 177 | */ 178 | export const DeletePet = (props: DeletePetProps) => ( 179 | 180 | verb=\\"DELETE\\" 181 | path={encode\`/pets\`} 182 | pathInlineBodyEncode={encodingFn} 183 | {...props} 184 | /> 185 | ); 186 | 187 | export type UseDeletePetProps = Omit, \\"path\\" | \\"verb\\">; 188 | 189 | /** 190 | * deletes a single pet based on the ID supplied 191 | */ 192 | export const useDeletePet = (props: UseDeletePetProps) => useMutate(\\"DELETE\\", encode\`/pets\`, { pathInlineBodyEncode: encodingFn, ...props }); 193 | 194 | 195 | export interface UpdatePetPathParams { 196 | /** 197 | * ID of pet to update 198 | */ 199 | id: number 200 | } 201 | 202 | export type UpdatePetProps = Omit, \\"path\\" | \\"verb\\"> & UpdatePetPathParams; 203 | 204 | /** 205 | * Updates a pet in the store. 206 | */ 207 | export const UpdatePet = ({id, ...props}: UpdatePetProps) => ( 208 | 209 | verb=\\"PATCH\\" 210 | path={encode\`/pets/\${id}\`} 211 | 212 | {...props} 213 | /> 214 | ); 215 | 216 | export type UseUpdatePetProps = Omit, \\"path\\" | \\"verb\\"> & UpdatePetPathParams; 217 | 218 | /** 219 | * Updates a pet in the store. 220 | */ 221 | export const useUpdatePet = ({id, ...props}: UseUpdatePetProps) => useMutate(\\"PATCH\\", (paramsInPath: UpdatePetPathParams) => encode\`/pets/\${paramsInPath.id}\`, { pathParams: { id }, ...props }); 222 | 223 | " 224 | `; 225 | 226 | exports[`scripts/import-open-api should parse correctly petstore-expanded.yaml (without react component) 1`] = ` 227 | "/* Generated by restful-react */ 228 | 229 | export const SPEC_VERSION = \\"1.0.0\\"; 230 | /** 231 | * A pet. 232 | */ 233 | export type Pet = NewPet & { 234 | id: number; 235 | }; 236 | 237 | /** 238 | * A new pet. 239 | */ 240 | export interface NewPet { 241 | name: string; 242 | tag?: string; 243 | } 244 | 245 | /** 246 | * A discriminator example. 247 | */ 248 | export type CatOrDog = Cat | Dog; 249 | 250 | /** 251 | * A cat, meow. 252 | */ 253 | export interface Cat { 254 | type: \\"cat\\"; 255 | breed: \\"labrador\\" | \\"carlin\\" | \\"beagle\\"; 256 | } 257 | 258 | /** 259 | * A dog, wooof. 260 | */ 261 | export interface Dog { 262 | type: \\"dog\\"; 263 | breed: \\"saimois\\" | \\"bengal\\" | \\"british shorthair\\"; 264 | } 265 | 266 | /** 267 | * An error :( 268 | */ 269 | export interface Error { 270 | code: number; 271 | message: string; 272 | } 273 | 274 | /** 275 | * Request description 276 | */ 277 | export interface Request { 278 | action?: (\\"create\\" | \\"read\\" | \\"update\\" | \\"delete\\")[]; 279 | } 280 | 281 | export type UpdatePetRequestRequestBody = NewPet; 282 | 283 | export interface FindPetsQueryParams { 284 | /** 285 | * tags to filter by 286 | */ 287 | tags?: string[]; 288 | /** 289 | * maximum number of results to return 290 | */ 291 | limit?: number; 292 | } 293 | 294 | 295 | 296 | export interface FindPetByIdPathParams { 297 | /** 298 | * ID of pet to fetch 299 | */ 300 | id: number 301 | } 302 | 303 | 304 | export interface DeletePetPathParams { 305 | /** 306 | * ID of pet to delete 307 | */ 308 | id: number 309 | } 310 | 311 | 312 | export interface UpdatePetPathParams { 313 | /** 314 | * ID of pet to update 315 | */ 316 | id: number 317 | } 318 | 319 | " 320 | `; 321 | 322 | exports[`scripts/import-open-api should parse correctly petstore-expanded.yaml 1`] = ` 323 | "/* Generated by restful-react */ 324 | 325 | import React from \\"react\\"; 326 | import { Get, GetProps, useGet, UseGetProps, Mutate, MutateProps, useMutate, UseMutateProps } from \\"restful-react\\"; 327 | export const SPEC_VERSION = \\"1.0.0\\"; 328 | /** 329 | * A pet. 330 | */ 331 | export type Pet = NewPet & { 332 | id: number; 333 | }; 334 | 335 | /** 336 | * A new pet. 337 | */ 338 | export interface NewPet { 339 | name: string; 340 | tag?: string; 341 | } 342 | 343 | /** 344 | * A discriminator example. 345 | */ 346 | export type CatOrDog = Cat | Dog; 347 | 348 | /** 349 | * A cat, meow. 350 | */ 351 | export interface Cat { 352 | type: \\"cat\\"; 353 | breed: \\"labrador\\" | \\"carlin\\" | \\"beagle\\"; 354 | } 355 | 356 | /** 357 | * A dog, wooof. 358 | */ 359 | export interface Dog { 360 | type: \\"dog\\"; 361 | breed: \\"saimois\\" | \\"bengal\\" | \\"british shorthair\\"; 362 | } 363 | 364 | /** 365 | * An error :( 366 | */ 367 | export interface Error { 368 | code: number; 369 | message: string; 370 | } 371 | 372 | /** 373 | * Request description 374 | */ 375 | export interface Request { 376 | action?: (\\"create\\" | \\"read\\" | \\"update\\" | \\"delete\\")[]; 377 | } 378 | 379 | export type UpdatePetRequestRequestBody = NewPet; 380 | 381 | export interface FindPetsQueryParams { 382 | /** 383 | * tags to filter by 384 | */ 385 | tags?: string[]; 386 | /** 387 | * maximum number of results to return 388 | */ 389 | limit?: number; 390 | } 391 | 392 | export type FindPetsProps = Omit, \\"path\\">; 393 | 394 | /** 395 | * Returns all pets from the system that the user has access to 396 | * Nam sed condimentum est. Maecenas tempor sagittis sapien, nec rhoncus sem sagittis sit amet. Aenean at gravida augue, ac iaculis sem. Curabitur odio lorem, ornare eget elementum nec, cursus id lectus. Duis mi turpis, pulvinar ac eros ac, tincidunt varius justo. In hac habitasse platea dictumst. Integer at adipiscing ante, a sagittis ligula. Aenean pharetra tempor ante molestie imperdiet. Vivamus id aliquam diam. Cras quis velit non tortor eleifend sagittis. Praesent at enim pharetra urna volutpat venenatis eget eget mauris. In eleifend fermentum facilisis. Praesent enim enim, gravida ac sodales sed, placerat id erat. Suspendisse lacus dolor, consectetur non augue vel, vehicula interdum libero. Morbi euismod sagittis libero sed lacinia. 397 | * 398 | * Sed tempus felis lobortis leo pulvinar rutrum. Nam mattis velit nisl, eu condimentum ligula luctus nec. Phasellus semper velit eget aliquet faucibus. In a mattis elit. Phasellus vel urna viverra, condimentum lorem id, rhoncus nibh. Ut pellentesque posuere elementum. Sed a varius odio. Morbi rhoncus ligula libero, vel eleifend nunc tristique vitae. Fusce et sem dui. Aenean nec scelerisque tortor. Fusce malesuada accumsan magna vel tempus. Quisque mollis felis eu dolor tristique, sit amet auctor felis gravida. Sed libero lorem, molestie sed nisl in, accumsan tempor nisi. Fusce sollicitudin massa ut lacinia mattis. Sed vel eleifend lorem. Pellentesque vitae felis pretium, pulvinar elit eu, euismod sapien. 399 | * 400 | */ 401 | export const FindPets = (props: FindPetsProps) => ( 402 | 403 | path={\`/pets\`} 404 | 405 | {...props} 406 | /> 407 | ); 408 | 409 | export type UseFindPetsProps = Omit, \\"path\\">; 410 | 411 | /** 412 | * Returns all pets from the system that the user has access to 413 | * Nam sed condimentum est. Maecenas tempor sagittis sapien, nec rhoncus sem sagittis sit amet. Aenean at gravida augue, ac iaculis sem. Curabitur odio lorem, ornare eget elementum nec, cursus id lectus. Duis mi turpis, pulvinar ac eros ac, tincidunt varius justo. In hac habitasse platea dictumst. Integer at adipiscing ante, a sagittis ligula. Aenean pharetra tempor ante molestie imperdiet. Vivamus id aliquam diam. Cras quis velit non tortor eleifend sagittis. Praesent at enim pharetra urna volutpat venenatis eget eget mauris. In eleifend fermentum facilisis. Praesent enim enim, gravida ac sodales sed, placerat id erat. Suspendisse lacus dolor, consectetur non augue vel, vehicula interdum libero. Morbi euismod sagittis libero sed lacinia. 414 | * 415 | * Sed tempus felis lobortis leo pulvinar rutrum. Nam mattis velit nisl, eu condimentum ligula luctus nec. Phasellus semper velit eget aliquet faucibus. In a mattis elit. Phasellus vel urna viverra, condimentum lorem id, rhoncus nibh. Ut pellentesque posuere elementum. Sed a varius odio. Morbi rhoncus ligula libero, vel eleifend nunc tristique vitae. Fusce et sem dui. Aenean nec scelerisque tortor. Fusce malesuada accumsan magna vel tempus. Quisque mollis felis eu dolor tristique, sit amet auctor felis gravida. Sed libero lorem, molestie sed nisl in, accumsan tempor nisi. Fusce sollicitudin massa ut lacinia mattis. Sed vel eleifend lorem. Pellentesque vitae felis pretium, pulvinar elit eu, euismod sapien. 416 | * 417 | */ 418 | export const useFindPets = (props: UseFindPetsProps) => useGet(\`/pets\`, props); 419 | 420 | 421 | export type AddPetProps = Omit, \\"path\\" | \\"verb\\">; 422 | 423 | /** 424 | * Creates a new pet in the store. Duplicates are allowed 425 | */ 426 | export const AddPet = (props: AddPetProps) => ( 427 | 428 | verb=\\"POST\\" 429 | path={\`/pets\`} 430 | 431 | {...props} 432 | /> 433 | ); 434 | 435 | export type UseAddPetProps = Omit, \\"path\\" | \\"verb\\">; 436 | 437 | /** 438 | * Creates a new pet in the store. Duplicates are allowed 439 | */ 440 | export const useAddPet = (props: UseAddPetProps) => useMutate(\\"POST\\", \`/pets\`, props); 441 | 442 | 443 | export interface FindPetByIdPathParams { 444 | /** 445 | * ID of pet to fetch 446 | */ 447 | id: number 448 | } 449 | 450 | export type FindPetByIdProps = Omit, \\"path\\"> & FindPetByIdPathParams; 451 | 452 | /** 453 | * Returns a user based on a single ID, if the user does not have access to the pet 454 | */ 455 | export const FindPetById = ({id, ...props}: FindPetByIdProps) => ( 456 | 457 | path={\`/pets/\${id}\`} 458 | 459 | {...props} 460 | /> 461 | ); 462 | 463 | export type UseFindPetByIdProps = Omit, \\"path\\"> & FindPetByIdPathParams; 464 | 465 | /** 466 | * Returns a user based on a single ID, if the user does not have access to the pet 467 | */ 468 | export const useFindPetById = ({id, ...props}: UseFindPetByIdProps) => useGet((paramsInPath: FindPetByIdPathParams) => \`/pets/\${paramsInPath.id}\`, { pathParams: { id }, ...props }); 469 | 470 | 471 | export type DeletePetProps = Omit, \\"path\\" | \\"verb\\">; 472 | 473 | /** 474 | * deletes a single pet based on the ID supplied 475 | */ 476 | export const DeletePet = (props: DeletePetProps) => ( 477 | 478 | verb=\\"DELETE\\" 479 | path={\`/pets\`} 480 | 481 | {...props} 482 | /> 483 | ); 484 | 485 | export type UseDeletePetProps = Omit, \\"path\\" | \\"verb\\">; 486 | 487 | /** 488 | * deletes a single pet based on the ID supplied 489 | */ 490 | export const useDeletePet = (props: UseDeletePetProps) => useMutate(\\"DELETE\\", \`/pets\`, { ...props }); 491 | 492 | 493 | export interface UpdatePetPathParams { 494 | /** 495 | * ID of pet to update 496 | */ 497 | id: number 498 | } 499 | 500 | export type UpdatePetProps = Omit, \\"path\\" | \\"verb\\"> & UpdatePetPathParams; 501 | 502 | /** 503 | * Updates a pet in the store. 504 | */ 505 | export const UpdatePet = ({id, ...props}: UpdatePetProps) => ( 506 | 507 | verb=\\"PATCH\\" 508 | path={\`/pets/\${id}\`} 509 | 510 | {...props} 511 | /> 512 | ); 513 | 514 | export type UseUpdatePetProps = Omit, \\"path\\" | \\"verb\\"> & UpdatePetPathParams; 515 | 516 | /** 517 | * Updates a pet in the store. 518 | */ 519 | export const useUpdatePet = ({id, ...props}: UseUpdatePetProps) => useMutate(\\"PATCH\\", (paramsInPath: UpdatePetPathParams) => \`/pets/\${paramsInPath.id}\`, { pathParams: { id }, ...props }); 520 | 521 | " 522 | `; 523 | -------------------------------------------------------------------------------- /src/Poll.test.tsx: -------------------------------------------------------------------------------- 1 | import "@testing-library/jest-dom/extend-expect"; 2 | import { cleanup, render, wait } from "@testing-library/react"; 3 | import "isomorphic-fetch"; 4 | import nock from "nock"; 5 | import React from "react"; 6 | 7 | import { Poll, RestfulProvider } from "./index"; 8 | 9 | describe("Poll", () => { 10 | afterEach(() => { 11 | cleanup(); 12 | nock.cleanAll(); 13 | }); 14 | 15 | describe("classic usage", () => { 16 | it("should call the url set in provider", async () => { 17 | nock("https://my-awesome-api.fake", { 18 | reqheaders: { 19 | prefer: "wait=60s;", 20 | }, 21 | }) 22 | .get("/") 23 | .reply(200, { data: "hello" }, { "x-polling-index": "1" }); 24 | 25 | nock("https://my-awesome-api.fake", { 26 | reqheaders: { 27 | prefer: "wait=60s;index=1", 28 | }, 29 | }) 30 | .get("/") 31 | .reply(200, { data: "hello" }, { "x-polling-index": "2" }); 32 | 33 | const children = jest.fn(); 34 | children.mockReturnValue(
); 35 | 36 | render( 37 | 38 | {children} 39 | , 40 | ); 41 | 42 | await wait(() => expect(children.mock.calls.length).toBe(2)); 43 | }); 44 | 45 | it("should compose the url with the base", async () => { 46 | nock("https://my-awesome-api.fake", { 47 | reqheaders: { 48 | prefer: "wait=60s;", 49 | }, 50 | }) 51 | .get("/plop") 52 | .reply(200, { data: "hello" }, { "x-polling-index": "1" }); 53 | 54 | nock("https://my-awesome-api.fake", { 55 | reqheaders: { 56 | prefer: "wait=60s;index=1", 57 | }, 58 | }) 59 | .get("/plop") 60 | .reply(200, { data: "hello" }, { "x-polling-index": "2" }); 61 | 62 | const children = jest.fn(); 63 | children.mockReturnValue(
); 64 | 65 | render( 66 | 67 | {children} 68 | , 69 | ); 70 | 71 | await wait(() => expect(children.mock.calls.length).toBe(2)); 72 | }); 73 | 74 | it("should set loading to `true` on mount", async () => { 75 | nock("https://my-awesome-api.fake", { 76 | reqheaders: { 77 | prefer: "wait=60s;", 78 | }, 79 | }) 80 | .get("/") 81 | .reply(200, { data: "hello" }, { "x-polling-index": "1" }); 82 | 83 | nock("https://my-awesome-api.fake", { 84 | reqheaders: { 85 | prefer: "wait=60s;index=1", 86 | }, 87 | }) 88 | .get("/") 89 | .reply(200, { data: "hello" }, { "x-polling-index": "2" }); 90 | 91 | const children = jest.fn(); 92 | children.mockReturnValue(
); 93 | 94 | render( 95 | 96 | {children} 97 | , 98 | ); 99 | 100 | await wait(() => expect(children.mock.calls.length).toBe(2)); 101 | expect(children.mock.calls[0][1].loading).toEqual(true); 102 | }); 103 | 104 | it("should set loading to `false` on data", async () => { 105 | nock("https://my-awesome-api.fake", { 106 | reqheaders: { 107 | prefer: "wait=60s;", 108 | }, 109 | }) 110 | .get("/") 111 | .reply(200, { data: "hello" }, { "x-polling-index": "1" }); 112 | 113 | nock("https://my-awesome-api.fake", { 114 | reqheaders: { 115 | prefer: "wait=60s;index=1", 116 | }, 117 | }) 118 | .get("/") 119 | .reply(200, { data: "hello" }, { "x-polling-index": "2" }); 120 | 121 | const children = jest.fn(); 122 | children.mockReturnValue(
); 123 | 124 | render( 125 | 126 | {children} 127 | , 128 | ); 129 | 130 | await wait(() => expect(children.mock.calls.length).toBe(2)); 131 | expect(children.mock.calls[1][1].loading).toEqual(false); 132 | }); 133 | 134 | it("should send data on data", async () => { 135 | nock("https://my-awesome-api.fake", { 136 | reqheaders: { 137 | prefer: "wait=0s;", 138 | }, 139 | }) 140 | .get("/") 141 | .reply(200, { data: "hello" }, { "x-polling-index": "1" }); 142 | 143 | nock("https://my-awesome-api.fake", { 144 | reqheaders: { 145 | prefer: "wait=0s;index=1", 146 | }, 147 | }) 148 | .get("/") 149 | .reply(200, { data: "hello" }, { "x-polling-index": "2" }); 150 | 151 | const children = jest.fn(); 152 | children.mockReturnValue(
); 153 | 154 | render( 155 | 156 | 157 | {children} 158 | 159 | , 160 | ); 161 | 162 | await wait(() => expect(children.mock.calls.length).toBe(2)); 163 | expect(children.mock.calls[1][0]).toEqual({ data: "hello" }); 164 | }); 165 | 166 | it("should update data if the response change", async () => { 167 | nock("https://my-awesome-api.fake", { 168 | reqheaders: { 169 | prefer: "wait=0s;", 170 | }, 171 | }) 172 | .get("/") 173 | .reply(200, { data: "hello" }, { "x-polling-index": "1" }); 174 | 175 | nock("https://my-awesome-api.fake", { 176 | reqheaders: { 177 | prefer: "wait=0s;index=1", 178 | }, 179 | }) 180 | .get("/") 181 | .reply(200, { data: "hello you" }, { "x-polling-index": "2" }); 182 | 183 | const children = jest.fn(); 184 | children.mockReturnValue(
); 185 | 186 | render( 187 | 188 | 189 | {children} 190 | 191 | , 192 | ); 193 | 194 | await wait(() => expect(children.mock.calls.length).toBe(3)); 195 | expect(children.mock.calls[2][0]).toEqual({ data: "hello you" }); 196 | }); 197 | 198 | it("should stop polling if no polling index returned", async () => { 199 | // render 1: loading 200 | nock("https://my-awesome-api.fake", { 201 | reqheaders: { 202 | prefer: "wait=1s;", 203 | }, 204 | }) 205 | .get("/") 206 | .reply(200, { data: "hello" }, { "x-polling-index": "1" }); 207 | // render 2: data (hello) 208 | 209 | const lastResponseWithoutIndex = { data: "new data" }; 210 | 211 | // render 3 data (new data) 212 | nock("https://my-awesome-api.fake", { 213 | reqheaders: { 214 | prefer: "wait=1s;index=1", 215 | }, 216 | }) 217 | .get("/") 218 | .reply(200, lastResponseWithoutIndex); 219 | 220 | // render 4 (shouldn't happen) 221 | nock("https://my-awesome-api.fake", { 222 | reqheaders: { 223 | prefer: "wait=1s;", 224 | }, 225 | }) 226 | .get("/") 227 | .reply( 228 | 200, 229 | { data: "You shouldn't get here because the previous one had no polling index." }, 230 | { "x-polling-index": "3" }, 231 | ); 232 | 233 | const children = jest.fn(); 234 | children.mockReturnValue(
); 235 | 236 | render( 237 | 238 | 239 | {children} 240 | 241 | , 242 | ); 243 | 244 | await wait(() => expect(children.mock.calls.length).toBe(3)); 245 | await wait(() => 246 | expect(children.mock.calls[children.mock.calls.length - 1][0]).toEqual(lastResponseWithoutIndex), 247 | ); 248 | }); 249 | it("should deal with query parameters", async () => { 250 | nock("https://my-awesome-api.fake", { 251 | reqheaders: { 252 | prefer: "wait=60s;", 253 | }, 254 | }) 255 | .get("/") 256 | .query({ 257 | myParam: true, 258 | }) 259 | .reply(200, { data: "hello" }, { "x-polling-index": "1" }); 260 | 261 | nock("https://my-awesome-api.fake", { 262 | reqheaders: { 263 | prefer: "wait=60s;index=1", 264 | }, 265 | }) 266 | .get("/") 267 | .query({ 268 | myParam: true, 269 | }) 270 | .reply(200, { data: "hello" }, { "x-polling-index": "2" }); 271 | 272 | const children = jest.fn(); 273 | children.mockReturnValue(
); 274 | 275 | render( 276 | 277 | path="" queryParams={{ myParam: true }}> 278 | {children} 279 | 280 | , 281 | ); 282 | 283 | await wait(() => expect(children.mock.calls.length).toBe(2)); 284 | }); 285 | it("should inherit query parameters from provider if none specified", async () => { 286 | nock("https://my-awesome-api.fake", { 287 | reqheaders: { 288 | prefer: "wait=60s;", 289 | }, 290 | }) 291 | .get("/") 292 | .query({ 293 | myParam: true, 294 | }) 295 | .reply(200, { data: "hello" }, { "x-polling-index": "1" }); 296 | 297 | nock("https://my-awesome-api.fake", { 298 | reqheaders: { 299 | prefer: "wait=60s;index=1", 300 | }, 301 | }) 302 | .get("/") 303 | .query({ 304 | myParam: true, 305 | }) 306 | .reply(200, { data: "hello" }, { "x-polling-index": "2" }); 307 | 308 | const children = jest.fn(); 309 | children.mockReturnValue(
); 310 | 311 | render( 312 | 313 | path="">{children} 314 | , 315 | ); 316 | 317 | await wait(() => expect(children.mock.calls.length).toBe(2)); 318 | }); 319 | it("should override query parameters from provider if own specified", async () => { 320 | nock("https://my-awesome-api.fake", { 321 | reqheaders: { 322 | prefer: "wait=60s;", 323 | }, 324 | }) 325 | .get("/") 326 | .query({ 327 | myParam: false, 328 | }) 329 | .reply(200, { data: "hello" }, { "x-polling-index": "1" }); 330 | 331 | nock("https://my-awesome-api.fake", { 332 | reqheaders: { 333 | prefer: "wait=60s;index=1", 334 | }, 335 | }) 336 | .get("/") 337 | .query({ 338 | myParam: true, 339 | }) 340 | .reply(200, { data: "hello" }, { "x-polling-index": "2" }); 341 | 342 | const children = jest.fn(); 343 | children.mockReturnValue(
); 344 | 345 | render( 346 | 347 | path="" queryParams={{ myParam: false }}> 348 | {children} 349 | 350 | , 351 | ); 352 | 353 | await wait(() => expect(children.mock.calls.length).toBe(2)); 354 | }); 355 | it("should merge query parameters from provider when both specified", async () => { 356 | nock("https://my-awesome-api.fake", { 357 | reqheaders: { 358 | prefer: "wait=60s;", 359 | }, 360 | }) 361 | .get("/") 362 | .query({ 363 | myParam: false, 364 | otherParam: true, 365 | }) 366 | .reply(200, { data: "hello" }, { "x-polling-index": "1" }); 367 | 368 | nock("https://my-awesome-api.fake", { 369 | reqheaders: { 370 | prefer: "wait=60s;index=1", 371 | }, 372 | }) 373 | .get("/") 374 | .query({ 375 | myParam: true, 376 | }) 377 | .reply(200, { data: "hello" }, { "x-polling-index": "2" }); 378 | 379 | const children = jest.fn(); 380 | children.mockReturnValue(
); 381 | 382 | render( 383 | 384 | path="" queryParams={{ myParam: false }}> 385 | {children} 386 | 387 | , 388 | ); 389 | 390 | await wait(() => expect(children.mock.calls.length).toBe(2)); 391 | }); 392 | 393 | it("should call the provider onRequest", async () => { 394 | const path = "https://my-awesome-api.fake"; 395 | nock(path) 396 | .get("/") 397 | .reply(200, { hello: "world" }); 398 | 399 | const children = jest.fn(); 400 | children.mockReturnValue(
); 401 | 402 | const onRequest = jest.fn(); 403 | const request = new Request(path, { headers: { prefer: "wait=60s;" } }); 404 | 405 | render( 406 | 407 | {children} 408 | , 409 | ); 410 | 411 | await wait(() => expect(children.mock.calls.length > 0).toBe(true)); 412 | expect(onRequest).toBeCalledWith(request); 413 | }); 414 | 415 | it("should call the provider onResponse", async () => { 416 | const path = "https://my-awesome-api.fake"; 417 | nock(path) 418 | .get("/") 419 | .reply(200, { hello: "world" }); 420 | 421 | const children = jest.fn(); 422 | children.mockReturnValue(
); 423 | 424 | let body: any; 425 | const onResponse = jest.fn(async (res: Response) => { 426 | body = await res.json(); 427 | }); 428 | 429 | render( 430 | 431 | {children} 432 | , 433 | ); 434 | 435 | await wait(() => expect(children.mock.calls.length).toBe(2)); 436 | expect(onResponse).toBeCalled(); 437 | expect(body).toMatchObject({ hello: "world" }); 438 | }); 439 | }); 440 | 441 | describe("with error", () => { 442 | it("should set the `error` object properly", async () => { 443 | nock("https://my-awesome-api.fake") 444 | .get("/") 445 | .reply(401, { message: "You shall not pass!" }); 446 | 447 | const children = jest.fn(); 448 | children.mockReturnValue(
); 449 | 450 | render( 451 | 452 | {children} 453 | , 454 | ); 455 | 456 | await wait(() => expect(children.mock.calls.length).toBe(2)); 457 | expect(children.mock.calls[1][0]).toEqual(null); 458 | expect(children.mock.calls[1][1].error).toEqual({ 459 | data: { message: "You shall not pass!" }, 460 | message: "Failed to poll: 401 Unauthorized", 461 | status: 401, 462 | }); 463 | }); 464 | 465 | it("should deal with non standard server error response (nginx style)", async () => { 466 | nock("https://my-awesome-api.fake") 467 | .get("/") 468 | .reply(200, "404 - this is not a json!", { 469 | "content-type": "application/json", 470 | }); 471 | 472 | const children = jest.fn(); 473 | children.mockReturnValue(
); 474 | 475 | render( 476 | 477 | {children} 478 | , 479 | ); 480 | 481 | await wait(() => expect(children.mock.calls.length).toBe(2)); 482 | expect(children.mock.calls[1][0]).toEqual(null); 483 | expect(children.mock.calls[1][1].error).toEqual({ 484 | data: 485 | "invalid json response body at https://my-awesome-api.fake reason: Unexpected token < in JSON at position 0", 486 | message: 487 | "Failed to poll: 200 OK - invalid json response body at https://my-awesome-api.fake reason: Unexpected token < in JSON at position 0", 488 | status: 200, 489 | }); 490 | }); 491 | 492 | it("should continue polling after an error", async () => { 493 | nock("https://my-awesome-api.fake") 494 | .get("/") 495 | .reply(504, "504 Gateway Time-out", { 496 | "content-type": "text/html", 497 | }); 498 | 499 | nock("https://my-awesome-api.fake", { 500 | reqheaders: { 501 | prefer: "wait=0s;", 502 | }, 503 | }) 504 | .get("/") 505 | .reply(200, { data: "hello" }, { "x-polling-index": "1", "content-type": "application/json" }); 506 | 507 | const children = jest.fn(); 508 | children.mockReturnValue(
); 509 | 510 | render( 511 | 512 | 513 | {children} 514 | 515 | , 516 | ); 517 | 518 | await wait(() => expect(children.mock.calls.length).toBe(3)); 519 | 520 | // first res (error) 521 | expect(children.mock.calls[1][0]).toEqual(null); 522 | expect(children.mock.calls[1][1].error).toEqual({ 523 | data: "504 Gateway Time-out", 524 | message: "Failed to poll: 504 Gateway Timeout", 525 | status: 504, 526 | }); 527 | 528 | // second res (success) 529 | expect(children.mock.calls[2][0]).toEqual({ data: "hello" }); 530 | expect(children.mock.calls[2][1].error).toEqual(null); 531 | }); 532 | 533 | it("should call the provider onError", async () => { 534 | nock("https://my-awesome-api.fake") 535 | .get("/") 536 | .reply(401, { message: "You shall not pass!" }); 537 | 538 | const children = jest.fn(); 539 | children.mockReturnValue(
); 540 | 541 | const onError = jest.fn(); 542 | 543 | render( 544 | 545 | {children} 546 | , 547 | ); 548 | 549 | await wait(() => expect(children.mock.calls.length).toBe(2)); 550 | expect(onError).toBeCalledWith( 551 | { 552 | data: { message: "You shall not pass!" }, 553 | message: "Failed to poll: 401 Unauthorized", 554 | status: 401, 555 | }, 556 | expect.any(Function), // retry 557 | expect.any(Object), // response 558 | ); 559 | }); 560 | 561 | it("should set the `error` object properly", async () => { 562 | nock("https://my-awesome-api.fake") 563 | .get("/") 564 | .reply(401, { message: "You shall not pass!" }); 565 | 566 | const children = jest.fn(); 567 | children.mockReturnValue(
); 568 | 569 | const onError = jest.fn(); 570 | 571 | render( 572 | 573 | 574 | {children} 575 | 576 | , 577 | ); 578 | 579 | await wait(() => expect(children.mock.calls.length).toBe(2)); 580 | expect(onError.mock.calls.length).toEqual(0); 581 | }); 582 | }); 583 | 584 | describe("with custom resolver", () => { 585 | it("should use the provider resolver", async () => { 586 | nock("https://my-awesome-api.fake") 587 | .get("/") 588 | .reply(200, { hello: "world" }); 589 | 590 | const children = jest.fn(); 591 | children.mockReturnValue(
); 592 | 593 | render( 594 | ({ ...data, foo: "bar" })}> 595 | {children} 596 | , 597 | ); 598 | 599 | await wait(() => expect(children.mock.calls.length).toBe(2)); 600 | expect(children.mock.calls[1][0]).toEqual({ hello: "world", foo: "bar" }); 601 | }); 602 | 603 | it("should transform data", async () => { 604 | nock("https://my-awesome-api.fake") 605 | .get("/") 606 | .reply(200, { hello: "world" }); 607 | 608 | const children = jest.fn(); 609 | children.mockReturnValue(
); 610 | 611 | render( 612 | 613 | ({ ...data, foo: "bar" })}> 614 | {children} 615 | 616 | , 617 | ); 618 | 619 | await wait(() => expect(children.mock.calls.length).toBe(2)); 620 | expect(children.mock.calls[1][0]).toEqual({ hello: "world", foo: "bar" }); 621 | }); 622 | 623 | it("should be able to consolidate data", async () => { 624 | nock("https://my-awesome-api.fake", { 625 | reqheaders: { 626 | prefer: "wait=0s;", 627 | }, 628 | }) 629 | .get("/") 630 | .reply(200, { data: "hello" }, { "x-polling-index": "1" }); 631 | 632 | nock("https://my-awesome-api.fake", { 633 | reqheaders: { 634 | prefer: "wait=0s;index=1", 635 | }, 636 | }) 637 | .get("/") 638 | .reply(200, { data: " you" }, { "x-polling-index": "2" }); 639 | 640 | const children = jest.fn(); 641 | children.mockReturnValue(
); 642 | 643 | render( 644 | 645 | ({ 649 | data: (prevData || { data: "" }).data + data.data, 650 | })} 651 | > 652 | {children} 653 | 654 | , 655 | ); 656 | 657 | await wait(() => expect(children.mock.calls.length).toBe(3)); 658 | expect(children.mock.calls[2][0]).toEqual({ data: "hello you" }); 659 | }); 660 | 661 | it("should update data when resolver changes", async () => { 662 | nock("https://my-awesome-api.fake") 663 | .get("/") 664 | .reply(200, { hello: "world" }); 665 | 666 | const children = jest.fn(); 667 | children.mockReturnValue(
); 668 | 669 | const resolve = (data: any) => ({ ...data, too: "bar" }); 670 | const newResolve = (data: any) => ({ ...data, foo: "bar" }); 671 | 672 | const { rerender } = render( 673 | 674 | 675 | {children} 676 | 677 | , 678 | ); 679 | 680 | rerender( 681 | 682 | 683 | {children} 684 | 685 | , 686 | ); 687 | 688 | await wait(() => expect(children.mock.calls.length).toBe(3)); 689 | expect(children.mock.calls[2][0]).toEqual({ hello: "world", foo: "bar" }); 690 | }); 691 | }); 692 | 693 | describe("with lazy", () => { 694 | it("should not fetch on mount", async () => { 695 | const children = jest.fn(); 696 | children.mockReturnValue(
); 697 | 698 | render( 699 | 700 | 701 | {children} 702 | 703 | , 704 | ); 705 | 706 | await wait(() => expect(children.mock.calls.length).toBe(1)); 707 | expect(children.mock.calls[0][1].loading).toBe(false); 708 | expect(children.mock.calls[0][0]).toBe(null); 709 | }); 710 | }); 711 | 712 | describe("with base", () => { 713 | it("should override the base url", async () => { 714 | nock("https://my-awesome-api.fake") 715 | .get("/") 716 | .reply(200, { id: 1 }); 717 | 718 | const children = jest.fn(); 719 | children.mockReturnValue(
); 720 | 721 | render( 722 | 723 | 724 | {children} 725 | 726 | , 727 | ); 728 | 729 | await wait(() => expect(children.mock.calls.length).toBe(2)); 730 | expect(children.mock.calls[1][1].loading).toEqual(false); 731 | expect(children.mock.calls[1][0]).toEqual({ id: 1 }); 732 | }); 733 | 734 | it("should override the base url and compose with the path", async () => { 735 | nock("https://my-awesome-api.fake") 736 | .get("/plop") 737 | .reply(200, { id: 1 }); 738 | 739 | const children = jest.fn(); 740 | children.mockReturnValue(
); 741 | 742 | render( 743 | 744 | 745 | {children} 746 | 747 | , 748 | ); 749 | 750 | await wait(() => expect(children.mock.calls.length).toBe(2)); 751 | expect(children.mock.calls[1][1].loading).toEqual(false); 752 | expect(children.mock.calls[1][0]).toEqual({ id: 1 }); 753 | }); 754 | 755 | it("should compose urls with base subpath", async () => { 756 | nock("https://my-awesome-api.fake/MY_SUBROUTE") 757 | .get("/absolute") 758 | .reply(200, { id: 1 }); 759 | 760 | const children = jest.fn(); 761 | children.mockReturnValue(
); 762 | 763 | render( 764 | 765 | {children} 766 | , 767 | ); 768 | 769 | await wait(() => expect(children.mock.calls.length).toBe(2)); 770 | expect(children.mock.calls[1][1].loading).toEqual(false); 771 | expect(children.mock.calls[1][0]).toEqual({ id: 1 }); 772 | }); 773 | 774 | it("should compose urls properly when base has a trailing slash", async () => { 775 | nock("https://my-awesome-api.fake/MY_SUBROUTE") 776 | .get("/absolute") 777 | .reply(200, { id: 1 }); 778 | 779 | const children = jest.fn(); 780 | children.mockReturnValue(
); 781 | 782 | render( 783 | 784 | {children} 785 | , 786 | ); 787 | 788 | await wait(() => expect(children.mock.calls.length).toBe(2)); 789 | expect(children.mock.calls[1][1].loading).toEqual(false); 790 | expect(children.mock.calls[1][0]).toEqual({ id: 1 }); 791 | }); 792 | }); 793 | 794 | describe("with custom request options", () => { 795 | it("should add a custom header", async () => { 796 | nock("https://my-awesome-api.fake", { reqheaders: { foo: "bar" } }) 797 | .get("/") 798 | .reply(200, { id: 1 }); 799 | 800 | const children = jest.fn(); 801 | children.mockReturnValue(
); 802 | 803 | render( 804 | 805 | 806 | {children} 807 | 808 | , 809 | ); 810 | 811 | await wait(() => expect(children.mock.calls.length).toBe(2)); 812 | expect(children.mock.calls[1][1].loading).toEqual(false); 813 | expect(children.mock.calls[1][0]).toEqual({ id: 1 }); 814 | }); 815 | 816 | it("should add a custom header with requestOptions method", async () => { 817 | nock("https://my-awesome-api.fake", { reqheaders: { foo: "bar" } }) 818 | .get("/") 819 | .reply(200, { id: 1 }); 820 | 821 | const children = jest.fn(); 822 | children.mockReturnValue(
); 823 | 824 | render( 825 | 826 | ({ headers: { foo: "bar" } })}> 827 | {children} 828 | 829 | , 830 | ); 831 | 832 | await wait(() => expect(children.mock.calls.length).toBe(2)); 833 | expect(children.mock.calls[1][1].loading).toEqual(false); 834 | expect(children.mock.calls[1][0]).toEqual({ id: 1 }); 835 | }); 836 | 837 | it("should add a promised custom header with the requestOptions method", async () => { 838 | nock("https://my-awesome-api.fake", { reqheaders: { foo: "bar" } }) 839 | .get("/") 840 | .reply(200, { id: 1 }); 841 | 842 | const children = jest.fn(); 843 | children.mockReturnValue(
); 844 | 845 | render( 846 | 847 | new Promise(res => setTimeout(() => res({ headers: { foo: "bar" } }), 1000))} 850 | > 851 | {children} 852 | 853 | , 854 | ); 855 | 856 | await wait(() => expect(children.mock.calls.length).toBe(2)); 857 | expect(children.mock.calls[1][1].loading).toEqual(false); 858 | expect(children.mock.calls[1][0]).toEqual({ id: 1 }); 859 | }); 860 | 861 | it("should merge headers with providers", async () => { 862 | nock("https://my-awesome-api.fake", { reqheaders: { foo: "bar", baz: "qux" } }) 863 | .get("/") 864 | .reply(200, { id: 1 }); 865 | 866 | const children = jest.fn(); 867 | children.mockReturnValue(
); 868 | 869 | render( 870 | 871 | ({ headers: { foo: "bar" } })}> 872 | {children} 873 | 874 | , 875 | ); 876 | 877 | await wait(() => expect(children.mock.calls.length).toBe(2)); 878 | expect(children.mock.calls[1][1].loading).toEqual(false); 879 | expect(children.mock.calls[1][0]).toEqual({ id: 1 }); 880 | }); 881 | }); 882 | }); 883 | -------------------------------------------------------------------------------- /src/scripts/import-open-api.ts: -------------------------------------------------------------------------------- 1 | import { pascal } from "case"; 2 | import chalk from "chalk"; 3 | import openApiValidator from "ibm-openapi-validator"; 4 | import get from "lodash/get"; 5 | import groupBy from "lodash/groupBy"; 6 | import isEmpty from "lodash/isEmpty"; 7 | import set from "lodash/set"; 8 | import uniq from "lodash/uniq"; 9 | 10 | import { 11 | ComponentsObject, 12 | OpenAPIObject, 13 | OperationObject, 14 | ParameterObject, 15 | PathItemObject, 16 | ReferenceObject, 17 | RequestBodyObject, 18 | ResponseObject, 19 | SchemaObject, 20 | } from "openapi3-ts"; 21 | 22 | import swagger2openapi from "swagger2openapi"; 23 | 24 | import YAML from "js-yaml"; 25 | import { AdvancedOptions } from "../bin/restful-react-import"; 26 | 27 | const IdentifierRegexp = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/; 28 | 29 | /** 30 | * Discriminator helper for `ReferenceObject` 31 | * 32 | * @param property 33 | */ 34 | export const isReference = (property: any): property is ReferenceObject => { 35 | return Boolean(property.$ref); 36 | }; 37 | 38 | /** 39 | * Return the typescript equivalent of open-api data type 40 | * 41 | * @param item 42 | * @ref https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#data-types 43 | */ 44 | export const getScalar = (item: SchemaObject) => { 45 | const nullable = item.nullable ? " | null" : ""; 46 | 47 | switch (item.type) { 48 | case "int32": 49 | case "int64": 50 | case "number": 51 | case "integer": 52 | case "long": 53 | case "float": 54 | case "double": 55 | return (item.enum ? `${item.enum.join(` | `)}` : "number") + nullable; 56 | 57 | case "boolean": 58 | return "boolean" + nullable; 59 | 60 | case "array": 61 | return getArray(item) + nullable; 62 | 63 | case "null": 64 | return "null"; 65 | 66 | case "string": 67 | case "byte": 68 | case "binary": 69 | case "date": 70 | case "dateTime": 71 | case "date-time": 72 | case "password": 73 | return (item.enum ? `"${item.enum.join(`" | "`)}"` : "string") + nullable; 74 | 75 | case "object": 76 | default: 77 | return getObject(item) + nullable; 78 | } 79 | }; 80 | 81 | /** 82 | * Return the output type from the $ref 83 | * 84 | * @param $ref 85 | */ 86 | export const getRef = ($ref: ReferenceObject["$ref"]) => { 87 | if ($ref.startsWith("#/components/schemas")) { 88 | return pascal($ref.replace("#/components/schemas/", "")); 89 | } else if ($ref.startsWith("#/components/responses")) { 90 | return pascal($ref.replace("#/components/responses/", "")) + "Response"; 91 | } else if ($ref.startsWith("#/components/parameters")) { 92 | return pascal($ref.replace("#/components/parameters/", "")) + "Parameter"; 93 | } else if ($ref.startsWith("#/components/requestBodies")) { 94 | return pascal($ref.replace("#/components/requestBodies/", "")) + "RequestBody"; 95 | } else { 96 | throw new Error("This library only resolve $ref that are include into `#/components/*` for now"); 97 | } 98 | }; 99 | 100 | /** 101 | * Return the output type from an array 102 | * 103 | * @param item item with type === "array" 104 | */ 105 | export const getArray = (item: SchemaObject): string => { 106 | if (!item.items) { 107 | throw new Error("All arrays must have an `items` key defined"); 108 | } 109 | let item_type = resolveValue(item.items); 110 | if (!isReference(item.items) && (item.items.oneOf || item.items.anyOf || item.items.allOf || item.items.enum)) { 111 | item_type = `(${item_type})`; 112 | } 113 | if (item.minItems && item.maxItems && item.minItems === item.maxItems) { 114 | return `[${new Array(item.minItems).fill(item_type).join(", ")}]`; 115 | } 116 | return `${item_type}[]`; 117 | }; 118 | 119 | const requireProperties = (type: string, toRequire: string[]) => { 120 | return `Require<${type}, ${toRequire.map(property => `"${property}"`).join(" | ")}>`; 121 | }; 122 | 123 | /** 124 | * Return the output type from an object 125 | * 126 | * @param item item with type === "object" 127 | */ 128 | export const getObject = (item: SchemaObject): string => { 129 | if (isReference(item)) { 130 | return getRef(item.$ref); 131 | } 132 | 133 | if (item.allOf) { 134 | const composedType = item.allOf.map(resolveValue).join(" & "); 135 | if (item.required && item.required.length) { 136 | return requireProperties(composedType, item.required); 137 | } 138 | return composedType; 139 | } 140 | 141 | if (item.anyOf) { 142 | return item.anyOf.map(resolveValue).join(" | "); 143 | } 144 | 145 | if (item.oneOf) { 146 | const unionType = item.oneOf.map(resolveValue).join(" | "); 147 | if (item.required && item.required.length) { 148 | return requireProperties(unionType, item.required); 149 | } 150 | return unionType; 151 | } 152 | 153 | if (!item.type && !item.properties && !item.additionalProperties) { 154 | return "{}"; 155 | } 156 | 157 | // Free form object (https://swagger.io/docs/specification/data-models/data-types/#free-form) 158 | if ( 159 | item.type === "object" && 160 | !item.properties && 161 | (!item.additionalProperties || item.additionalProperties === true || isEmpty(item.additionalProperties)) 162 | ) { 163 | return "{[key: string]: any}"; 164 | } 165 | 166 | // Consolidation of item.properties & item.additionalProperties 167 | let output = "{\n"; 168 | if (item.properties) { 169 | output += Object.entries(item.properties) 170 | .map(([key, prop]: [string, ReferenceObject | SchemaObject]) => { 171 | const doc = isReference(prop) ? "" : formatDescription(prop.description, 2); 172 | const isRequired = (item.required || []).includes(key); 173 | const processedKey = IdentifierRegexp.test(key) ? key : `"${key}"`; 174 | return ` ${doc}${processedKey}${isRequired ? "" : "?"}: ${resolveValue(prop)};`; 175 | }) 176 | .join("\n"); 177 | } 178 | 179 | if (item.additionalProperties) { 180 | if (item.properties) { 181 | output += "\n"; 182 | } 183 | output += ` [key: string]: ${ 184 | item.additionalProperties === true ? "any" : resolveValue(item.additionalProperties) 185 | };`; 186 | } 187 | 188 | if (item.properties || item.additionalProperties) { 189 | if (output === "{\n") return "{}"; 190 | return output + "\n}"; 191 | } 192 | 193 | return item.type === "object" ? "{[key: string]: any}" : "any"; 194 | }; 195 | 196 | /** 197 | * Resolve the value of a schema object to a proper type definition. 198 | * @param schema 199 | */ 200 | export const resolveValue = (schema: SchemaObject) => (isReference(schema) ? getRef(schema.$ref) : getScalar(schema)); 201 | 202 | /** 203 | * Extract responses / request types from open-api specs 204 | * 205 | * @param responsesOrRequests reponses or requests object from open-api specs 206 | */ 207 | export const getResReqTypes = ( 208 | responsesOrRequests: Array<[string, ResponseObject | ReferenceObject | RequestBodyObject]>, 209 | ) => 210 | uniq( 211 | responsesOrRequests.map(([_, res]) => { 212 | if (!res) { 213 | return "void"; 214 | } 215 | 216 | if (isReference(res)) { 217 | return getRef(res.$ref); 218 | } 219 | 220 | if (res.content) { 221 | for (let contentType of Object.keys(res.content)) { 222 | if ( 223 | contentType.startsWith("*/*") || 224 | contentType.startsWith("application/json") || 225 | contentType.startsWith("application/octet-stream") 226 | ) { 227 | const schema = res.content[contentType].schema!; 228 | return resolveValue(schema); 229 | } 230 | } 231 | return "void"; 232 | } 233 | 234 | return "void"; 235 | }), 236 | ).join(" | "); 237 | 238 | /** 239 | * Return every params in a path 240 | * 241 | * @example 242 | * ``` 243 | * getParamsInPath("/pet/{category}/{name}/"); 244 | * // => ["category", "name"] 245 | * ``` 246 | * @param path 247 | */ 248 | export const getParamsInPath = (path: string) => { 249 | let n; 250 | const output = []; 251 | const templatePathRegex = /\{(\w+)}/g; 252 | // tslint:disable-next-line:no-conditional-assignment 253 | while ((n = templatePathRegex.exec(path)) !== null) { 254 | output.push(n[1]); 255 | } 256 | 257 | return output; 258 | }; 259 | 260 | /** 261 | * Import and parse the openapi spec from a yaml/json 262 | * 263 | * @param data raw data of the spec 264 | * @param format format of the spec 265 | */ 266 | const importSpecs = (data: string, extension: "yaml" | "json"): Promise => { 267 | const schema = extension === "yaml" ? YAML.safeLoad(data) : JSON.parse(data); 268 | 269 | return new Promise((resolve, reject) => { 270 | if (!schema.openapi || !schema.openapi.startsWith("3.0")) { 271 | swagger2openapi.convertObj(schema, {}, (err, convertedObj) => { 272 | if (err) { 273 | reject(err); 274 | } else { 275 | resolve(convertedObj.openapi); 276 | } 277 | }); 278 | } else { 279 | resolve(schema); 280 | } 281 | }); 282 | }; 283 | 284 | /** 285 | * Take a react props value style and convert it to object style 286 | * 287 | * Example: 288 | * reactPropsValueToObjectValue(`{ getConfig("myVar") }`) // `getConfig("myVar")` 289 | */ 290 | export const reactPropsValueToObjectValue = (value: string) => { 291 | if (value.startsWith("{") && value.endsWith("}")) { 292 | return value.slice(1, -1); 293 | } 294 | return value; 295 | }; 296 | 297 | /** 298 | * Generate a restful-react component from openapi operation specs 299 | * 300 | * @param operation 301 | * @param verb 302 | * @param route 303 | * @param baseUrl 304 | * @param operationIds - List of `operationId` to check duplication 305 | */ 306 | export const generateRestfulComponent = ( 307 | operation: OperationObject, 308 | verb: string, 309 | route: string, 310 | operationIds: string[], 311 | parameters: Array = [], 312 | schemasComponents?: ComponentsObject, 313 | customProps: AdvancedOptions["customProps"] = {}, 314 | skipReact = false, 315 | pathParametersEncodingMode?: AdvancedOptions["pathParametersEncodingMode"], 316 | customGenerator?: AdvancedOptions["customGenerator"], 317 | ) => { 318 | if (!operation.operationId) { 319 | throw new Error(`Every path must have a operationId - No operationId set for ${verb} ${route}`); 320 | } 321 | if (operationIds.includes(operation.operationId)) { 322 | throw new Error(`"${operation.operationId}" is duplicated in your schema definition!`); 323 | } 324 | operationIds.push(operation.operationId); 325 | 326 | route = route.replace(/\{/g, "${"); // `/pet/{id}` => `/pet/${id}` 327 | 328 | // Remove the last param of the route if we are in the DELETE case and generating React components/hooks 329 | let lastParamInTheRoute: string | null = null; 330 | if (!skipReact && verb === "delete") { 331 | const lastParamInTheRouteRegExp = /\/\$\{(\w+)\}\/?$/; 332 | lastParamInTheRoute = (route.match(lastParamInTheRouteRegExp) || [])[1]; 333 | route = route.replace(lastParamInTheRouteRegExp, ""); // `/pet/${id}` => `/pet` 334 | } 335 | const componentName = pascal(operation.operationId!); 336 | const Component = verb === "get" ? "Get" : "Mutate"; 337 | 338 | const isOk = ([statusCode]: [string, ResponseObject | ReferenceObject]) => statusCode.toString().startsWith("2"); 339 | const isError = ([statusCode]: [string, ResponseObject | ReferenceObject]) => 340 | statusCode.toString().startsWith("4") || statusCode.toString().startsWith("5") || statusCode === "default"; 341 | 342 | const responseTypes = getResReqTypes(Object.entries(operation.responses).filter(isOk)) || "void"; 343 | const errorTypes = getResReqTypes(Object.entries(operation.responses).filter(isError)) || "unknown"; 344 | const requestBodyTypes = getResReqTypes([["body", operation.requestBody!]]); 345 | const needARequestBodyComponent = requestBodyTypes.includes("{"); 346 | const needAResponseComponent = responseTypes.includes("{"); 347 | 348 | /** 349 | * We strip the ID from the URL in order to pass it as an argument to the 350 | * `delete` function for generated components. 351 | * 352 | * For example: 353 | * 354 | * A given request 355 | * DELETE https://my.api/resource/123 356 | * 357 | * Becomes 358 | * 359 | * {(deleteThisThing) => } 360 | * 361 | */ 362 | 363 | const paramsInPath = getParamsInPath(route).filter(param => !(verb === "delete" && param === lastParamInTheRoute)); 364 | const { query: queryParams = [], path: pathParams = [], header: headerParams = [] } = groupBy( 365 | [...parameters, ...(operation.parameters || [])].map(p => { 366 | if (isReference(p)) { 367 | return get(schemasComponents, p.$ref.replace("#/components/", "").replace("/", ".")); 368 | } else { 369 | return p; 370 | } 371 | }), 372 | "in", 373 | ); 374 | 375 | const paramsTypes = paramsInPath 376 | .map(p => { 377 | try { 378 | const { name, required, schema, description } = pathParams.find(i => i.name === p)!; 379 | return `${description ? formatDescription(description, 2) : ""}${name}${required ? "" : "?"}: ${resolveValue( 380 | schema!, 381 | )}`; 382 | } catch (err) { 383 | throw new Error(`The path params ${p} can't be found in parameters (${operation.operationId})`); 384 | } 385 | }) 386 | .join(";\n "); 387 | 388 | const queryParamsType = queryParams 389 | .map(p => { 390 | const processedName = IdentifierRegexp.test(p.name) ? p.name : `"${p.name}"`; 391 | return `${formatDescription(p.description, 2)}${processedName}${p.required ? "" : "?"}: ${resolveValue( 392 | p.schema!, 393 | )}`; 394 | }) 395 | .join(";\n "); 396 | 397 | // Retrieve the type of the param for delete verb 398 | const lastParamInTheRouteDefinition = 399 | operation.parameters && lastParamInTheRoute 400 | ? operation.parameters 401 | .map(p => 402 | isReference(p) 403 | ? (get(schemasComponents, p.$ref.replace("#/components/", "").replace("/", ".")) as ParameterObject) 404 | : p, 405 | ) 406 | .find(p => p.name === lastParamInTheRoute) 407 | : { schema: { type: "string" } }; 408 | 409 | if (!lastParamInTheRouteDefinition) { 410 | throw new Error(`The path params ${lastParamInTheRoute} can't be found in parameters (${operation.operationId})`); 411 | } 412 | 413 | const lastParamInTheRouteType = 414 | !isReference(lastParamInTheRouteDefinition.schema) && lastParamInTheRouteDefinition.schema 415 | ? getScalar(lastParamInTheRouteDefinition.schema) 416 | : isReference(lastParamInTheRouteDefinition.schema) 417 | ? getRef(lastParamInTheRouteDefinition.schema.$ref) 418 | : "string"; 419 | 420 | const responseType = needAResponseComponent ? componentName + "Response" : responseTypes; 421 | const genericsTypes = 422 | verb === "get" 423 | ? `${responseType}, ${errorTypes}, ${queryParamsType ? componentName + "QueryParams" : "void"}, ${ 424 | paramsInPath.length ? componentName + "PathParams" : "void" 425 | }` 426 | : `${responseType}, ${errorTypes}, ${queryParamsType ? componentName + "QueryParams" : "void"}, ${ 427 | verb === "delete" && lastParamInTheRoute 428 | ? lastParamInTheRouteType 429 | : needARequestBodyComponent 430 | ? componentName + "RequestBody" 431 | : requestBodyTypes 432 | }, ${paramsInPath.length ? componentName + "PathParams" : "void"}`; 433 | 434 | const genericsTypesForHooksProps = 435 | verb === "get" 436 | ? `${responseType}, ${errorTypes}, ${queryParamsType ? componentName + "QueryParams" : "void"}, ${ 437 | paramsInPath.length ? componentName + "PathParams" : "void" 438 | }` 439 | : `${responseType}, ${errorTypes}, ${queryParamsType ? componentName + "QueryParams" : "void"}, ${ 440 | verb === "delete" && lastParamInTheRoute 441 | ? lastParamInTheRouteType 442 | : needARequestBodyComponent 443 | ? componentName + "RequestBody" 444 | : requestBodyTypes 445 | }, ${paramsInPath.length ? componentName + "PathParams" : "void"}`; 446 | 447 | const customPropsEntries = Object.entries(customProps).map(([key, prop]) => { 448 | if (typeof prop === "function") { 449 | return [key, prop({ responseType })]; 450 | } 451 | return [key, prop]; 452 | }); 453 | 454 | const description = formatDescription( 455 | operation.summary && operation.description 456 | ? `${operation.summary}\n\n${operation.description}` 457 | : `${operation.summary || ""}${operation.description || ""}`, 458 | ); 459 | 460 | let output = `${ 461 | needAResponseComponent 462 | ? ` 463 | export ${ 464 | responseTypes.includes("|") || responseTypes.includes("&") 465 | ? `type ${componentName}Response =` 466 | : `interface ${componentName}Response` 467 | } ${responseTypes} 468 | ` 469 | : "" 470 | }${ 471 | queryParamsType 472 | ? ` 473 | export interface ${componentName}QueryParams { 474 | ${queryParamsType}; 475 | } 476 | ` 477 | : "" 478 | }${ 479 | paramsInPath.length 480 | ? ` 481 | export interface ${componentName}PathParams { 482 | ${paramsTypes} 483 | } 484 | ` 485 | : "" 486 | }${ 487 | needARequestBodyComponent 488 | ? ` 489 | export ${ 490 | requestBodyTypes.includes("&") 491 | ? `type ${componentName}RequestBody =` 492 | : `interface ${componentName}RequestBody` 493 | } ${requestBodyTypes} 494 | ` 495 | : "" 496 | } 497 | `; 498 | 499 | if (!skipReact) { 500 | const encode = pathParametersEncodingMode ? "encode" : ""; 501 | 502 | // Component version 503 | output += `export type ${componentName}Props = Omit<${Component}Props<${genericsTypes}>, "path"${ 504 | verb === "get" ? "" : ` | "verb"` 505 | }>${paramsInPath.length ? ` & ${componentName}PathParams` : ""}; 506 | 507 | ${description}export const ${componentName} = (${ 508 | paramsInPath.length ? `{${paramsInPath.join(", ")}, ...props}` : "props" 509 | }: ${componentName}Props) => ( 510 | <${Component}<${genericsTypes}>${ 511 | verb === "get" 512 | ? "" 513 | : ` 514 | verb="${verb.toUpperCase()}"` 515 | } 516 | path=${`{${encode}\`${route}\`}`}${ 517 | customPropsEntries.length 518 | ? "\n " + customPropsEntries.map(([key, value]) => `${key}=${value}`).join("\n ") 519 | : "" 520 | } 521 | ${verb === "delete" && pathParametersEncodingMode ? "pathInlineBodyEncode={encodingFn}" : ""} 522 | {...props} 523 | /> 524 | ); 525 | 526 | `; 527 | 528 | // Poll component 529 | if (headerParams.map(({ name }) => name.toLocaleLowerCase()).includes("prefer")) { 530 | output += `export type Poll${componentName}Props = Omit, "path">${ 531 | paramsInPath.length ? ` & {${paramsTypes}}` : "" 532 | }; 533 | 534 | ${operation.summary ? `// ${operation.summary} (long polling)` : ""} 535 | export const Poll${componentName} = (${ 536 | paramsInPath.length ? `{${paramsInPath.join(", ")}, ...props}` : "props" 537 | }: Poll${componentName}Props) => ( 538 | 539 | path={${encode}\`${route}\`} 540 | {...props} 541 | /> 542 | ); 543 | 544 | `; 545 | } 546 | 547 | // Hooks version 548 | output += `export type Use${componentName}Props = Omit, "path"${ 549 | verb === "get" ? "" : ` | "verb"` 550 | }>${paramsInPath.length ? ` & ${componentName}PathParams` : ""}; 551 | 552 | ${description}export const use${componentName} = (${ 553 | paramsInPath.length ? `{${paramsInPath.join(", ")}, ...props}` : "props" 554 | }: Use${componentName}Props) => use${Component}<${genericsTypes}>(${ 555 | verb === "get" ? "" : `"${verb.toUpperCase()}", ` 556 | }${ 557 | paramsInPath.length 558 | ? `(paramsInPath: ${componentName}PathParams) => ${encode}\`${route.replace(/\$\{/g, "${paramsInPath.")}\`` 559 | : `${encode}\`${route}\`` 560 | }, ${ 561 | customPropsEntries.length || paramsInPath.length || verb === "delete" 562 | ? `{ ${ 563 | customPropsEntries.length 564 | ? `${customPropsEntries 565 | .map(([key, value]) => `${key}:${reactPropsValueToObjectValue(value || "")}`) 566 | .join(", ")},` 567 | : "" 568 | }${verb === "delete" && pathParametersEncodingMode ? "pathInlineBodyEncode: encodingFn, " : " "}${ 569 | paramsInPath.length ? `pathParams: { ${paramsInPath.join(", ")} },` : "" 570 | } ...props }` 571 | : "props" 572 | }); 573 | 574 | `; 575 | } 576 | 577 | // Custom version 578 | if (customGenerator) { 579 | output += customGenerator({ 580 | componentName, 581 | verb, 582 | route, 583 | description, 584 | genericsTypes, 585 | paramsInPath, 586 | paramsTypes, 587 | operation, 588 | }); 589 | } 590 | 591 | return output; 592 | }; 593 | 594 | /** 595 | * Generate the interface string 596 | * 597 | * @param name interface name 598 | * @param schema 599 | */ 600 | export const generateInterface = (name: string, schema: SchemaObject) => { 601 | const scalar = getScalar(schema); 602 | const isEmptyInterface = scalar === "{}"; 603 | return `${formatDescription(schema.description)}${ 604 | isEmptyInterface ? "// tslint:disable-next-line:no-empty-interface\n" : "" 605 | }export interface ${pascal(name)} ${scalar}`; 606 | }; 607 | 608 | /** 609 | * Propagate every `discriminator.propertyName` mapping to the original ref 610 | * 611 | * Note: This method directly mutate the `specs` object. 612 | * 613 | * @param specs 614 | */ 615 | export const resolveDiscriminator = (specs: OpenAPIObject) => { 616 | if (specs.components && specs.components.schemas) { 617 | Object.values(specs.components.schemas).forEach(schema => { 618 | if (isReference(schema) || !schema.discriminator || !schema.discriminator.mapping) { 619 | return; 620 | } 621 | const { mapping, propertyName } = schema.discriminator; 622 | 623 | Object.entries(mapping).forEach(([name, ref]) => { 624 | if (!ref.startsWith("#/components/schemas/")) { 625 | throw new Error("Discriminator mapping outside of `#/components/schemas` is not supported"); 626 | } 627 | set(specs, `components.schemas.${ref.slice("#/components/schemas/".length)}.properties.${propertyName}.enum`, [ 628 | name, 629 | ]); 630 | }); 631 | }); 632 | } 633 | }; 634 | 635 | /** 636 | * Add the version of the spec 637 | * 638 | * @param version 639 | */ 640 | export const addVersionMetadata = (version: string) => `export const SPEC_VERSION = "${version}"; \n`; 641 | 642 | /** 643 | * Extract all types from #/components/schemas 644 | * 645 | * @param schemas 646 | */ 647 | export const generateSchemasDefinition = (schemas: ComponentsObject["schemas"] = {}) => { 648 | if (isEmpty(schemas)) { 649 | return ""; 650 | } 651 | 652 | return ( 653 | Object.entries(schemas) 654 | .map(([name, schema]) => 655 | !isReference(schema) && 656 | (!schema.type || schema.type === "object") && 657 | !schema.allOf && 658 | !schema.anyOf && 659 | !schema.oneOf && 660 | !isReference(schema) && 661 | !schema.nullable 662 | ? generateInterface(name, schema) 663 | : `${formatDescription(isReference(schema) ? undefined : schema.description)}export type ${pascal( 664 | name, 665 | )} = ${resolveValue(schema)};`, 666 | ) 667 | .join("\n\n") + "\n" 668 | ); 669 | }; 670 | 671 | /** 672 | * Extract all types from #/components/requestBodies 673 | * 674 | * @param requestBodies 675 | */ 676 | export const generateRequestBodiesDefinition = (requestBodies: ComponentsObject["requestBodies"] = {}) => { 677 | if (isEmpty(requestBodies)) { 678 | return ""; 679 | } 680 | 681 | return ( 682 | "\n" + 683 | Object.entries(requestBodies) 684 | .map(([name, requestBody]) => { 685 | const doc = isReference(requestBody) ? "" : formatDescription(requestBody.description); 686 | const type = getResReqTypes([["", requestBody]]); 687 | const isEmptyInterface = type === "{}"; 688 | if (isEmptyInterface) { 689 | return `// tslint:disable-next-line:no-empty-interface 690 | export interface ${pascal(name)}RequestBody ${type}`; 691 | } else if (type.includes("{") && !type.includes("|") && !type.includes("&")) { 692 | return `${doc}export interface ${pascal(name)}RequestBody ${type}`; 693 | } else { 694 | return `${doc}export type ${pascal(name)}RequestBody = ${type};`; 695 | } 696 | }) 697 | .join("\n\n") + 698 | "\n" 699 | ); 700 | }; 701 | 702 | /** 703 | * Extract all types from #/components/responses 704 | * 705 | * @param responses 706 | */ 707 | export const generateResponsesDefinition = (responses: ComponentsObject["responses"] = {}) => { 708 | if (isEmpty(responses)) { 709 | return ""; 710 | } 711 | 712 | return ( 713 | "\n" + 714 | Object.entries(responses) 715 | .map(([name, response]) => { 716 | const doc = isReference(response) ? "" : formatDescription(response.description); 717 | const type = getResReqTypes([["", response]]); 718 | const isEmptyInterface = type === "{}"; 719 | if (isEmptyInterface) { 720 | return `// tslint:disable-next-line:no-empty-interface 721 | export interface ${pascal(name)}Response ${type}`; 722 | } else if (type.includes("{") && !type.includes("|") && !type.includes("&")) { 723 | return `${doc}export interface ${pascal(name)}Response ${type}`; 724 | } else { 725 | return `${doc}export type ${pascal(name)}Response = ${type};`; 726 | } 727 | }) 728 | .join("\n\n") + 729 | "\n" 730 | ); 731 | }; 732 | 733 | /** 734 | * Format a description to code documentation. 735 | * 736 | * @param description 737 | */ 738 | export const formatDescription = (description?: string, tabSize = 0) => 739 | description 740 | ? `/**\n${description 741 | .split("\n") 742 | .map(i => `${" ".repeat(tabSize)} * ${i}`) 743 | .join("\n")}\n${" ".repeat(tabSize)} */\n${" ".repeat(tabSize)}` 744 | : ""; 745 | 746 | /** 747 | * Validate the spec with ibm-openapi-validator (with a custom pretty logger). 748 | * 749 | * @param specs openAPI spec 750 | */ 751 | const validate = async (specs: OpenAPIObject) => { 752 | // tslint:disable:no-console 753 | const log = console.log; 754 | 755 | // Catch the internal console.log to add some information if needed 756 | // because openApiValidator() calls console.log internally and 757 | // we want to add more context if it's used 758 | let wasConsoleLogCalledFromBlackBox = false; 759 | console.log = (...props: any) => { 760 | wasConsoleLogCalledFromBlackBox = true; 761 | log(...props); 762 | }; 763 | const { errors, warnings } = await openApiValidator(specs); 764 | console.log = log; // reset console.log because we're done with the black box 765 | 766 | if (wasConsoleLogCalledFromBlackBox) { 767 | log("More information: https://github.com/IBM/openapi-validator/#configuration"); 768 | } 769 | if (warnings.length) { 770 | log(chalk.yellow("(!) Warnings")); 771 | warnings.forEach(i => 772 | log( 773 | chalk.yellow(` 774 | Message : ${i.message} 775 | Path : ${i.path}`), 776 | ), 777 | ); 778 | } 779 | if (errors.length) { 780 | log(chalk.red("(!) Errors")); 781 | errors.forEach(i => 782 | log( 783 | chalk.red(` 784 | Message : ${i.message} 785 | Path : ${i.path}`), 786 | ), 787 | ); 788 | } 789 | // tslint:enable:no-console 790 | }; 791 | 792 | /** 793 | * Get the url encoding function to be aliased at the module scope. 794 | * This function is used to encode the path parameters. 795 | * 796 | * @param mode Either "uricomponent" or "rfc3986". "rfc3986" mode also encodes 797 | * symbols from the `!'()*` range, while "uricomponent" leaves those as is. 798 | */ 799 | const getEncodingFunction = (mode: "uriComponent" | "rfc3986") => { 800 | if (mode === "uriComponent") return "encodeURIComponent"; 801 | 802 | return `(uriComponent: string | number | boolean) => { 803 | return encodeURIComponent(uriComponent).replace( 804 | /[!'()*]/g, 805 | (c: string) => \`%\${c.charCodeAt(0).toString(16)}\`, 806 | ); 807 | };`; 808 | }; 809 | 810 | /** 811 | * Main entry of the generator. Generate restful-react component from openAPI. 812 | * 813 | * @param options.data raw data of the spec 814 | * @param options.format format of the spec 815 | * @param options.transformer custom function to transform your spec 816 | * @param options.validation validate the spec with ibm-openapi-validator tool 817 | * @param options.skipReact skip the generation of react components/hooks 818 | */ 819 | const importOpenApi = async ({ 820 | data, 821 | format, 822 | transformer, 823 | validation, 824 | skipReact, 825 | customImport, 826 | customProps, 827 | customGenerator, 828 | pathParametersEncodingMode, 829 | }: { 830 | data: string; 831 | format: "yaml" | "json"; 832 | transformer?: (specs: OpenAPIObject) => OpenAPIObject; 833 | validation?: boolean; 834 | skipReact?: boolean; 835 | customImport?: AdvancedOptions["customImport"]; 836 | customProps?: AdvancedOptions["customProps"]; 837 | customGenerator?: AdvancedOptions["customGenerator"]; 838 | pathParametersEncodingMode?: "uriComponent" | "rfc3986"; 839 | }) => { 840 | const operationIds: string[] = []; 841 | let specs = await importSpecs(data, format); 842 | if (transformer) { 843 | specs = transformer(specs); 844 | } 845 | 846 | if (validation) { 847 | await validate(specs); 848 | } 849 | 850 | resolveDiscriminator(specs); 851 | 852 | let output = ""; 853 | 854 | output += addVersionMetadata(specs.info.version); 855 | output += generateSchemasDefinition(specs.components && specs.components.schemas); 856 | output += generateRequestBodiesDefinition(specs.components && specs.components.requestBodies); 857 | output += generateResponsesDefinition(specs.components && specs.components.responses); 858 | Object.entries(specs.paths).forEach(([route, verbs]: [string, PathItemObject]) => { 859 | Object.entries(verbs).forEach(([verb, operation]: [string, OperationObject]) => { 860 | if (["get", "post", "patch", "put", "delete"].includes(verb)) { 861 | output += generateRestfulComponent( 862 | operation, 863 | verb, 864 | route, 865 | operationIds, 866 | verbs.parameters, 867 | specs.components, 868 | customProps, 869 | skipReact, 870 | pathParametersEncodingMode, 871 | customGenerator, 872 | ); 873 | } 874 | }); 875 | }); 876 | 877 | const haveGet = Boolean(output.match(/ ( 912 | strings: TemplateStringsArray, 913 | ...params: (string | number | boolean)[] 914 | ) => 915 | strings.reduce( 916 | (accumulatedPath, pathPart, idx) => 917 | \`\${accumulatedPath}\${pathPart}\${ 918 | idx < params.length ? encodingFn(params[idx]) : '' 919 | }\`, 920 | '', 921 | ); 922 | 923 | const encode = encodingTagFactory(encodingFn); 924 | 925 | `; 926 | } 927 | 928 | return outputHeaders + output; 929 | }; 930 | 931 | export default importOpenApi; 932 | --------------------------------------------------------------------------------