diff --git a/docs/guides/custom-networking.md b/docs/guides/custom-networking.md index 0654e9dcf9d..f193edec098 100644 --- a/docs/guides/custom-networking.md +++ b/docs/guides/custom-networking.md @@ -4,45 +4,66 @@ sidebar_label: Custom networking library --- The default `fetch()` implementation uses [superagent]() due to it's server-side support and excellent builder-pattern api. However, a simple overriding of the `fetch()` function -will enable you to use any networking library you please. +will enable you to use any networking library you please. By extending from `SimpleResource` +instead of `Resource` you can potentially reduce your bundle size by enabling the superagent +library to be tree-shaken away. ### Default fetch with superagent: ```typescript +import request from 'superagent'; +import { Method } from '~/types'; + +import SimpleResource from './SimpleResource'; + +/** Represents an entity to be retrieved from a server. Typically 1:1 with a url endpoint. */ +export default abstract class Resource extends SimpleResource { + /** A function to mutate all requests for fetch */ + static fetchPlugin?: request.Plugin; + /** Perform network request and resolve with json body */ - static async fetch( + static fetch( this: T, - method: Method = 'get', + method: Method, url: string, body?: Readonly, ) { let req = request[method](url).on('error', () => {}); if (this.fetchPlugin) req = req.use(this.fetchPlugin); if (body) req = req.send(body); - const json = (await req).body; - return json; + return req.then(res => { + if (process.env.NODE_ENV !== 'production') { + if (!res.type.includes('json') && Object.keys(res.body).length === 0) { + throw new Error('JSON expected but not returned from API'); + } + } + return res.body; + }); } +} ``` Here are examples using other popular networking APIs: -## [Fetch](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) +## Fetch + +[Fetch](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) ```typescript -import { Resource, Method } from 'rest-hooks'; +import { SimpleResource, Method } from 'rest-hooks'; -export default abstract class FetchResource extends Resource { +export default abstract class FetchResource extends SimpleResource { /** A function to mutate all request options for fetch */ static fetchOptionsPlugin?: (options: RequestInit) => RequestInit; /** Perform network request and resolve with json body */ - static async fetch( + static async fetch( this: T, - method: Method = 'get', + method: Method, url: string, body?: Readonly ) { - let options = { + let options: RequestInit = { method: method.toUpperCase(), credentials: 'same-origin', headers: { @@ -59,17 +80,19 @@ export default abstract class FetchResource extends Resource { } ``` -## [Axios](https://github.com/axios/axios) +## Axios + +[Axios](https://github.com/axios/axios) ```typescript -import { Resource, Method } from 'rest-hooks'; +import { SimpleResource, Method } from 'rest-hooks'; import axios from 'axios'; -export default abstract class AxiosResource extends Resource { +export default abstract class AxiosResource extends SimpleResource { /** Perform network request and resolve with json body */ - static async fetch( + static async fetch( this: T, - method: Method = 'get', + method: Method, url: string, body?: Readonly ) { diff --git a/src/index.ts b/src/index.ts index 122d2cb8fd5..2b3bd92d47d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,7 @@ import { Resource, + SimpleResource, + SuperagentResource, FetchShape, DeleteShape, ReadShape, @@ -88,6 +90,8 @@ export type Manager = Manager; export { Resource, + SimpleResource, + SuperagentResource, CacheProvider, ExternalCacheProvider, useCache, diff --git a/src/resource/Resource.ts b/src/resource/Resource.ts index ec9f519cd61..d9844408c18 100644 --- a/src/resource/Resource.ts +++ b/src/resource/Resource.ts @@ -1,200 +1,12 @@ import request from 'superagent'; -import { memoize } from 'lodash'; -import { AbstractInstanceType, Method, RequestOptions } from '~/types'; +import { Method } from '~/types'; -import { ReadShape, MutateShape, DeleteShape } from './types'; -import { schemas, SchemaDetail, SchemaList } from './normal'; - -const getEntitySchema: ( - M: T, -) => schemas.Entity> = memoize( - (M: T) => { - const e = new schemas.Entity( - M.getKey(), - {}, - { - idAttribute: (value, parent, key) => { - const id = M.pk(value) || key; - if (process.env.NODE_ENV !== 'production' && id === null) { - throw new Error( - `Missing usable resource key when normalizing response. - -This is likely due to a malformed response. -Try inspecting the network response or fetch() return value. -`, - ); - } - return id.toString(); - }, - processStrategy: value => { - return M.fromJS(value); - }, - mergeStrategy: ( - a: AbstractInstanceType, - b: AbstractInstanceType, - ) => (a.constructor as T).merge(a, b), - }, - ); - // TODO: long term figure out a plan to actually denormalize - (e as any).denormalize = function denormalize(entity: any) { - return entity; - }; - return e; - }, -) as any; - -const DefinedMembersKey = Symbol('Defined Members'); -type Filter = T extends U ? T : never; -interface ResourceMembers { - [DefinedMembersKey]: (Filter, string>)[]; -} +import SimpleResource from './SimpleResource'; /** Represents an entity to be retrieved from a server. Typically 1:1 with a url endpoint. */ -export default abstract class Resource { - // typescript todo: require subclasses to implement - /** Used as base of url construction */ - static readonly urlRoot: string; +export default abstract class Resource extends SimpleResource { /** A function to mutate all requests for fetch */ static fetchPlugin?: request.Plugin; - /** A unique identifier for this Resource */ - abstract pk(): string | number | null; - - /** Resource factory. Takes an object of properties to assign to Resource. */ - static fromJS( - this: T, - props: Partial>, - ) { - if (this === Resource) - throw new Error('cannot construct on abstract types'); - // we type guarded abstract case above, so ok to force typescript to allow constructor call - const instance = new (this as any)(props) as AbstractInstanceType; - - Object.defineProperty(instance, DefinedMembersKey, { - value: Object.keys(props), - writable: false, - }); - - Object.assign(instance, props); - - // to trick normalizr into thinking we're Immutable.js does it doesn't copy - Object.defineProperty(instance, '__ownerID', { - value: 1337, - writable: false, - }); - return instance; - } - - /** Creates new instance copying over defined values of arguments */ - static merge( - this: T, - first: AbstractInstanceType, - second: AbstractInstanceType, - ) { - const props = Object.assign( - {}, - this.toObjectDefined(first), - this.toObjectDefined(second), - ); - return this.fromJS(props); - } - - /** Whether key is non-default */ - static hasDefined( - this: T, - instance: AbstractInstanceType, - key: Filter, string>, - ) { - return ((instance as any) as ResourceMembers)[ - DefinedMembersKey - ].includes(key); - } - - /** Returns simple object with all the non-default members */ - static toObjectDefined( - this: T, - instance: AbstractInstanceType, - ) { - const defined: Partial> = {}; - for (const member of ((instance as any) as ResourceMembers)[ - DefinedMembersKey - ]) { - defined[member] = instance[member]; - } - return defined; - } - - /** Returns array of all keys that have values defined in instance */ - static keysDefined( - this: T, - instance: AbstractInstanceType, - ) { - return ((instance as any) as ResourceMembers)[DefinedMembersKey]; - } - - static toString(this: T) { - return `${this.name}::${this.urlRoot}`; - } - - /** Returns the globally unique identifier for this Resource */ - static getKey(this: T) { - return this.urlRoot; - } - - /** A unique identifier for this Resource */ - static pk( - this: T, - params: Partial>, - ): string | number | null { - return this.prototype.pk.call(params); - } - - /** URL to find this Resource */ - get url(): string { - if (this.__url !== undefined) return this.__url; - // typescript thinks constructor is just a function - const Static = this.constructor as typeof Resource; - return Static.url(this); - } - private __url?: string; - - /** Get the url for a Resource - * - * Default implementation conforms to commoon REST patterns - */ - static url( - this: T, - urlParams?: Partial>, - ): string { - if (urlParams) { - if ( - urlParams.hasOwnProperty('url') && - urlParams.url && - typeof urlParams.url === 'string' - ) { - return urlParams.url; - } - if (this.pk(urlParams) !== null) { - return `${this.urlRoot}${this.pk(urlParams)}`; - } - } - return this.urlRoot; - } - - /** Get the url for many Resources - * - * Default implementation conforms to common REST patterns - */ - static listUrl( - this: T, - searchParams?: Readonly>, - ): string { - if (searchParams && Object.keys(searchParams).length) { - const params = new URLSearchParams(searchParams as any); - params.sort(); - return `${this.urlRoot}?${params.toString()}`; - } - return this.urlRoot; - } /** Perform network request and resolve with json body */ static fetch( @@ -215,163 +27,4 @@ export default abstract class Resource { return res.body; }); } - - /** Get the entity schema defining */ - static getEntitySchema( - this: T, - ): schemas.Entity> { - return getEntitySchema(this); - } - - /** Get the request options for this resource */ - static getRequestOptions( - this: T, - ): RequestOptions | undefined { - return; - } - - // TODO: memoize these so they can be referentially compared - /** Shape to get a single entity */ - static detailShape( - this: T, - ): ReadShape>> { - const self = this; - const getFetchKey = (params: Readonly) => { - return 'GET ' + this.url(params); - }; - const schema: SchemaDetail< - AbstractInstanceType - > = this.getEntitySchema(); - const options = this.getRequestOptions(); - return { - type: 'read', - schema, - options, - getFetchKey, - fetch(params: Readonly, body?: Readonly) { - return self.fetch('get', self.url(params), body); - }, - }; - } - - /** Shape to get a list of entities */ - static listShape( - this: T, - ): ReadShape>> { - const self = this; - const getFetchKey = (params: Readonly>) => { - return 'GET ' + this.listUrl(params); - }; - const schema: SchemaList> = [ - this.getEntitySchema(), - ]; - const options = this.getRequestOptions(); - return { - type: 'read', - schema, - options, - getFetchKey, - fetch( - params: Readonly>, - body?: Readonly, - ) { - return self.fetch('get', self.listUrl(params), body); - }, - }; - } - /** Shape to create a new entity (post) */ - static createShape( - this: T, - ): MutateShape< - SchemaDetail>, - Readonly, - Partial> - > { - const self = this; - const options = this.getRequestOptions(); - return { - type: 'mutate', - schema: self.getEntitySchema(), - options, - getFetchKey(params: Readonly>) { - return 'POST ' + self.listUrl(params); - }, - fetch( - params: Readonly>, - body: Partial>, - ) { - return self.fetch('post', self.listUrl(params), body); - }, - }; - } - /** Shape to update an existing entity (put) */ - static updateShape( - this: T, - ): MutateShape< - SchemaDetail>, - Readonly, - Partial> - > { - const self = this; - const options = this.getRequestOptions(); - return { - type: 'mutate', - schema: self.getEntitySchema(), - options, - getFetchKey(params: object) { - return 'PUT ' + self.url(params); - }, - fetch(params: Readonly, body: Partial>) { - return self.fetch('put', self.url(params), body); - }, - }; - } - /** Shape to update a subset of fields of an existing entity (patch) */ - static partialUpdateShape( - this: T, - ): MutateShape< - SchemaDetail>, - Readonly, - Partial> - > { - const self = this; - const options = this.getRequestOptions(); - return { - type: 'mutate', - schema: self.getEntitySchema(), //TODO: change merge strategy in case we want to handle partial returns - options, - getFetchKey(params: Readonly) { - return 'PATCH ' + self.url(params); - }, - fetch(params: Readonly, body: Partial>) { - return self.fetch('patch', self.url(params), body); - }, - }; - } - /** Shape to delete an entity (delete) */ - static deleteShape( - this: T, - ): DeleteShape>, Readonly> { - const self = this; - const options = this.getRequestOptions(); - return { - type: 'delete', - schema: self.getEntitySchema(), - options, - getFetchKey(params: object) { - return 'DELETE ' + self.url(params); - }, - fetch(params: Readonly) { - return self.fetch('delete', self.url(params)); - }, - }; - } } - -// We're only allowing this to get set for descendants but -// by default we want Typescript to treat it as readonly. -Object.defineProperty(Resource.prototype, 'url', { - set(url: string) { - this.__url = url; - }, -}); diff --git a/src/resource/SimpleResource.ts b/src/resource/SimpleResource.ts new file mode 100644 index 00000000000..b440a7ccb8a --- /dev/null +++ b/src/resource/SimpleResource.ts @@ -0,0 +1,366 @@ +import { memoize } from 'lodash'; +import { AbstractInstanceType, Method, RequestOptions } from '~/types'; + +import { ReadShape, MutateShape, DeleteShape } from './types'; +import { schemas, SchemaDetail, SchemaList } from './normal'; + +const getEntitySchema: ( + M: T, +) => schemas.Entity> = memoize( + (M: T) => { + const e = new schemas.Entity( + M.getKey(), + {}, + { + idAttribute: (value, parent, key) => { + const id = M.pk(value) || key; + if (process.env.NODE_ENV !== 'production' && id === null) { + throw new Error( + `Missing usable resource key when normalizing response. + +This is likely due to a malformed response. +Try inspecting the network response or fetch() return value. +`, + ); + } + return id.toString(); + }, + processStrategy: value => { + return M.fromJS(value); + }, + mergeStrategy: ( + a: AbstractInstanceType, + b: AbstractInstanceType, + ) => (a.constructor as T).merge(a, b), + }, + ); + // TODO: long term figure out a plan to actually denormalize + (e as any).denormalize = function denormalize(entity: any) { + return entity; + }; + return e; + }, +) as any; + +const DefinedMembersKey = Symbol('Defined Members'); +type Filter = T extends U ? T : never; +interface SimpleResourceMembers { + [DefinedMembersKey]: (Filter, string>)[]; +} + +/** Represents an entity to be retrieved from a server. Typically 1:1 with a url endpoint. */ +export default abstract class SimpleResource { + // typescript todo: require subclasses to implement + /** Used as base of url construction */ + static readonly urlRoot: string; + /** A unique identifier for this SimpleResource */ + abstract pk(): string | number | null; + + /** SimpleResource factory. Takes an object of properties to assign to SimpleResource. */ + static fromJS( + this: T, + props: Partial>, + ) { + // we type guarded abstract case above, so ok to force typescript to allow constructor call + const instance = new (this as any)(props) as AbstractInstanceType; + + if (instance.pk === undefined) + throw new Error('cannot construct on abstract types'); + + Object.defineProperty(instance, DefinedMembersKey, { + value: Object.keys(props), + writable: false, + }); + + Object.assign(instance, props); + + // to trick normalizr into thinking we're Immutable.js does it doesn't copy + Object.defineProperty(instance, '__ownerID', { + value: 1337, + writable: false, + }); + return instance; + } + + /** Creates new instance copying over defined values of arguments */ + static merge( + this: T, + first: AbstractInstanceType, + second: AbstractInstanceType, + ) { + const props = Object.assign( + {}, + this.toObjectDefined(first), + this.toObjectDefined(second), + ); + return this.fromJS(props); + } + + /** Whether key is non-default */ + static hasDefined( + this: T, + instance: AbstractInstanceType, + key: Filter, string>, + ) { + return ((instance as any) as SimpleResourceMembers)[ + DefinedMembersKey + ].includes(key); + } + + /** Returns simple object with all the non-default members */ + static toObjectDefined( + this: T, + instance: AbstractInstanceType, + ) { + const defined: Partial> = {}; + for (const member of ((instance as any) as SimpleResourceMembers)[ + DefinedMembersKey + ]) { + defined[member] = instance[member]; + } + return defined; + } + + /** Returns array of all keys that have values defined in instance */ + static keysDefined( + this: T, + instance: AbstractInstanceType, + ) { + return ((instance as any) as SimpleResourceMembers)[DefinedMembersKey]; + } + + static toString(this: T) { + return `${this.name}::${this.urlRoot}`; + } + + /** Returns the globally unique identifier for this SimpleResource */ + static getKey(this: T) { + return this.urlRoot; + } + + /** A unique identifier for this SimpleResource */ + static pk( + this: T, + params: Partial>, + ): string | number | null { + return this.prototype.pk.call(params); + } + + /** URL to find this SimpleResource */ + get url(): string { + if (this.__url !== undefined) return this.__url; + // typescript thinks constructor is just a function + const Static = this.constructor as typeof SimpleResource; + return Static.url(this); + } + private __url?: string; + + /** Get the url for a SimpleResource + * + * Default implementation conforms to commoon REST patterns + */ + static url( + this: T, + urlParams?: Partial>, + ): string { + if (urlParams) { + if ( + urlParams.hasOwnProperty('url') && + urlParams.url && + typeof urlParams.url === 'string' + ) { + return urlParams.url; + } + if (this.pk(urlParams) !== null) { + return `${this.urlRoot}${this.pk(urlParams)}`; + } + } + return this.urlRoot; + } + + /** Get the url for many SimpleResources + * + * Default implementation conforms to common REST patterns + */ + static listUrl( + this: T, + searchParams?: Readonly>, + ): string { + if (searchParams && Object.keys(searchParams).length) { + const params = new URLSearchParams(searchParams as any); + params.sort(); + return `${this.urlRoot}?${params.toString()}`; + } + return this.urlRoot; + } + + /** Perform network request and resolve with json body */ + static fetch( + this: T, + method: Method, + url: string, + body?: Readonly, + ): Promise { + // typescript currently doesn't allow abstract static methods + throw new Error('not implemented'); + } + + /** Get the entity schema defining */ + static getEntitySchema( + this: T, + ): schemas.Entity> { + return getEntitySchema(this); + } + + /** Get the request options for this SimpleResource */ + static getRequestOptions( + this: T, + ): RequestOptions | undefined { + return; + } + + // TODO: memoize these so they can be referentially compared + /** Shape to get a single entity */ + static detailShape( + this: T, + ): ReadShape>> { + const self = this; + const getFetchKey = (params: Readonly) => { + return 'GET ' + this.url(params); + }; + const schema: SchemaDetail< + AbstractInstanceType + > = this.getEntitySchema(); + const options = this.getRequestOptions(); + return { + type: 'read', + schema, + options, + getFetchKey, + fetch(params: Readonly, body?: Readonly) { + return self.fetch('get', self.url(params), body); + }, + }; + } + + /** Shape to get a list of entities */ + static listShape( + this: T, + ): ReadShape>> { + const self = this; + const getFetchKey = (params: Readonly>) => { + return 'GET ' + this.listUrl(params); + }; + const schema: SchemaList> = [ + this.getEntitySchema(), + ]; + const options = this.getRequestOptions(); + return { + type: 'read', + schema, + options, + getFetchKey, + fetch( + params: Readonly>, + body?: Readonly, + ) { + return self.fetch('get', self.listUrl(params), body); + }, + }; + } + /** Shape to create a new entity (post) */ + static createShape( + this: T, + ): MutateShape< + SchemaDetail>, + Readonly, + Partial> + > { + const self = this; + const options = this.getRequestOptions(); + return { + type: 'mutate', + schema: self.getEntitySchema(), + options, + getFetchKey(params: Readonly>) { + return 'POST ' + self.listUrl(params); + }, + fetch( + params: Readonly>, + body: Partial>, + ) { + return self.fetch('post', self.listUrl(params), body); + }, + }; + } + /** Shape to update an existing entity (put) */ + static updateShape( + this: T, + ): MutateShape< + SchemaDetail>, + Readonly, + Partial> + > { + const self = this; + const options = this.getRequestOptions(); + return { + type: 'mutate', + schema: self.getEntitySchema(), + options, + getFetchKey(params: object) { + return 'PUT ' + self.url(params); + }, + fetch(params: Readonly, body: Partial>) { + return self.fetch('put', self.url(params), body); + }, + }; + } + /** Shape to update a subset of fields of an existing entity (patch) */ + static partialUpdateShape( + this: T, + ): MutateShape< + SchemaDetail>, + Readonly, + Partial> + > { + const self = this; + const options = this.getRequestOptions(); + return { + type: 'mutate', + schema: self.getEntitySchema(), //TODO: change merge strategy in case we want to handle partial returns + options, + getFetchKey(params: Readonly) { + return 'PATCH ' + self.url(params); + }, + fetch(params: Readonly, body: Partial>) { + return self.fetch('patch', self.url(params), body); + }, + }; + } + /** Shape to delete an entity (delete) */ + static deleteShape( + this: T, + ): DeleteShape>, Readonly> { + const self = this; + const options = this.getRequestOptions(); + return { + type: 'delete', + schema: self.getEntitySchema(), + options, + getFetchKey(params: object) { + return 'DELETE ' + self.url(params); + }, + fetch(params: Readonly) { + return self.fetch('delete', self.url(params)); + }, + }; + } +} + +// We're only allowing this to get set for descendants but +// by default we want Typescript to treat it as readonly. +Object.defineProperty(SimpleResource.prototype, 'url', { + set(url: string) { + this.__url = url; + }, +}); diff --git a/src/resource/__tests__/resource.ts b/src/resource/__tests__/resource.ts index 9c15ddc0178..7075e46a266 100644 --- a/src/resource/__tests__/resource.ts +++ b/src/resource/__tests__/resource.ts @@ -5,7 +5,7 @@ import { UserResource, UrlArticleResource, } from '../../__tests__/common'; -import { Resource, normalize } from '..'; +import { Resource, normalize, SimpleResource } from '..'; describe('Resource', () => { it('should init', () => { @@ -219,6 +219,16 @@ describe('Resource', () => { .intercept('/article-cooler/5', 'DELETE') .reply(200, {}); }); + it('should throw with SimpleResource', () => { + expect(() => + SimpleResource.fetch( + 'get', + CoolerArticleResource.url({ + id: payload.id, + }), + ), + ).toThrow(); + }); it('should GET', async () => { const article = await CoolerArticleResource.fetch( 'get', diff --git a/src/resource/index.ts b/src/resource/index.ts index 09954e59652..4c11c5077da 100644 --- a/src/resource/index.ts +++ b/src/resource/index.ts @@ -1,4 +1,5 @@ import Resource from './Resource'; +import SimpleResource from './SimpleResource'; import { SchemaOf, DeleteShape, @@ -42,5 +43,15 @@ export type SchemaOf = SchemaOf; export type SchemaList = SchemaList; export type SchemaDetail = SchemaDetail; export type RequestResource = RequestResource; +const SuperagentResource = Resource; -export { Resource, isEntity, normalize, denormalize, isDeleteShape, schemas }; +export { + Resource, + SimpleResource, + SuperagentResource, + isEntity, + normalize, + denormalize, + isDeleteShape, + schemas, +};