Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added a New, Optional Apollo Link: HeadersLink #9

Merged
merged 12 commits into from
Dec 6, 2021
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@

All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.

## [1.2.0](https://github.com/nrccua/apollo-rest-utils/compare/1.1.1...1.2.0) (2021-12-06)


### Changes

* [E4E-0]: Flesh out unit tests ([5668f6b](https://github.com/nrccua/apollo-rest-utils/commit/5668f6b24a25e394865225ac3f8e0dd013a3007f))
* Merge branch 'main' into feature/headersLink ([991c60f](https://github.com/nrccua/apollo-rest-utils/commit/991c60f103428a30d1c24690a537fe0bc46b6dab))
* Merge pull request #10 from nrccua/fix/swagger_2_wordpress_fixes ([eea1539](https://github.com/nrccua/apollo-rest-utils/commit/eea1539cb6684a543a5a12b4143405f445b7fe29)), closes [#10](https://github.com/nrccua/apollo-rest-utils/issues/10)
* [E4E-0]: 1.1.1 ([81b1e1d](https://github.com/nrccua/apollo-rest-utils/commit/81b1e1d9c262314f91501f920e2ed3e24f7da125))
* [E4E-14]: Add eslint as a devDependency ([66613e4](https://github.com/nrccua/apollo-rest-utils/commit/66613e40b67d7151d414f9123333b1529af4c65b))

### [1.1.1](https://github.com/nrccua/apollo-rest-utils/compare/1.1.0...1.1.1) (2021-12-03)


Expand Down
77 changes: 69 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,16 @@ with REST apis and Apollo in a TypeScript application.

## Features

* A command line utiltity that takes a swagger file/url, and automatically
- A command line utiltity that takes a swagger file/url, and automatically
generates input and output types, and endpoint definitions that can be used
to make integration with `apollo-link-rest` much easier.
* Wrapper functions for common GraphQL operations that allow you to pass in
- Wrapper functions for common GraphQL operations that allow you to pass in
pure GraphQL, and enables the input variables and the result to be strongly
typed based on the swagger definition.
* Automatically checks your GraphQL at runtime and will throw exceptions if
- Automatically checks your GraphQL at runtime and will throw exceptions if
your GraphQL fields do not match the endpoint definition.
- Custom apollo links to cover REST API edge cases, such as using the
`headersLink` to retrieve data from REST response headers.

## Usage

Expand Down Expand Up @@ -69,18 +71,77 @@ To facilitate using this with multiple endpoints, you must specify an endpoint
id per endpoint. See
[https://www.apollographql.com/docs/react/api/link/apollo-link-rest/#multiple-endpoints](https://www.apollographql.com/docs/react/api/link/apollo-link-rest/#multiple-endpoints)

## Custom Links

### `HeadersLink`

This link allows you to access the REST API's response headers within the
`data` object that comes back from Apollo Client.

NOTE: Since GraphQL only accepts field names written in `camelCase`, the headers
should be requested in camel case format. I.e. if your REST API returns a
`total-count` header, you'll want to ask for it using `totalCount`.

#### `HeadersLink` Setup

```ts
import { RestLink } from 'apollo-link-rest';
import { HeadersLink } from 'apollo-rest-utils';

const headersLink = new HeadersLink();
const restLink = new RestLink({ ... });

new ApolloClient({
...
cache: new InMemoryCache({ ... }),
link: ApolloLink.from([headersLink, restLink]),
...
});
```

#### `HeadersLink` Usage

```ts
const { data } = wrapRestQuery<'something'>()(
gql`
query SomethingQuery($id: String!) {
something(id: $id) {
_id
fieldA
fieldB
fieldC
}
headers {
nextOffset
totalCount
totalPages
}
}
`,
{
endpoint: REST.GET.SOMETHING,
variables: {
id,
},
},
);

console.log(data?.something); // response data
console.log(data?.headers); // response headers
```

## Releasing

Checking in the dist folder is not necessary as it will be built upon
npm install by the downstream project.

After making any changes and merging them to main, please do the following:

* Create a new branch from main and run `npm run update:version`
* Verify the `CHANGELOG.md` generated changes
* Commit, push, and merge to main.
* Create a new
- Create a new branch from main and run `npm run update:version`
- Verify the `CHANGELOG.md` generated changes
- Commit, push, and merge to main.
- Create a new
[release](https://github.com/nrccua/apollo-rest-utils/releases/new) using
the tag generated in the previous steps
* Use the `Auto-generate release notes` button to generate the release notes,
- Use the `Auto-generate release notes` button to generate the release notes,
and add any context you may deem necessary.
46 changes: 46 additions & 0 deletions lib/HeadersLink/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { NextLink, Operation } from '@apollo/client';
import HeadersLink from '.';

describe('HeadersLink', () => {
const mockForward = jest.fn();
const mockGetContext = jest.fn();
const mockOperation = {
getContext: mockGetContext,
} as unknown as Operation;

beforeEach(() => {
jest.resetAllMocks();
mockForward.mockReturnValue([{}]);
mockGetContext.mockReturnValue({
restResponses: [{}],
});
});

afterAll(() => {
jest.restoreAllMocks();
});

it('can handle requests without headers', () => {
const headersLink = new HeadersLink();

const result = headersLink.request(mockOperation, mockForward as unknown as NextLink);

expect(result).toEqual([{ data: { headers: {} } }]);
});

it('can handle requests with headers', () => {
mockGetContext.mockReturnValue({
restResponses: [
{
headers: [['x-api-key', '12345']],
},
],
});

const headersLink = new HeadersLink();

const result = headersLink.request(mockOperation, mockForward as unknown as NextLink);

expect(result).toEqual([{ data: { headers: { xApiKey: '12345' } } }]);
});
});
29 changes: 29 additions & 0 deletions lib/HeadersLink/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { ApolloLink, FetchResult, NextLink, Observable, Operation } from '@apollo/client';
import { camelCase, get } from 'lodash';

export class HeadersLink extends ApolloLink {
// eslint-disable-next-line class-methods-use-this
public request(operation: Operation, forward: NextLink): Observable<FetchResult> | null {
return forward(operation).map((response): Record<string, unknown> => {
const context = operation.getContext();

const headersMap = (get(context, 'restResponses[0].headers') as Map<string, unknown>) || new Map();
const headersObj: Record<string, unknown> = Object.fromEntries(headersMap);

const headersObjCamelCased: Record<string, unknown> = {};
Object.keys(headersObj).forEach((key): void => {
headersObjCamelCased[camelCase(key)] = headersObj[key];
});

return {
...response,
data: {
...response.data,
headers: headersObjCamelCased,
},
};
});
}
}

export default HeadersLink;
1 change: 1 addition & 0 deletions lib/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './HeadersLink';
export * from './types';
export * from './useRestQuery';
13 changes: 13 additions & 0 deletions lib/useRestQuery/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,19 @@ describe('validateQueryAgainstEndpoint', () => {
expect(() => validateQueryAgainstEndpoint(query, dummyEndpoint)).not.toThrowError();
});

it('should not throw an error for a valid query with headers', () => {
const query = gql`
query TestQuery($input: input) {
refreshToken(input: $input) {
sessionToken
}
headers
}
`;

expect(() => validateQueryAgainstEndpoint(query, dummyEndpoint)).not.toThrowError();
});

it('should throw an error for a query with no definitions', () => {
const query = { definitions: [], kind: 'Document' } as DocumentNode;

Expand Down
12 changes: 11 additions & 1 deletion lib/useRestQuery/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
useQuery,
} from '@apollo/client';
import { DirectiveNode, FieldNode, OperationDefinitionNode } from 'graphql';
import { get } from 'lodash';

import { IEndpointOptions, getSchemaField, Input, InvalidQueryError, IRestEndpoint, NamedGQLResult } from '../types';

Expand All @@ -33,7 +34,16 @@ export function validateQueryAgainstEndpoint<TName extends string, TData = unkno
}

if (definition.selectionSet.selections.length !== 1) {
throw new InvalidQueryError('Query must contain exactly one selection', query, endpoint);
let selectionsIncludeHeaders = false;
definition.selectionSet.selections.forEach((selection): void => {
if (get(selection, 'name.value') === 'headers') {
selectionsIncludeHeaders = true;
}
});

if (!(selectionsIncludeHeaders && definition.selectionSet.selections.length === 2)) {
throw new InvalidQueryError('Query must contain exactly one selection, or one selection with headers (if using the HeadersLink)', query, endpoint);
}
}

const selection = definition.selectionSet.selections[0] as FieldNode;
Expand Down
31 changes: 15 additions & 16 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"@types/react": "17.0.35",
"babel-plugin-root-import": "5.1.0",
"babel-plugin-transform-imports": "2.0.0",
"eslint": "8.3.0",
"eslint-import-resolver-babel-plugin-root-import": "1.1.1",
"eslint-plugin-react": "7.27.1",
"husky": "7.0.4",
Expand Down Expand Up @@ -67,5 +68,5 @@
"update:version:major": "standard-version --release-as major",
"update:version:minor": "standard-version --release-as minor"
},
"version": "1.1.1"
"version": "1.2.0"
}