Skip to content

Commit

Permalink
feat: add graphql over http client
Browse files Browse the repository at this point in the history
  • Loading branch information
TomokiMiyauci committed Jul 20, 2022
1 parent 6df0174 commit 26525b8
Show file tree
Hide file tree
Showing 6 changed files with 350 additions and 2 deletions.
68 changes: 66 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -229,14 +229,78 @@ Make a GraphQL `Response` Object that validate to `Request` Object.
| playground | - | `boolean`<br>Whether enabled [graphql-playground](https://github.com/graphql/graphql-playground) or not. |
| playgroundOptions | `{ endpoint: "/graphql" }` | `RenderPageOptions`<br> [graphql-playground](https://github.com/graphql/graphql-playground) options. |

### ReturnType
#### ReturnType

`(req: Request) => Promise<Response>`

### Throws
#### Throws

- `AggregateError` - When graphql schema validation is fail.

### gqlFetch

GraphQL client with HTTP.

#### Example

```ts
import { gqlFetch } from "https://deno.land/x/graphql_http@$VERSION/mod.ts";

const { data, errors, extensions } = await gqlFetch({
url: `<graphql-endpoint>`,
query: `query Greet(name: $name) {
hello(name: $name)
}`,
}, {
variables: {
name: "Bob",
},
operationName: "Greet",
method: "GET",
});
```

#### Generics

- `T extends jsonObject` - `data` field type

#### Parameters

| N | Name | Required / Default | Description |
| - | ------------- | :----------------: | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1 | params | :white_check_mark: | Parameters |
| | url | :white_check_mark: | `string` &#124; `URL`<br>GraphQL URL endpoint. |
| | query | :white_check_mark: | `string`<br>GraphQL query |
| 2 | options | - | Options |
| | variables | - | `jsonObject`<br> GraphQL variables. |
| | operationName | - | `string`<br>GraphQL operation name. |
| | method | `"POST"` | `"GET"` &#124; `"POST"` &#124; `({} & string)`<br>HTTP Request method. According to the GraphQL over HTTP Spec, all GraphQL servers accept `POST` requests. |
| 3 | requestInit | - | `RequestInit`<br>Request init for customize HTTP request. |

```ts
type json =
| string
| number
| boolean
| null
| { [k: string]: json }
| json[];

type jsonObject = {
[k: string]: json;
};
```

#### ReturnTypes

`Promise<Result<T>>`

#### Throws

- `TypeError`
- `DOMException`
- `AggregateError`

## Recipes

- [std/http](./examples/std_http/README.md)
Expand Down
11 changes: 11 additions & 0 deletions constants.ts
Original file line number Diff line number Diff line change
@@ -1 +1,12 @@
export const APPLICATION_GRAPHQL_JSON = "application/graphql+json";
export const APPLICATION_JSON = "application/json";

export const CHARSET = "charset=UTF-8";

export const MIME_TYPE_APPLICATION_GRAPHQL_JSON =
`${APPLICATION_GRAPHQL_JSON};${CHARSET}` as const;

export const MIME_TYPE_APPLICATION_JSON =
`${APPLICATION_JSON};${CHARSET}` as const;

export const MIME_TYPE = "application/graphql+json; charset=UTF-8";
1 change: 1 addition & 0 deletions deps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export {
export {
JSON,
type json,
stringify,
} from "https://deno.land/x/pure_json@1.0.0-beta.1/mod.ts";
export {
type RenderPageOptions,
Expand Down
59 changes: 59 additions & 0 deletions fetch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import {
createRequest,
jsonObject,
Options,
Params,
resolveResponse,
Result,
} from "./requests.ts";

/** GraphQL client with HTTP.
* @param params parameters
* @param options Options
* @param requestInit Request init for customize HTTP request.
* @throws TypeError
* @throws DOMException
* @throws AggregateError
* ```ts
* import { gqlFetch } from "https://deno.land/x/graphql_http@$VERSION/mod.ts";
*
* const { data, errors, extensions } = await gqlFetch({
* url: `<graphql-endpoint>`,
* query: `query Greet(name: $name) {
* hello(name: $name)
* }
* `,
* }, {
* variables: {
* name: "Bob",
* },
* operationName: "Greet",
* method: "GET",
* });
* ```
*/
export default async function gqlFetch<T extends jsonObject>(
{ url, query }: Readonly<Params>,
{ method: _method = "POST", variables, operationName }: Readonly<
Partial<Options>
> = {},
requestInit?: RequestInit,
): Promise<Result<T>> {
const method = requestInit?.method ?? _method;
const [data, err] = createRequest(
{
url,
query,
method,
},
{ variables, operationName },
requestInit,
);

if (!data) {
throw err;
}

const res = await fetch(data);
return resolveResponse(res);
}
1 change: 1 addition & 0 deletions mod.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export { default as graphqlHttp, type Params } from "./graphql_http.ts";
export { default as gqlFetch } from "./fetch.ts";
212 changes: 212 additions & 0 deletions requests.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
import {
APPLICATION_GRAPHQL_JSON,
APPLICATION_JSON,
MIME_TYPE_APPLICATION_JSON,
} from "./constants.ts";
import {
GraphQLError,
isString,
json,
stringify,
tryCatchSync,
} from "./deps.ts";

type GqlError = Omit<InstanceType<typeof GraphQLError>, "toString" | "toJSON">;

export type jsonObject = {
[k: string]: json;
};

const ACCEPT = `${APPLICATION_GRAPHQL_JSON}, ${APPLICATION_JSON}` as const;

export type Params = {
/** GraphQL query. */
query: string;

/** GraphQL URL endpoint. */
url: URL | string;
};

export type Options = {
/** GraphQL variables. */
variables: jsonObject;

/** HTTP Request method.
* According to the GraphQL over HTTP Spec, all GraphQL servers accept `POST` requests.
* @default `POST`
*/
// deno-lint-ignore ban-types
method: "GET" | "POST" | ({} & string);

/** GraphQL operation name. */
operationName: string;
};

export type Result<
T extends Record<string, unknown> = Record<string, unknown>,
> = {
data?: T;
errors?: GqlError[];
extensions?: unknown;
};

export interface GraphQLResponse<T extends jsonObject = jsonObject> {
data?: T | null;
errors?: GraphQLError[];
extensions?: unknown;
}

export function createRequest(
params: Readonly<Params & Pick<Options, "method">>,
options: Partial<Options>,
requestInit: RequestInit = {},
): [data: Request, error: undefined] | [data: undefined, error: TypeError] {
const [data, err] = createRequestInitSet(params, options);
if (err) {
return [, err];
}

const mergeResult = mergeRequest(data.requestInit, requestInit);
if (mergeResult[1]) {
return [, mergeResult[1]];
}

try {
const req = new Request(data.url.toString(), mergeResult[0]);
return [req, undefined];
} catch (e) {
return [, e as TypeError];
}
}

export function createRequestInitSet(
{ url: _url, query, method }: Readonly<Params & Pick<Options, "method">>,
{ variables, operationName }: Partial<Options>,
):
| [data: { url: URL; requestInit: RequestInit }, error: undefined]
| [data: undefined, error: TypeError] {
const [url, err] = isString(_url)
? tryCatchSync(() => new URL(_url))
: [_url, undefined] as const;

if (!url) {
return [, err as TypeError];
}

switch (method) {
case "GET": {
const result = addQueryString(url, {
query,
operationName,
variables,
});

if (result[1]) {
return [undefined, result[1]];
}

const requestInit: RequestInit = {
method,
headers: {
Accept: ACCEPT,
},
};

return [{ url: result[0], requestInit }, undefined];
}
case "POST": {
const [body, err] = stringify({ query, variables, operationName });
if (err) {
return [, err];
}
const requestInit: RequestInit = {
method,
body,
headers: {
Accept: ACCEPT,
"content-type": MIME_TYPE_APPLICATION_JSON,
},
};
return [{ url, requestInit }, undefined];
}
default: {
return [{ url, requestInit: {} }, undefined];
}
}
}

function addQueryString(
url: URL,
{ query, variables, operationName }:
& Pick<Params, "query">
& Partial<Pick<Options, "operationName" | "variables">>,
): [data: URL, error: undefined] | [data: undefined, error: TypeError] {
url.searchParams.set("query", query);
if (variables) {
const [data, err] = stringify(variables);
if (err) {
return [, err];
}
url.searchParams.set("variables", data);
}
if (operationName) {
url.searchParams.set("operationName", operationName);
}

return [url, undefined];
}

export async function resolveResponse<T extends jsonObject>(
res: Response,
): Promise<Result<T>> {
const json = await res.json() as Result<T>;

if (res.ok) {
return json;
} else {
if (json.errors) {
throw new AggregateError(
json.errors,
"GraphQL request error has occurred",
);
}

throw Error("Unknown error has occurred");
}
}

function mergeRequest(
a: RequestInit,
b: RequestInit,
): [data: RequestInit, err: undefined] | [data: undefined, err: TypeError] {
const [data, err] = mergeHeaders(a.headers, b.headers);
if (err) {
return [, err];
}

const headers = new Headers(data);
return [{
...a,
...b,
headers,
}, undefined];
}

function mergeHeaders(
a?: HeadersInit,
b?: HeadersInit,
): [data: HeadersInit, err: undefined] | [data: undefined, err: TypeError] {
const aHeader = new Headers(a);
const bHeader = new Headers(b);

try {
aHeader.forEach((value, key) => {
bHeader.append(key, value);
});

const headersInit = Object.fromEntries(bHeader.entries());
return [headersInit ?? {}, undefined];
} catch (e) {
return [, e as TypeError];
}
}

0 comments on commit 26525b8

Please sign in to comment.