diff --git a/.changeset/great-poems-share.md b/.changeset/great-poems-share.md new file mode 100644 index 0000000..0ddc5af --- /dev/null +++ b/.changeset/great-poems-share.md @@ -0,0 +1,15 @@ +--- +'@apollo/datasource-rest': major +--- + +When passing `params` as an object, parameters with `undefined` values are now skipped, like with `JSON.stringify`. So you can write: + +```ts +getPost(query: string | undefined) { + return this.get('post', { params: { query } }); +} +``` + +and if `query` is not provided, the `query` parameter will be left off of the URL instead of given the value `undefined`. + +As part of this change, we've removed the ability to provide `params` in formats other than this kind of object or as an `URLSearchParams` object. Previously, we allowed every form of input that could be passed to `new URLSearchParams()`. If you were using one of the other forms (like a pre-serialized URL string or an array of two-element arrays), just pass it directly to `new URLSearchParams`; note that the feature of stripping `undefined` values will not occur in this case. For example, you can replace `this.get('post', { params: [['query', query]] })` with `this.get('post', { params: new URLSearchParams([['query', query]]) })`. (`URLSearchParams` is available in Node as a global.) diff --git a/src/RESTDataSource.ts b/src/RESTDataSource.ts index 23cdd4f..46a1afb 100644 --- a/src/RESTDataSource.ts +++ b/src/RESTDataSource.ts @@ -10,11 +10,16 @@ import type { WithRequired } from '@apollo/utils.withrequired'; type ValueOrPromise = T | Promise; -// URLSearchParams is globally available in Node / coming from @types/node -type URLSearchParamsInit = ConstructorParameters[0]; - export type RequestOptions = FetcherRequestInit & { - params?: URLSearchParamsInit; + /** + * URL search parameters can be provided either as a record object (in which + * case keys with `undefined` values are ignored) or as an URLSearchParams + * object. If you want to specify a parameter multiple times, use + * URLSearchParams with its "array of two-element arrays" constructor form. + * (The URLSearchParams object is globally available in Node, and provided to + * TypeScript by @types/node.) + */ + params?: Record | URLSearchParams; cacheOptions?: | CacheOptions | (( @@ -195,6 +200,20 @@ export abstract class RESTDataSource { return this.fetch(path, { method: 'DELETE', ...request }); } + private urlSearchParamsFromRecord( + params: Record | undefined, + ): URLSearchParams { + const usp = new URLSearchParams(); + if (params) { + for (const [name, value] of Object.entries(params)) { + if (value !== undefined && value !== null) { + usp.set(name, value); + } + } + } + return usp; + } + private async fetch( path: string, request: DataSourceRequest, @@ -205,7 +224,7 @@ export abstract class RESTDataSource { params: request.params instanceof URLSearchParams ? request.params - : new URLSearchParams(request.params), + : this.urlSearchParamsFromRecord(request.params), headers: request.headers ?? Object.create(null), body: undefined, }; @@ -216,7 +235,7 @@ export abstract class RESTDataSource { const url = await this.resolveURL(path, modifiedRequest); - // Append params to existing params in the path + // Append params from the request to any existing params in the path for (const [name, value] of modifiedRequest.params as URLSearchParams) { url.searchParams.append(name, value); } diff --git a/src/__tests__/RESTDataSource.test.ts b/src/__tests__/RESTDataSource.test.ts index 3aef2da..488c392 100644 --- a/src/__tests__/RESTDataSource.test.ts +++ b/src/__tests__/RESTDataSource.test.ts @@ -6,6 +6,7 @@ import { ForbiddenError, RequestOptions, RESTDataSource, + WillSendRequestOptions, // RequestOptions } from '../RESTDataSource'; @@ -95,7 +96,12 @@ describe('RESTDataSource', () => { getPostsForUser( username: string, - params: { filter: string; limit: number; offset: number }, + params: { + filter: string; + limit: number; + offset: number; + optional?: string; + }, ) { return this.get('posts', { params: { @@ -103,9 +109,17 @@ describe('RESTDataSource', () => { filter: params.filter, limit: params.limit.toString(), offset: params.offset.toString(), + // In the test, this is undefined and should not end up in the URL. + optional: params.optional, }, }); } + + getPostsWithURLSearchParams(username: string) { + return this.get('posts2', { + params: new URLSearchParams([['username', username]]), + }); + } })(); nock(apiUrl) @@ -118,11 +132,19 @@ describe('RESTDataSource', () => { }) .reply(200); + nock(apiUrl) + .get('/posts2') + .query({ + username: 'beyoncé', + }) + .reply(200); + await dataSource.getPostsForUser('beyoncé', { filter: 'jalapeño', limit: 10, offset: 20, }); + await dataSource.getPostsWithURLSearchParams('beyoncé'); }); it('allows setting default query string parameters', async () => { @@ -133,10 +155,8 @@ describe('RESTDataSource', () => { super(config); } - override willSendRequest(request: RequestOptions) { - const params = new URLSearchParams(request.params); - params.set('apiKey', this.token); - request.params = params; + override willSendRequest(request: WillSendRequestOptions) { + request.params.set('apiKey', this.token); } getFoo(id: string) { @@ -155,7 +175,7 @@ describe('RESTDataSource', () => { const dataSource = new (class extends RESTDataSource { override baseURL = 'https://api.example.com'; - override willSendRequest(request: RequestOptions) { + override willSendRequest(request: WillSendRequestOptions) { request.headers = { ...request.headers, credentials: 'include' }; } @@ -177,7 +197,7 @@ describe('RESTDataSource', () => { super(config); } - override willSendRequest(request: RequestOptions) { + override willSendRequest(request: WillSendRequestOptions) { request.headers = { ...request.headers, authorization: this.token }; }