Skip to content

Commit

Permalink
Path Variable Syntax & PathBuilder Signature Changes & Scalar Data Su…
Browse files Browse the repository at this point in the history
…pport Fixes

Fixes #129, #130, #131, #132
  • Loading branch information
fbartho committed Jul 16, 2018
1 parent a1f2d8b commit 8c04410
Show file tree
Hide file tree
Showing 5 changed files with 312 additions and 81 deletions.
9 changes: 9 additions & 0 deletions CHANGELOG.md
Expand Up @@ -4,6 +4,15 @@

### v0.next

### v0.4.0

Breaking changes around `path`-variable replacement and `pathBuilder` (previously undocumented, [#132](https://github.com/apollographql/apollo-link-rest/issues/132)).

* Breaking Change: paths now have a new style for variable replacement. (Old style is marked as deprecated, but will still work until v0.5.0). The migration should be easy in most cases /path/:foo => /path/{args.foo}
* Breaking Change: pathBuilder signature changes to give them access to context & other data [#131](https://github.com/apollographql/apollo-link-rest/issues/131) and support optional Values [#130](https://github.com/apollographql/apollo-link-rest/issues/130)
* Breaking Change: BodyBuilder signature changes to give them access to context & other data (for consistency with pathBuilder)
* Fix/Feature: Queries that fetch Scalar values or Arrays of scalar values should now work! [#129](https://github.com/apollographql/apollo-link-rest/issues/129)

### v0.3.1

* Fix: Fetch Response bodies can only be "read" once after which they throw "Already Read" -- this prevented us from properly speculatively parsing the error bodies outside of a test environment. [#122](https://github.com/apollographql/apollo-link-rest/issues/122)
Expand Down
44 changes: 38 additions & 6 deletions docs/rest.md
Expand Up @@ -363,9 +363,10 @@ The rest directive could be used at any depth in a query, but once it is used, n
An `@rest(…)` directive takes two required and several optional arguments:

* `type: string`: The GraphQL type this will return
* `path: string`: uri-path to the REST API. This could be a path or a full url. If a path, the endpoint given on link creation or from the context is concatenated with it to produce a full `URI`.
* `path: string`: uri-path to the REST API. This could be a path or a full url. If a path, the endpoint given on link creation or from the context is concatenated with it to produce a full `URI`. See also: `pathBuilder
* _optional_ `method?: "GET" | "PUT" | "POST" | "DELETE"`: the HTTP method to send the request via (i.e GET, PUT, POST)
* _optional_ `endpoint?: string` key to use when looking up the endpoint in the (optional) `endpoints` table if provided to RestLink at creation time.
* _optional_ `pathBuilder?: /function/`: If provided, this function gets to control what path is produced for this request.
* _optional_ `bodyKey?: string = "input"`: This is the name of the `variable` to use when looking to build a REST request-body for a `PUT` or `POST` request. It defaults to `input` if not supplied.
* _optional_ `bodyBuilder?: /function/`: If provided, this is the name a `function` that you provided to `variables`, that is called when a request-body needs to be built. This lets you combine arguments or encode the body in some format other than JSON.
* _optional_ `bodySerializer?: /string | function/`: string key to look up a function in `bodySerializers` or a custom serialization function for the body/headers of this request before it is passed ot the fetch call. Defaults to `JSON.stringify` and setting `Content-Type: application-json`.
Expand All @@ -376,13 +377,44 @@ You can use query `variables` inside nested queries, or in the the path argument

```graphql
query postTitle {
post(id: "1") @rest(type: "Post", path: "/post/:id") {
post(id: "1") @rest(type: "Post", path: "/post/{args.id}") {
id
title
}
}
```

*Warning*: Variables in the main path will not automatically have `encodeURIComponent` called on them

Additionally, you can also control the query-string:

```graphql
query postTitle {
postSearch(query: "some key words", page_size: 5)
@rest(type: "Post", path: "/search?{args}&{context.language}") {
id
title
}
}
```

Things to note:

1. This will be converted into `/search?query=some%20key%20words&page_size=5&lang=en`
2. The `context.language / lang=en` is extracting an object from the Apollo Context, that was added via an `apollo-link-context` Link.
3. The query string arguments are assembled by npm:qs and have `encodeURIComponent` called on them.

The available variable sources are:

* `args` these are the things passed directly to this field parameters. In the above example `postSearch` had `query` and `page_size` in args.
* `exportVariables` these are the things in the parent context that were tagged as `@export(as: ...)`
* `context` these are the apollo-context, so you can have globals set up via `apollo-link-context`
* `@rest` these include any other parameters you pass to the `@rest()` directive. This is probably more useful when working with `pathBuilder`, documented below.

<h4 id="rest.arguments.pathBuilder">`pathBuilder`</h4>

If the variable-replacement options described above aren't enough, you can provide a `pathBuilder` to your query. This will be called to dynamically construct the path. This is considered an advanced feature, and is documented in the source -- it also should be considered syntactically unstable, and we're looking for feedback!

<h4 id="rest.arguments.body">`bodyKey` / `bodyBuilder`</h4>

When making a `POST` or `PUT` HTTP request, you often need to provide a request body. By [convention](https://graphql.org/graphql-js/mutations-and-input-types/), GraphQL recommends you name your input-types as `input`, so by default that's where we'll look to find a JSON object for your body.
Expand All @@ -400,7 +432,7 @@ mutation publishPost(
publishedPost: publish(input: "Foo", body: $someApiWithACustomBodyKey)
@rest(
type: "Post"
path: "/posts/:input/new"
path: "/posts/{args.input}/new"
method: "POST"
bodyKey: "body"
) {
Expand Down Expand Up @@ -515,11 +547,11 @@ An example use-case would be getting a list of users, and hitting a different en
```graphql
const QUERY = gql`
query RestData($email: String!) {
users @rest(path: '/users/email/:email', params: { email: $email }, method: 'GET', type: 'User') {
users @rest(path: '/users/email?{args.email}', method: 'GET', type: 'User') {
id @export(as: "id")
firstName
lastName
friends @rest(path: '/friends/:id', params: { id: $id }, type: '[User]') {
friends @rest(path: '/friends/{exportVariables.id}', type: '[User]') {
firstName
lastName
}
Expand All @@ -535,7 +567,7 @@ You can write also mutations with the apollo-link-rest, for example:
```graphql
mutation deletePost($id: ID!) {
deletePostResponse(id: $id)
@rest(type: "Post", path: "/posts/:id", method: "DELETE") {
@rest(type: "Post", path: "/posts/{args.id}", method: "DELETE") {
NoResponse
}
}
Expand Down
5 changes: 4 additions & 1 deletion package.json
Expand Up @@ -41,12 +41,14 @@
"peerDependencies": {
"apollo-link": ">=1",
"graphql": ">=0.11",
"graphql-anywhere": ">=4"
"graphql-anywhere": ">=4",
"qs": ">=6"
},
"devDependencies": {
"@types/graphql": "0.13.x",
"@types/jest": "23.x",
"@types/node": "10.3.x",
"@types/qs": "6.5.x",
"apollo-cache-inmemory": "1.2.x",
"apollo-client": "2.3.x",
"apollo-link": "1.2.x",
Expand All @@ -68,6 +70,7 @@
"lodash": "4.17.x",
"pre-commit": "1.2.x",
"prettier": "1.13.x",
"qs": "6.5.x",
"rimraf": "2.6.x",
"rollup": "0.59.x",
"rollup-plugin-local-resolve": "1.0.x",
Expand Down
109 changes: 69 additions & 40 deletions src/__tests__/restLink.ts
Expand Up @@ -100,42 +100,7 @@ describe('Configuration', async () => {
);
} catch (error) {
expect(error.message).toBe(
`One and only one of ("path" | "pathBuilder") must be set in the @rest() directive. ` +
`This request had neither, please add one!`,
);
}
});

it('throws if both path and pathBuilder are simultaneously provided', async () => {
expect.assertions(1);

const link = new RestLink({ uri: '/api' });
const post = { id: '1', title: 'Love apollo' };
fetchMock.get('/api/post/1', post);

const postTitleQuery = gql`
query postTitle($pathBuilder: any) {
post @rest(type: "Post", path: "/post/1", pathBuilder: $pathBuilder) {
id
title
}
}
`;

try {
await makePromise<Result>(
execute(link, {
operationName: 'postTitle',
query: postTitleQuery,
variables: {
pathBuilder: (args: any) => '/whatever',
},
}),
);
} catch (error) {
expect(error.message).toBe(
`One and only one of ("path" | "pathBuilder") must be set in the @rest() directive. ` +
`This request had both, please remove one!`,
'One of ("path" | "pathBuilder") must be set in the @rest() directive. This request had neither, please add one',
);
}
});
Expand Down Expand Up @@ -875,6 +840,50 @@ describe('Query single call', () => {

expect(data).toMatchObject({ post: { ...post, __typename: 'Post' } });
});
it('can run a query that returns a scalar (simple types like string, number, boolean) response', async () => {
expect.assertions(1);

const link = new RestLink({ uri: '/api' });
const stringResp = 'SecretString';
fetchMock.get('/api/config', JSON.stringify(stringResp));

const serverConfigQuery = gql`
query config {
config @rest(type: "String", path: "/config")
}
`;

const { data } = await makePromise<Result>(
execute(link, {
operationName: 'serverConfig',
query: serverConfigQuery,
}),
);

expect(data).toMatchObject({ config: stringResp });
});

it('can run a query that returns an array of scalars', async () => {
expect.assertions(1);

const link = new RestLink({ uri: '/api' });

const arrayResp = ['Id1', 'Id2'];
fetchMock.get('/api/admins', JSON.stringify(arrayResp));
const adminsQuery = gql`
query adminIds {
admins @rest(type: "[String!]!", path: "/admins")
}
`;
const { data } = await makePromise<Result>(
execute(link, {
operationName: 'adminIds',
query: adminsQuery,
}),
);

expect(data).toMatchObject({ admins: arrayResp });
});

it('can get query params regardless of the order', async () => {
expect.assertions(1);
Expand Down Expand Up @@ -1214,8 +1223,13 @@ describe('Query single call', () => {
post: { ...postWithNest, __typename: 'Post' },
});
});
});

it('can build the path using pathBuilder', async () => {
describe('Use a custom pathBuilder', () => {
afterEach(() => {
fetchMock.restore();
});
it('in a basic way', async () => {
expect.assertions(4);

const link = new RestLink({ uri: '/api' });
Expand All @@ -1238,7 +1252,11 @@ describe('Query single call', () => {
}
`;

function createPostsPath(variables) {
function createPostsPath({
args,
exportVariables,
}: RestLink.PathBuilderProps) {
const variables = { ...args, ...exportVariables };
const qs = Object.keys(variables).reduce(
(acc: string, key: string): string => {
if (variables[key] === null || variables[key] === undefined) {
Expand Down Expand Up @@ -1316,6 +1334,10 @@ describe('Query single call', () => {
posts: [{ ...posts2[0], __typename: 'Post' }],
});
});

// TODO: Test for Path using context
// TODO: Test for PathBuilder using replacer
// TODO: Test for PathBuilder using @rest
});

describe('Query multiple calls', () => {
Expand Down Expand Up @@ -2456,7 +2478,7 @@ describe('Mutation', () => {
}
}
`;
function fakeEncryption(args) {
function fakeEncryption({ args }: RestLink.RestLinkHelperProps) {
return 'MAGIC_PREFIX' + JSON.stringify(args.input);
}

Expand All @@ -2477,11 +2499,18 @@ describe('Mutation', () => {
expect.objectContaining({
method: 'POST',
body: JSON.stringify(
fakeEncryption({ input: { title: post.title } }),
fakeEncryption({
args: { input: { title: post.title } },
exportVariables: {},
context: {},
'@rest': {},
}),
),
}),
);
});
// TODO: Test for BodyBuilder using context
// TODO: Test for BodyBuilder using @rest
});

describe('bodySerializer', () => {
Expand Down

0 comments on commit 8c04410

Please sign in to comment.