From 2ecea9479d5a6370eb8b487fa3af03958e10380a Mon Sep 17 00:00:00 2001 From: Tomoki Miyauchi Date: Fri, 14 Oct 2022 15:18:40 +0900 Subject: [PATCH] feat: remove request and response logic export handler feature only --- FUNDING.yml | 4 - README.md | 406 ++------------------- SECURITY.md | 20 -- _test_import_map.json | 6 +- constants.ts | 11 - deps.ts | 101 +----- dev_deps.ts | 93 +---- handler.ts | 97 +---- handler_test.ts | 452 +++-------------------- mod.ts | 10 +- parses.ts | 78 ---- requests.ts | 438 ----------------------- requests_test.ts | 794 ----------------------------------------- responses.ts | 302 ---------------- responses_test.ts | 374 ------------------- types.ts | 72 +--- use/playground.ts | 54 --- use/playground_test.ts | 74 ---- utils.ts | 36 -- validates.ts | 18 - 20 files changed, 135 insertions(+), 3305 deletions(-) delete mode 100644 FUNDING.yml delete mode 100644 SECURITY.md delete mode 100644 constants.ts delete mode 100644 parses.ts delete mode 100644 requests.ts delete mode 100644 requests_test.ts delete mode 100644 responses.ts delete mode 100644 responses_test.ts delete mode 100644 use/playground.ts delete mode 100644 use/playground_test.ts delete mode 100644 utils.ts delete mode 100644 validates.ts diff --git a/FUNDING.yml b/FUNDING.yml deleted file mode 100644 index c8f470c..0000000 --- a/FUNDING.yml +++ /dev/null @@ -1,4 +0,0 @@ -# These are supported funding model platforms - -github: TomokiMiyauci -patreon: tomoki_miyauci diff --git a/README.md b/README.md index a117393..ce5f2cc 100644 --- a/README.md +++ b/README.md @@ -4,15 +4,13 @@ [![deno doc](https://img.shields.io/badge/deno-doc-black)](https://doc.deno.land/https/deno.land/x/graphql_http/mod.ts) [![codecov](https://codecov.io/gh/TomokiMiyauci/graphql-http/branch/main/graph/badge.svg?token=0Dq5iqtnjw)](https://codecov.io/gh/TomokiMiyauci/graphql-http) -GraphQL client and handler compliant with GraphQL over HTTP specification +GraphQL request handler compliant with GraphQL-over-HTTP specification ## Features -- [GraphQL over HTTP Spec](https://graphql.github.io/graphql-over-http/) - compliant -- `application/graphql+json` support -- Lean interface, tiny using [std](https://deno.land/std/http) and graphql - public libraries +- [GraphQL-over-HTTP](https://graphql.github.io/graphql-over-http/) + specification compliant +- `application/graphql-response+json` support - Universal ## Example @@ -27,38 +25,39 @@ server: ```ts import { createHandler, - usePlayground, } from "https://deno.land/x/graphql_http@$VERSION/mod.ts"; -import { serve, Status } from "https://deno.land/std@$VERSION/http/mod.ts"; +import { serve } from "https://deno.land/std@$VERSION/http/mod.ts"; import { buildSchema } from "https://esm.sh/graphql@$VERSION"; const schema = buildSchema(`type Query { - hello: String! + greet: String! }`); - -let handler = createHandler(schema, { +const handler = createHandler(schema, { rootValue: { - hello: "world", + greet: "hello world!", }, }); -handler = usePlayground(handler); - -serve((req) => { - const { pathname } = new URL(req.url); - if (pathname === "/graphql") { - return handler(req); - } - return new Response("Not Found", { - status: Status.NotFound, - }); -}); -// Listening on + +serve(handler); +``` + +It is recommended to use +[graphql-request](https://github.com/graphqland/graphql-request/) as the GraphQL +client. + +client: + +```ts +import { gql, gqlFetch } from "https://deno.land/x/gql_request@$VERSION/mod.ts"; + +const document = gql`query { greet }`; +const { data, errors, extensions } = await gqlFetch("", document); ``` ## Spec This project is implemented in accordance with -[GraphQL over HTTP Spec](https://graphql.github.io/graphql-over-http/). +[GraphQL-over-HTTP](https://graphql.github.io/graphql-over-http/) specification. We are actively implementing [IETF RFC 2119](https://datatracker.ietf.org/doc/html/rfc2119) `SHOULD` and @@ -121,8 +120,9 @@ for the reason for this. ##### application/graphql+json -If Content-Type is `application/graphql+json`, it is possible to respond with a -status code other than `200` depending on the result of the GraphQL Request. +If Content-Type is `application/graphql-response+json`, it is possible to +respond with a status code other than `200` depending on the result of the +GraphQL Request. If the GraphQL request is invalid (e.g. it is malformed, or does not pass validation) then the response with 400 status code. @@ -139,18 +139,18 @@ Even if a Field error occurs, it will always be a `200` status code. If an error other than the above occurs on the server side, a `500` status code will be responded. -#### Upgrade to application/graphql+json +#### Upgrade to application/graphql-response+json -As you may have noticed, `application/graphql+json` represents a more accurate -semantics response. +As you may have noticed, `application/graphql-response+json` represents a more +accurate semantics response. -If you want `application/graphql+json` content, you must put -`application/graphql+json` as a higher priority than `application/json` in the -`Accept` header. +If you want `application/graphql-response+json` content, you must put +`application/graphql-response+json` as a higher priority than `application/json` +in the `Accept` header. -Example: `Accept: application/graphql+json,application/json`. +Example: `Accept: application/graphql-response+json,application/json`. -## application/graphql+json vs application/json +## application/graphql-response+json vs application/json Response status @@ -173,340 +173,6 @@ or [Websocket](https://developer.mozilla.org/en-US/docs/Web/API/Websockets_API). - [graphql-ws](https://github.com/enisdenjo/graphql-ws) - [subscriptions-transport-ws](https://github.com/apollographql/subscriptions-transport-ws) -## API - -### createHandler - -Create HTTP handler what handle GraphQL over HTTP request. - -#### Example - -```ts -import { createHandler } from "https://deno.land/x/graphql_http@$VERSION/mod.ts"; -import { buildSchema } from "https://esm.sh/graphql@$VERSION"; - -const schema = buildSchema(`type Query { - hello: String! - }`); - -const handler = createHandler(schema, { - rootValue: { - hello: "world", - }, -}); -const req = new Request(""); -const res = await handler(req); -``` - -#### Parameters - -| N | Name | Required / Default | Description | -| - | -------------- | :----------------: | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 1 | schema | :white_check_mark: | `GraphQLSchema`
The GraphQL type system to use when validating and executing a query. | -| 2 | options | - | handler options | -| | source | - | `Source` | `string`
A GraphQL language formatted string representing the requested operation. | -| | rootValue | - | `unknown`
The value provided as the first argument to resolver functions on the top level type (e.g. the query object type). | -| | contextValue | - | `unknown`
The context value is provided as an argument to resolver functions after field arguments. It is used to pass shared information useful at any point during executing this query, for example the currently logged in user and connections to databases or other services. | -| | variableValues | - | `<{ readonly [variable: string: unknown; }>` | `null`
A mapping of variable name to runtime value to use for all variables defined in the requestString. | -| | operationName | - | `string` | `null`
The name of the operation to use if requestString contains multiple possible operations. Can be omitted if requestString contains only one operation. | -| | fieldResolver | - | `GraphQLFieldResolver` | `null`
A resolver function to use when one is not provided by the schema. If not provided, the default field resolver is used (which looks for a value or method on the source value with the field's name). | -| | typeResolver | - | `GraphQLTypeResolver` | `null`
A type resolver function to use when none is provided by the schema. If not provided, the default type resolver is used (which looks for a `__typename` field or alternatively calls the `isTypeOf` method). | - -#### ReturnType - -`(req: Request) => Promise` - -#### Throws - -- `AggregateError` - When graphql schema validation is fail. - -### usePlayground - -Use GraphQL Playground as handler. - -#### Example - -```ts -import { - createHandler, - usePlayground, -} from "https://deno.land/x/graphql_http@$VERSION/mod.ts"; -import { buildSchema } from "https://esm.sh/graphql@$VERSION"; - -const schema = buildSchema(`type Query { - hello: String! - }`); - -let handler = createHandler(schema, { - rootValue: { - hello: "world", - }, -}); -handler = usePlayground(handler); -const req = new Request(""); -const res = await handler(req); -``` - -#### Parameters - -| N | Name | Required / Default | Description | -| - | -------------------- | :----------------: | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 1 | handler | :white_check_mark: | `(req: Request) => Promise` | `Response`
The handler for individual HTTP requests. | -| 2 | options | - | `RenderPageOptions`
The [graphql-playground](https://github.com/graphql/graphql-playground) options. | -| | endpoint | `"/graphql"` | `string`
The GraphQL endpoint url. | -| | subscriptionEndpoint | - | `string`
The GraphQL subscriptions endpoint url. | -| | workspaceName | - | `string`
in case you provide a GraphQL Config, you can name your workspace here. | -| | env | - | `any` | -| | config | - | `any`
The JSON of a GraphQL Config. | -| | settings | - | `ISettings`
Editor settings in json format. | -| | schema | - | `IntrospectionResult`
The result of an introspection query (an object of this form: `{__schema: {...}}`) The playground automatically fetches the schema from the endpoint. This is only needed when you want to override the schema. | -| | tabs | - | `Tab[]`
An array of tabs to inject. | -| | codeTheme | - | `EditorColours`
Customize your color theme. | -| | version | - | `string` | -| | cdnUrl | - | `string` | -| | title | - | `string` | -| | faviconUrl | - | `string` | `null` | - -```ts -interface ISettings { - "editor.cursorShape": "line" | "block" | "underline"; - "editor.fontFamily": string; - "editor.fontSize": number; - "editor.reuseHeaders": boolean; - "editor.theme": "dark" | "light"; - "general.betaUpdates": boolean; - "prettier.printWidth": number; - "prettier.tabWidth": number; - "prettier.useTabs": boolean; - "request.credentials": "omit" | "include" | "same-origin"; - "request.globalHeaders": { [key: string]: string }; - "schema.polling.enable": boolean; - "schema.polling.endpointFilter": string; - "schema.polling.interval": number; - "schema.disableComments": boolean; - "tracing.hideTracingResponse": boolean; - "tracing.tracingSupported": boolean; -} -interface Tab { - endpoint: string; - query: string; - name?: string; - variables?: string; - responses?: string[]; - headers?: { - [key: string]: string; - }; -} -interface EditorColours { - property: string; - comment: string; - punctuation: string; - keyword: string; - def: string; - qualifier: string; - attribute: string; - number: string; - string: string; - builtin: string; - string2: string; - variable: string; - meta: string; - atom: string; - ws: string; - selection: string; - cursorColor: string; - editorBackground: string; - resultBackground: string; - leftDrawerBackground: string; - rightDrawerBackground: string; -} -``` - -#### ReturnType - -`(req: Request) => Promise | Response` - -### createRequest - -Create GraphQL `Request` object. - -#### Example - -```ts -import { createRequest } from "https://deno.land/x/graphql_http@$VERSION/mod.ts"; - -const [request, err] = createRequest({ - url: "", - query: `query Greet(name: $name) { - hello(name: $name) - }`, - method: "GET", -}); - -if (!err) { - const res = await fetch(request); -} -``` - -#### Generics - -- `T extends jsonObject` - -#### Parameters - -| N | Name | Required / Default | Description | -| - | ------------- | :----------------: | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 1 | params | :white_check_mark: | Parameters | -| | url | :white_check_mark: | `string` | `URL`
GraphQL URL endpoint. | -| | query | :white_check_mark: | `string`
GraphQL query | -| | method | :white_check_mark: | `"GET"` | `"POST"` | `({} & string)`
HTTP Request method. According to the GraphQL over HTTP Spec, all GraphQL servers accept `POST` requests. | -| 2 | options | - | Options | -| | variables | - | `jsonObject`
GraphQL variables. | -| | operationName | - | `string`
GraphQL operation name. | - -#### ReturnType - -`[data: Request, error: undefined] | [data: undefined, error: TypeError]` - -### resolveRequest - -Resolve GraphQL over HTTP request, take out GraphQL parameters safety. - -#### Example - -```ts -import { - resolveRequest, -} from "https://deno.land/x/graphql_http@$VERSION/mod.ts"; - -const req = new Request(""); // any Request -const [data, err] = await resolveRequest(req); -if (data) { - const { query, variables, operationName, extensions } = data; -} -``` - -#### Parameters - -| Name | Required | Description | -| ---- | :----------------: | ------------------------------ | -| req | :white_check_mark: | `Request`
`Request` object | - -#### ReturnType - -`Promise` | `RequestResult` - -RequestResult: - -| N | Name | Description | -| - | ------------- | ---------------------------------------------------------------------------------------------------------------------------- | -| 1 | data | Bellow records | `undefined`
GraphQL parameters. | -| | query | `string`
A Document containing GraphQL Operations and Fragments to execute. | -| | variables | `Record` | `null`
Values for any Variables defined by the Operation. | -| | operationName | `string` | `null`
The name of the Operation in the Document to execute. | -| | extensions | `Record` | `null`
Reserved for implementors to extend the protocol however they see fit. | -| 2 | error | `HttpError` | `undefined`
The base class that all derivative HTTP extend, providing a status and an expose property. | - -#### Remark - -No error is thrown and `reject` is never called. - -### createResponse - -Create a GraphQL over HTTP compliant `Response` object. - -#### Example - -```ts -import { - createResponse, -} from "https://deno.land/x/graphql_http@$VERSION/mod.ts"; -import { buildSchema } from "https://esm.sh/graphql@$VERSION"; - -const schema = buildSchema(`query { - hello: String! -}`); - -const res = createResponse({ - schema, - source: `query { hello }`, - method: "POST", -}, { - rootValue: { - hello: "world", - }, -}); -``` - -#### Parameters - -| N | Name | Required / Default | Description | -| - | -------------- | :--------------------------: | -------------------------------------------------------- | -| 1 | params | :white_check_mark: | Parameters. | -| | schema | :white_check_mark: | `GraphQLSchema` | -| | method | :white_check_mark: | `GET` | `POST` | -| 2 | options | - | options. | -| | operationName | - | `string` | `null` | -| | variableValues | - | `{ readonly [variable: string]: unknown }` | `null` | -| | contextValue | - | `unknown` | -| | rootValue | - | `unknown` | -| | fieldResolver | - | `GraphQLFieldResolver` | `null` | -| | typeResolver | - | `GraphQLTypeResolver` | `null` | -| | mimeType | `"application/graphql+json"` | `"application/graphql+json"`| `application/json` | - -#### ReturnType - -`Response` - -### resolveResponse - -Resolve GraphQL over HTTP response safety. - -#### Example - -```ts -import { - resolveResponse, -} from "https://deno.land/x/graphql_http@$VERSION/mod.ts"; - -const res = new Response(); // any Response -const { data, errors, extensions } = await resolveResponse(res); -``` - -#### Parameters - -| Name | Required | Description | -| ---- | :----------------: | -------------------------------- | -| res | :white_check_mark: | `Response`
`Response` object | - -#### ReturnType - -`Promise>` - -```ts -import { GraphQLError } from "https://esm.sh/graphql@$VERSION"; -import { json } from "https://deno.land/x/pure_json@$VERSION/mod.ts"; -type PickBy = { - [k in keyof T as (K extends T[k] ? k : never)]: T[k]; -}; - -type SerializedGraphQLError = PickBy; -type Result< - T extends Record = Record, -> = { - data?: T; - errors?: SerializedGraphQLError[]; - extensions?: unknown; -}; -``` - -#### Throws - -- `Error` -- `AggregateError` -- `SyntaxError` -- `TypeError` - ## Recipes - [std/http](./examples/std_http/README.md) @@ -514,6 +180,6 @@ type Result< ## License -Copyright © 2022-present [TomokiMiyauci](https://github.com/TomokiMiyauci). +Copyright © 2022-present [graphqland](https://github.com/graphqland). Released under the [MIT](./LICENSE) license diff --git a/SECURITY.md b/SECURITY.md deleted file mode 100644 index d906fed..0000000 --- a/SECURITY.md +++ /dev/null @@ -1,20 +0,0 @@ -# Security Policy - -## Supported Versions - -Use this section to tell people about which versions of your project are -currently being supported with security updates. - -| Version | Supported | -| ------- | ------------------ | -| 5.1.x | :white_check_mark: | -| 5.0.x | :x: | -| 4.0.x | :white_check_mark: | -| < 4.0 | :x: | - -## Reporting a Vulnerability - -Use this section to tell people how to report a vulnerability. - -Tell them where to go, how often they can expect to get an update on a reported -vulnerability, what to expect if the vulnerability is accepted or declined, etc. diff --git a/_test_import_map.json b/_test_import_map.json index 71db0ff..8868415 100644 --- a/_test_import_map.json +++ b/_test_import_map.json @@ -1,8 +1,8 @@ { "imports": { "https://deno.land/x/graphql_http@$VERSION/": "./", - "https://deno.land/std@$VERSION/": "https://deno.land/std@0.147.0/", - "https://esm.sh/graphql@$VERSION": "https://esm.sh/graphql@16.5.0", - "https://deno.land/x/pure_json@$VERSION/": "https://deno.land/x/pure_json@1.0.0-beta.1/" + "https://deno.land/std@$VERSION/": "https://deno.land/std@0.159.0/", + "https://esm.sh/graphql@$VERSION": "https://esm.sh/v96/graphql@16.6.0", + "https://deno.land/x/gql_request@$VERSION/": "https://deno.land/x/gql_request@1.0.0-beta.1/" } } diff --git a/constants.ts b/constants.ts deleted file mode 100644 index 2826f84..0000000 --- a/constants.ts +++ /dev/null @@ -1,11 +0,0 @@ -export const APPLICATION_GRAPHQL_JSON = "application/graphql+json"; -export const APPLICATION_JSON = "application/json"; - -/** Available charset. */ -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; diff --git a/deps.ts b/deps.ts index bdefca4..b989cbd 100644 --- a/deps.ts +++ b/deps.ts @@ -1,95 +1,12 @@ +// Copyright 2022-latest the graphqland authors. All rights reserved. MIT license. +// This module is browser compatible. + export { - buildSchema, - execute, - executeSync, - type ExecutionResult, - getOperationAST, - graphql, - type GraphQLArgs, - GraphQLError, - type GraphQLFieldResolver, + assertValidSchema, GraphQLSchema, - type GraphQLTypeResolver, - parse, - Source, - specifiedRules, - validate, - validateSchema, -} from "https://esm.sh/v87/graphql@16.5.0"; -export { - contentType, - parseMediaType, -} from "https://deno.land/std@0.150.0/media_types/mod.ts"; -export { accepts } from "https://deno.land/std@0.150.0/http/negotiation.ts"; -export { - isNil, - isNull, - isObject, - isPlainObject, - isString, - isUndefined, -} from "https://deno.land/x/isx@1.0.0-beta.19/mod.ts"; -export { - JSON, - type json, - stringify, -} from "https://deno.land/x/pure_json@1.0.0-beta.1/mod.ts"; -import { type json } from "https://deno.land/x/pure_json@1.0.0-beta.1/mod.ts"; +} from "https://esm.sh/v96/graphql@16.6.0"; export { - type RenderPageOptions, - renderPlaygroundPage, -} from "https://esm.sh/graphql-playground-html@1.6.30"; -export { - createHttpError, - HttpError, - Status, -} from "https://deno.land/std@0.150.0/http/mod.ts"; - -export type PartialBy = - Omit & Partial> extends infer U - ? { [K in keyof U]: U[K] } - : never; - -export type PickBy = { - [k in keyof T as (K extends T[k] ? k : never)]: T[k]; -}; - -export type PickRequired = { - [k in keyof T as Record extends Pick ? never : k]: T[k]; -}; - -export type PickPartial = { - [k in keyof T as Record extends Pick ? k : never]: T[k]; -}; - -export type jsonObject = { - [k: string]: json; -}; - -export function tryCatchSync( - fn: () => T, -): [data: T, err: undefined] | [data: undefined, err: unknown] { - try { - return [fn(), undefined]; - } catch (er) { - return [, er]; - } -} - -export async function tryCatch( - fn: () => Promise | T, -): Promise<[data: T, err: undefined] | [data: undefined, err: unknown]> { - try { - return [await fn(), undefined]; - } catch (er) { - return [, er]; - } -} - -// deno-lint-ignore no-explicit-any -export function has, K extends string>( - value: T, - key: K, -): value is T & Record { - return key in value; -} + createResponse, + type ExecutionParams, +} from "https://deno.land/x/graphql_response@1.0.0-beta.2/mod.ts"; +export { type HttpHandler } from "https://deno.land/x/http_utils@1.0.0-beta.6/mod.ts"; diff --git a/dev_deps.ts b/dev_deps.ts index b342432..eda47b0 100644 --- a/dev_deps.ts +++ b/dev_deps.ts @@ -1,90 +1,5 @@ -export * from "https://deno.land/std@0.150.0/testing/asserts.ts"; -export * from "https://deno.land/std@0.150.0/testing/bdd.ts"; -export * from "https://deno.land/std@0.150.0/media_types/mod.ts"; -export * from "https://deno.land/std@0.150.0/http/mod.ts"; -import { - defineExpect, - equal, - jestMatcherMap, - jestModifierMap, - MatchResult, -} from "https://deno.land/x/unitest@v1.0.0-beta.82/mod.ts"; -import { isString } from "https://deno.land/x/isx@v1.0.0-beta.17/mod.ts"; -export * from "https://esm.sh/graphql@16.5.0"; +export * from "https://deno.land/std@0.159.0/testing/asserts.ts"; +export * from "https://deno.land/std@0.159.0/testing/mock.ts"; +export * from "https://deno.land/std@0.159.0/testing/bdd.ts"; -export const expect = defineExpect({ - matcherMap: { - ...jestMatcherMap, - toError: ( - actual: unknown, - // deno-lint-ignore ban-types - error: Function, - message?: string, - ) => { - if (!(actual instanceof Error)) { - return { - pass: false, - expected: "Error Object", - }; - } - - if (!(actual instanceof error)) { - return { - pass: false, - expected: `${error.name} Object`, - }; - } - - if (message) { - return { - pass: actual.message === message, - expected: message, - resultActual: actual.message, - }; - } - - return { - pass: true, - expected: error, - }; - }, - toEqualIterable, - }, - modifierMap: jestModifierMap, -}); - -function toEqualIterable( - actual: Iterable, - expected: Iterable, -): MatchResult { - const act = Object.fromEntries(actual); - const exp = Object.fromEntries(expected); - - return { - pass: equal(act, exp), - expected: exp, - resultActual: act, - }; -} - -export function queryString( - baseURL: string | URL, - urlParams: { [param: string]: string }, -): string { - const url = isString(baseURL) ? new URL(baseURL) : baseURL; - - Object.entries(urlParams).forEach(([key, value]) => { - url.searchParams.set(key, value); - }); - - return url.toString(); -} - -export class BaseRequest extends Request { - constructor(input: RequestInfo, init?: RequestInit) { - const headers = new Headers(init?.headers); - headers.append("accept", "application/graphql+json"); - - super(input, { ...init, headers }); - } -} +export { buildSchema } from "https://esm.sh/v96/graphql@16.6.0"; diff --git a/handler.ts b/handler.ts index 5ff0464..87da966 100644 --- a/handler.ts +++ b/handler.ts @@ -1,28 +1,23 @@ -import { accepts, Status, validateSchema } from "./deps.ts"; +// Copyright 2022-latest the graphqland authors. All rights reserved. MIT license. +// This module is browser compatible. + import { - createJSONResponse, + assertValidSchema, createResponse, - createResult, - withCharset, -} from "./responses.ts"; -import { resolveRequest } from "./requests.ts"; -import { GraphQLOptionalArgs, GraphQLRequiredArgs } from "./types.ts"; - -export type Options = - & GraphQLOptionalArgs - & Pick; + GraphQLSchema, + HttpHandler, +} from "./deps.ts"; +import { HandlerOptions } from "./types.ts"; -/** Create HTTP handler what handle GraphQL over HTTP request. - * @throws {@link AggregateError} - * When graphql schema validation is fail. +/** Create HTTP handler what handle GraphQL-over-HTTP request. + * @throws {Error} When schema is invalid. + * + * @example * ```ts * import { createHandler } from "https://deno.land/x/graphql_http@$VERSION/mod.ts"; * import { buildSchema } from "https://esm.sh/graphql@$VERSION"; * - * const schema = buildSchema(`type Query { - * hello: String! - * }`); - * + * const schema = buildSchema(`type Query { hello: String! }`); * const handler = createHandler(schema, { * rootValue: { * hello: "world", @@ -32,65 +27,11 @@ export type Options = * const res = await handler(req); * ``` */ -export default function createHandler( - schema: GraphQLRequiredArgs["schema"], - options: Readonly> = {}, -): (req: Request) => Promise | Response { - const validateSchemaResult = validateSchema(schema); - if (validateSchemaResult.length) { - throw new AggregateError(validateSchemaResult, "Schema validation error"); - } - - return async (req) => { - const result = await process(req); - - return result; - }; - - async function process(req: Request): Promise { - const mimeType = getMediaType(req); - const preferContentType = withCharset(mimeType); - - const [data, err] = await resolveRequest(req); - if (!data) { - const result = createResult(err); - const baseHeaders: HeadersInit = { "content-type": preferContentType }; - const responseInit: ResponseInit = err.status === Status.MethodNotAllowed - ? { - status: err.status, - headers: { - ...baseHeaders, - allow: ["GET", "POST"].join(","), - }, - } - : { - status: err.status, - headers: baseHeaders, - }; - const res = createJSONResponse(result, responseInit); - - return res; - } - const { query: source, variables: variableValues, operationName } = data; - - const res = createResponse({ - schema, - source, - method: req.method as "GET" | "POST", - }, { - mimeType: mimeType, - variableValues, - operationName, - ...options, - }); - - return res; - } -} +export function createHandler( + schema: GraphQLSchema, + options?: HandlerOptions, +): HttpHandler { + assertValidSchema(schema); -function getMediaType( - req: Request, -): "application/graphql+json" | "application/json" { - return (accepts(req, "application/graphql+json", "application/json") ?? - "application/json") as "application/graphql+json" | "application/json"; + return (request) => createResponse(request, { ...options, schema }); } diff --git a/handler_test.ts b/handler_test.ts index 1b341ea..20f31ca 100644 --- a/handler_test.ts +++ b/handler_test.ts @@ -1,420 +1,78 @@ -import { - BaseRequest, - buildSchema, - contentType, - describe, - expect, - GraphQLObjectType, - GraphQLSchema, - GraphQLString, - it, - queryString, - Status, -} from "./dev_deps.ts"; -import createHandler from "./handler.ts"; -import { MIME_TYPE_APPLICATION_GRAPHQL_JSON } from "./constants.ts"; +import { assertEquals, buildSchema, describe, it } from "./dev_deps.ts"; +import { createHandler } from "./handler.ts"; -function assertHeaderAppGraphqlJson(headers: Headers): void { - expect(headers).toEqualIterable( - new Headers({ - "content-type": "application/graphql+json; charset=UTF-8", - }), - ); -} +const schema = buildSchema(`type Query { hello: String }`); -function assertHeaderAppJson(headers: Headers): void { - expect(headers).toEqualIterable( - new Headers({ - "content-type": "application/json; charset=UTF-8", - }), - ); -} +describe("createHandler", () => { + it("should return 200 when valid graphql request with GET", async () => { + const handler = createHandler(schema); -const QueryRootType = new GraphQLObjectType({ - name: "QueryRoot", - fields: { - test: { - type: GraphQLString, - args: { - who: { type: GraphQLString }, - }, - resolve: (_root, args: { who?: string }) => - "Hello " + (args.who ?? "World"), - }, - thrower: { - type: GraphQLString, - resolve() { - throw new Error("Throws!"); - }, - }, - }, -}); - -/** - * schema { - * query: QueryRoot - * mutation: MutationRoot - * } - * - * type QueryRoot { - * test(who: String): String - * thrower: String - * } - * - * type MutationRoot { - * writeTest: QueryRoot - * } - */ -const schema = new GraphQLSchema({ - query: QueryRootType, - mutation: new GraphQLObjectType({ - name: "MutationRoot", - fields: { - writeTest: { - type: QueryRootType, - resolve: () => ({}), - }, - }, - }), -}); - -const handler = createHandler(schema); - -const BASE_URL = "https://test.test"; - -const describeTests = describe("createHandler"); - -it("should throw error when validation of schema is fail", () => { - expect(() => createHandler(buildSchema(`type Test { hello: String }`))) - .toThrow( - "Schema validation error", + const response = await handler( + new Request("http://localhost?query=query{hello}"), ); -}); - -it("should error when HTTP request method is unsupported", async () => { - const res = await handler( - new Request(queryString(BASE_URL, {}), { - headers: { - "accept": "plain/text", - }, - method: "OPTIONS", - }), - ); - - expect(res.status).toBe(Status.MethodNotAllowed); - expect(res.headers).toEqualIterable( - new Headers({ - allow: "GET,POST", - "content-type": contentType(".json"), - }), - ); - await expect(res.json()).resolves.toEqual({ - errors: [ - { - message: - "Invalid HTTP method. GraphQL only supports GET and POST requests.", - }, - ], - }); -}); - -describe("HTTP method is GET", () => { - it( - describeTests, - `should return 406 when "Accept" header does not include application/graphql+json or application/json`, - async () => { - const res = await handler( - new Request(queryString(BASE_URL, {}), { - headers: { - "accept": "plain/text", - }, - }), - ); - - expect(res.status).toBe(Status.NotAcceptable); - assertHeaderAppJson(res.headers); - await expect(res.json()).resolves.toEqual({ - errors: [{ - message: - `The header is invalid. "Accept" must include "application/graphql+json" or "application/json"`, - }], - }); - }, - ); - it( - describeTests, - "should return 400 when query string is not exists", - async () => { - const res = await handler( - new BaseRequest(new URL(BASE_URL).toString()), - ); - - expect(res.status).toBe(Status.BadRequest); - assertHeaderAppGraphqlJson(res.headers); - await expect(res.json()).resolves.toEqual({ - errors: [{ message: `The parameter is required. "query"` }], - }); - }, - ); - - it( - describeTests, - `should return 405 when query is not "query"`, - async () => { - const res = await handler( - new BaseRequest(queryString(BASE_URL, { - query: "mutation { hello }", - })), - ); - - expect(res.status).toBe(Status.MethodNotAllowed); - expect(res.headers).toEqualIterable( - new Headers({ - "content-type": MIME_TYPE_APPLICATION_GRAPHQL_JSON, - allow: "POST", - }), - ); - await expect(res.json()).resolves.toEqual({ - errors: [{ - message: - `Invalid GraphQL operation. Can only perform a mutation operation from a POST request.`, - }], - }); - }, - ); - - it( - describeTests, - `should return 405 when operation is not "query"`, - async () => { - const res = await handler( - new BaseRequest(queryString(BASE_URL, { - query: ` - query { hello } - mutation TestMutation { hello } - `, - operationName: "TestMutation", - })), - ); - - expect(res.status).toBe(Status.MethodNotAllowed); - expect(res.headers).toEqualIterable( - new Headers({ - "content-type": MIME_TYPE_APPLICATION_GRAPHQL_JSON, - allow: "POST", - }), - ); - await expect(res.json()).resolves.toEqual({ - errors: [{ - message: - `Invalid GraphQL operation. Can only perform a mutation operation from a POST request.`, - }], - }); - }, - ); - - it("allows GET with query param", async () => { - const url = new URL( - `?query={test}`, - BASE_URL, + assertEquals(response.status, 200); + assertEquals( + response.headers.get("content-type"), + "application/graphql-response+json;charset=UTF-8", ); - const req = new BaseRequest(url.toString()); - const res = await handler(req); - - expect(res.status).toBe(Status.OK); - assertHeaderAppGraphqlJson(res.headers); - await expect(res.json()).resolves.toEqual({ - data: { test: "Hello World" }, - }); + assertEquals(await response.json(), { data: { hello: null } }); }); - it("allows GET with variable values", async () => { - const url = queryString(BASE_URL, { - query: `query helloWho($who: String){ test(who: $who) }`, - variables: `{"who":"Dolly"}`, - }); - const req = new BaseRequest(url); - const res = await handler(req); + it("should return 200 when valid graphql request with POST", async () => { + const handler = createHandler(schema); - expect(res.status).toBe(Status.OK); - assertHeaderAppGraphqlJson(res.headers); - await expect(res.json()).resolves.toEqual({ - data: { test: "Hello Dolly" }, - }); - }); - - it("allows GET with operation name", async () => { - const url = queryString(BASE_URL, { - query: ` - query helloYou { test(who: "You"), ...shared } - query helloWorld { test(who: "World"), ...shared } - query helloDolly { test(who: "Dolly"), ...shared } - fragment shared on QueryRoot { - shared: test(who: "Everyone") - } - `, - operationName: "helloWorld", - }); - - const res = await handler(new BaseRequest(url)); - expect(res.status).toBe(Status.OK); - await expect(res.json()).resolves.toEqual({ - data: { - test: "Hello World", - shared: "Hello Everyone", - }, - }); - }); - - it("Allows a mutation to exist within a GET", async () => { - const url = queryString(BASE_URL, { - operationName: "TestQuery", - query: ` - mutation TestMutation { writeTest { test } } - query TestQuery { test } - `, - }); - - const res = await handler(new BaseRequest(url)); - - expect(res.status).toEqual(Status.OK); - await expect(res.json()).resolves.toEqual({ - data: { - test: "Hello World", - }, - }); - }); -}); - -describe("HTTP method is POST", () => { - it( - describeTests, - `should return 406 when "Accept" header does not include application/graphql+json or application/json`, - async () => { - const res = await handler( - new Request(queryString(BASE_URL, {}), { - headers: { - "accept": "plain/text", - }, - method: "POST", - }), - ); - - expect(res.status).toBe(Status.NotAcceptable); - expect(res.headers).toEqualIterable( - new Headers({ - "content-type": contentType(".json"), - }), - ); - await expect(res.json()).resolves.toEqual({ - errors: [{ - message: - `The header is invalid. "Accept" must include "application/graphql+json" or "application/json"`, - }], - }); - }, - ); - - it("Allows POST with JSON encoding", async () => { - const req = new BaseRequest(BASE_URL, { - body: JSON.stringify({ query: "{test}" }), - method: "POST", - headers: { - "content-type": contentType(".json"), - }, - }); - const res = await handler(req); - - expect(res.status).toBe(Status.OK); - assertHeaderAppGraphqlJson(res.headers); - expect(res.json()).resolves.toEqual({ data: { test: "Hello World" } }); - }); - - it("Allows sending a mutation via POST", async () => { - const req = new BaseRequest(BASE_URL, { - body: JSON.stringify({ - query: "mutation TestMutation { writeTest { test } }", + const response = await handler( + new Request("http://localhost", { + body: JSON.stringify({ query: `query { hello }` }), + method: "POST", + headers: { + "content-type": "application/json", + }, }), - method: "POST", - headers: { - "content-type": contentType(".json"), - }, - }); - const res = await handler(req); - - expect(res.status).toBe(Status.OK); - assertHeaderAppGraphqlJson(res.headers); - expect(res.json()).resolves.toEqual({ - data: { writeTest: { test: "Hello World" } }, - }); - }); - - it(`return with errors when "Content-Type" is not exists`, async () => { - const req = new BaseRequest(BASE_URL, { - method: "POST", - }); - const res = await handler(req); + ); - expect(res.status).toBe(Status.BadRequest); - assertHeaderAppGraphqlJson(res.headers); - expect(res.json()).resolves.toEqual({ - errors: [{ message: 'The header is required. "Content-Type"' }], - }); + assertEquals(response.status, 200); + assertEquals( + response.headers.get("content-type"), + "application/graphql-response+json;charset=UTF-8", + ); + assertEquals(await response.json(), { data: { hello: null } }); }); - it("return with errros when message body is invalid JSON format", async () => { - const req = new BaseRequest(BASE_URL, { - method: "POST", - headers: { - "content-type": contentType(".json"), + it("should return 200 when valid graphql request", async () => { + const handler = createHandler(schema, { + rootValue: { + hello: "world", }, }); - const res = await handler(req); - expect(res.status).toBe(Status.BadRequest); - assertHeaderAppGraphqlJson(res.headers); - expect(res.json()).resolves.toEqual({ - errors: [{ - message: "The message body is invalid. Invalid JSON format.", - }], - }); - }); - - it("Allows POST with url encoding", async () => { - const url = queryString(BASE_URL, { - query: `{test}`, - }); - const req = new BaseRequest(url, { - body: JSON.stringify({}), - method: "POST", - headers: { - "content-type": contentType(".json"), - }, - }); - const res = await handler(req); + const response = await handler( + new Request("http://localhost", { + body: JSON.stringify({ query: `query { hello }` }), + method: "POST", + headers: { + "content-type": "application/json", + accept: "application/json", + }, + }), + ); - expect(res.status).toBe(Status.OK); - assertHeaderAppGraphqlJson(res.headers); - expect(res.json()).resolves.toEqual({ data: { test: "Hello World" } }); + assertEquals(response.status, 200); + assertEquals( + response.headers.get("content-type"), + "application/json;charset=UTF-8", + ); + assertEquals(await response.json(), { data: { hello: "world" } }); }); - it("should return 200 when body includes variables", async () => { - const req = new BaseRequest(BASE_URL, { - body: JSON.stringify({ - query: "query helloWho($who: String){ test(who: $who) }", - variables: { who: "Dolly" }, - }), - method: "POST", - headers: { - "content-type": contentType(".json"), - }, - }); + it("should return 400 when valid graphql request", async () => { + const handler = createHandler(schema); - const res = await handler(req); + const response = await handler( + new Request("http://localhost"), + ); - expect(res.status).toBe(Status.OK); - assertHeaderAppGraphqlJson(res.headers); - expect(res.json()).resolves.toEqual({ data: { test: "Hello Dolly" } }); + assertEquals(response.status, 400); }); }); diff --git a/mod.ts b/mod.ts index f507c92..7c5e205 100644 --- a/mod.ts +++ b/mod.ts @@ -1,5 +1,5 @@ -export { default as createHandler } from "./handler.ts"; -export { createRequest, resolveRequest } from "./requests.ts"; -export { createResponse, resolveResponse } from "./responses.ts"; -export { parseGraphQLParameters } from "./parses.ts"; -export { type GraphQLParameters } from "./types.ts"; +// Copyright 2022-latest the graphqland authors. All rights reserved. MIT license. +// This module is browser compatible. + +export { createHandler } from "./handler.ts"; +export { type HandlerOptions } from "./types.ts"; diff --git a/parses.ts b/parses.ts deleted file mode 100644 index a2e430f..0000000 --- a/parses.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { has, isNull, isPlainObject, isString } from "./deps.ts"; -import { GraphQLParameters } from "./types.ts"; - -/** Parse value as {@link GraphQLParameters}. */ -export function parseGraphQLParameters( - value: unknown, -): [data: GraphQLParameters] | [data: undefined, error: TypeError] { - if (!isPlainObject(value)) { - return [ - , - TypeError( - `Invalid field. "payload" must be plain object.`, - ), - ]; - } - - if (!has(value, "query")) { - return [ - , - TypeError( - `Missing field. "query"`, - ), - ]; - } - - if (!isString(value.query)) { - return [ - , - TypeError( - `Invalid field. "query" must be string.`, - ), - ]; - } - - if ( - has(value, "variables") && - (!isNull(value.variables) && !isPlainObject(value.variables)) - ) { - return [ - , - TypeError( - `Invalid field. "variables" must be plain object or null`, - ), - ]; - } - if ( - has(value, "operationName") && - (!isNull(value.operationName) && !isString(value.operationName)) - ) { - return [ - , - TypeError( - `Invalid field. "operationName" must be string or null.`, - ), - ]; - } - if ( - has(value, "extensions") && - (!isNull(value.extensions) && !isPlainObject(value.extensions)) - ) { - return [ - , - TypeError( - `Invalid field. "extensions" must be plain object or null`, - ), - ]; - } - - const { query, variables = null, operationName = null, extensions = null } = - value as GraphQLParameters; - - return [{ - operationName, - variables, - extensions, - query, - }]; -} diff --git a/requests.ts b/requests.ts deleted file mode 100644 index cd9d3d7..0000000 --- a/requests.ts +++ /dev/null @@ -1,438 +0,0 @@ -import { - APPLICATION_GRAPHQL_JSON, - APPLICATION_JSON, - MIME_TYPE_APPLICATION_JSON, -} from "./constants.ts"; -import { - accepts, - createHttpError, - HttpError, - isNil, - isNull, - isObject, - isString, - JSON, - jsonObject, - parseMediaType, - Status, - stringify, - tryCatchSync, -} from "./deps.ts"; -import { GraphQLParameters } from "./types.ts"; - -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; -}; - -/** Create GraphQL `Request` object. - * @param params parameters. - * @param options options. - * ```ts - * import { createRequest } from "https://deno.land/x/graphql_http@$VERSION/mod.ts"; - * - * const [request, err] = createRequest({ - * url: "", - * query: `query Greet(name: $name) { - * hello(name: $name) - * }`, - * method: "GET", - * }); - * - * if (!err) { - * const res = await fetch(request); - * } - * ``` - */ -export function createRequest( - params: Readonly>, - options: Partial> = {}, -): [data: Request, error: undefined] | [data: undefined, error: TypeError] { - const [data, err] = createRequestInitSet(params, options); - if (err) { - return [, err]; - } - - const requestResult = tryCatchSync(() => - new Request(data.url.toString(), data.requestInit) - ); - - return requestResult as [Request, undefined] | [undefined, TypeError]; -} - -export function createRequestInitSet( - { url: _url, query, method }: Readonly>, - { variables, operationName }: Partial, -): - | [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 - & Partial>, -): [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 type RequestResult = [data: GraphQLParameters] | [ - data: undefined, - error: HttpError, -]; - -/** Resolve GraphQL over HTTP request, take out GraphQL parameters safety. - * @params req `Request` object - * @remark No error is thrown and `reject` is never called. - * ```ts - * import { - * resolveRequest, - * } from "https://deno.land/x/graphql_http@$VERSION/mod.ts"; - * - * const req = new Request(""); // any Request - * const [data, err] = await resolveRequest(req); - * if (data) { - * const { query, variables, operationName, extensions } = data; - * } - * ``` - */ -export function resolveRequest( - req: Request, -): RequestResult | Promise { - const method = req.method; - - switch (method) { - case "GET": { - return resolveGetRequest(req); - } - case "POST": { - return resolvePostRequest(req); - } - default: { - return [ - , - createHttpError( - Status.MethodNotAllowed, - `Invalid HTTP method. GraphQL only supports GET and POST requests.`, - ), - ]; - } - } -} - -export function resolveGetRequest(req: Request): RequestResult { - const acceptResult = resolveAcceptHeader(req); - - if (acceptResult[1]) { - return acceptResult; - } - - const url = new URL(req.url); - - const source = url.searchParams.get("query"); - if (!source) { - return [ - , - createHttpError(Status.BadRequest, `The parameter is required. "query"`), - ]; - } - let variables: GraphQLParameters["variables"] | null = null; - const variablesStr = url.searchParams.get("variables"); - if (isString(variablesStr)) { - const [data, err] = JSON.parse(variablesStr); - if (err) { - return [ - , - createHttpError( - Status.BadRequest, - `The parameter is invalid. "variables" are invalid JSON.`, - ), - ]; - } - if (isPlainObject(data)) { - variables = data; - } - } - - const operationName = url.searchParams.get("operationName"); - - return [{ - query: source, - variables, - operationName, - extensions: null, - }]; -} - -export async function resolvePostRequest( - req: Request, -): Promise { - const acceptHeader = resolveAcceptHeader(req); - - if (acceptHeader[1]) { - return acceptHeader; - } - const contentType = req.headers.get("content-type"); - if (!contentType) { - return [ - , - createHttpError( - Status.BadRequest, - `The header is required. "Content-Type"`, - ), - ]; - } - - const [mediaType, record = { charset: "UTF-8" }] = parseMediaType( - contentType, - ); - - const charset = record.charset ? record.charset : "UTF-8"; - if (charset.toUpperCase() !== "UTF-8") { - return [ - , - createHttpError( - Status.UnsupportedMediaType, - `The header is invalid. Supported media type charset is "UTF-8".`, - ), - ]; - } - - // // TODO:(miyauci) check the body already read. - // const body = await req.text(); - - switch (mediaType) { - case "application/json": { - const data = await req.text(); - const [json, err] = JSON.parse(data); - - if (err) { - return [ - , - createHttpError( - Status.BadRequest, - `The message body is invalid. Invalid JSON format.`, - ), - ]; - } - - if (!isPlainObject(json)) { - return [ - , - createHttpError( - Status.BadRequest, - `The message body is invalid. Must be JSON object format.`, - ), - ]; - } - - const { query: _query, operationName = null, variables = null } = json; - - const query = isNil(_query) - ? new URL(req.url).searchParams.get("query") - : _query; - - if (isNil(query)) { - return [ - , - createHttpError( - Status.BadRequest, - `The parameter is required. "query"`, - ), - ]; - } - - if (!isString(query)) { - return [ - , - createHttpError( - Status.BadRequest, - `The parameter is invalid. "query" must be string.`, - ), - ]; - } - - if (!isNull(variables) && !isPlainObject(variables)) { - return [ - , - createHttpError( - Status.BadRequest, - `The parameter is invalid. "variables" must be JSON object format`, - ), - ]; - } - if (!isStringOrNull(operationName)) { - return [ - , - createHttpError( - Status.BadRequest, - `The parameter is invalid. "operationName" must be string or null.`, - ), - ]; - } - - return [{ - query, - operationName, - variables, - extensions: null, - }]; - } - - case "application/graphql+json": { - const body = await req.text(); - const fromQueryString = new URL(req.url).searchParams.get("query"); - const query = !body ? fromQueryString : body; - - if (!query) { - return [ - , - createHttpError( - Status.BadRequest, - `The message body is required. "GraphQL query"`, - ), - ]; - } - return [{ - query, - operationName: null, - variables: null, - extensions: null, - }]; - } - - default: { - return [ - , - createHttpError( - Status.UnsupportedMediaType, - `The header is invalid. "Content-Type" must be "application/json" or "application/graphql+json"`, - ), - ]; - } - } -} - -function resolveAcceptHeader( - req: Request, -): [data: string, error: undefined] | [ - data: undefined, - error: HttpError, -] { - // Accept header is not provided, treat the request has `Accept: application/json` - // From 1st January 2025 (2025-01-01T00:00:00Z), treat the request has `Accept: application/graphql+json` - // @see https://graphql.github.io/graphql-over-http/draft/#sec-Legacy-watershed - if (!req.headers.has("accept")) { - return [ - "application/json", - undefined, - ]; - } - - const acceptResult = accepts( - req, - "application/graphql+json", - "application/json", - ); - - if (!acceptResult) { - return [ - , - createHttpError( - Status.NotAcceptable, - `The header is invalid. "Accept" must include "application/graphql+json" or "application/json"`, - ), - ]; - } - - return [acceptResult, undefined]; -} - -function isPlainObject(value: unknown): value is Record { - return isObject(value) && value.constructor === Object; -} - -function isStringOrNull(value: unknown): value is string | null { - return isString(value) || isNull(value); -} diff --git a/requests_test.ts b/requests_test.ts deleted file mode 100644 index 461fc50..0000000 --- a/requests_test.ts +++ /dev/null @@ -1,794 +0,0 @@ -import { - createRequest, - resolveGetRequest, - resolvePostRequest, - resolveRequest, -} from "./requests.ts"; -import { - BaseRequest, - contentType, - describe, - expect, - HttpError, - it, - queryString, -} from "./dev_deps.ts"; - -const describeCreateRequestTests = describe("createRequest"); - -it( - describeCreateRequestTests, - "should return Response object with method is POST, headers contain content-type, body has JSON data", - async () => { - const request = createRequest({ - url: new URL("http://localhost/"), - query: `query { hello }`, - method: "POST", - }); - - expect(request[1]).toBeUndefined(); - expect( - request[0]?.method, - ).toBe("POST"); - expect( - request[0]?.url, - ).toBe("http://localhost/"); - await expect( - request[0]!.json(), - ).resolves.toEqual({ query: `query { hello }` }); - expect( - request[0]!.headers, - ).toEqualIterable( - new Headers({ - accept: "application/graphql+json, application/json", - "content-type": "application/json; charset=UTF-8", - }), - ); - }, -); - -it( - describeCreateRequestTests, - "should return Response object when pass the variables and operationName", - async () => { - const request = createRequest({ - url: new URL("http://localhost/"), - query: `query Greet(id: $id) { hello(id: $id) }`, - method: "POST", - }, { - variables: { - "id": "test", - }, - operationName: "Greet", - }); - - expect(request[1]).toBeUndefined(); - await expect( - request[0]!.json(), - ).resolves.toEqual({ - query: `query Greet(id: $id) { hello(id: $id) }`, - variables: { id: "test" }, - operationName: "Greet", - }); - }, -); - -it( - describeCreateRequestTests, - "should return Response object what url is with query string without variable and operationName", - () => { - const request = createRequest({ - url: new URL("http://localhost/"), - query: `query { hello }`, - method: "GET", - }); - - expect(request[0]!.url).toBe( - "http://localhost/?query=query+%7B+hello+%7D", - ); - }, -); - -it( - describeCreateRequestTests, - "should return Response object what url is with query string when pass GET method", - async () => { - const request = createRequest({ - url: new URL("http://localhost/"), - query: `query { hello }`, - method: "GET", - }, { - variables: { - "id": "test", - }, - operationName: "Greet", - }); - - expect(request[1]).toBeUndefined(); - expect(request[0]?.method).toBe("GET"); - expect(request[0]!.headers).toEqualIterable( - new Headers({ accept: "application/graphql+json, application/json" }), - ); - expect(request[0]!.url).toBe( - "http://localhost/?query=query+%7B+hello+%7D&variables=%7B%22id%22%3A%22test%22%7D&operationName=Greet", - ); - await expect( - request[0]!.text(), - ).resolves.toEqual(""); - }, -); - -// it( -// describeCreateRequestTests, -// "should return Response what include custom header when pass request init", -// () => { -// const request = createRequest( -// { -// url: new URL("http://localhost/"), -// query: `query { hello }`, -// method: "GET", -// }, -// undefined, -// { -// headers: { -// "x-test": "test", -// }, -// }, -// ); - -// expect(request[1]).toBeUndefined(); -// expect(request[0]!.headers).toEqualIterable( -// new Headers({ -// accept: "application/graphql+json, application/json", -// "x-test": "test", -// }), -// ); -// }, -// ); - -// it( -// describeCreateRequestTests, -// "should return Response what include custom merged header that when pass request init", -// () => { -// const request = createRequest( -// { -// url: new URL("http://localhost/"), -// query: `query { hello }`, -// method: "GET", -// }, -// undefined, -// { -// headers: { -// "accept": "text/html", -// "x-test": "test", -// }, -// }, -// ); - -// expect(request[1]).toBeUndefined(); -// expect(request[0]!.headers).toEqualIterable( -// new Headers({ -// accept: "text/html, application/graphql+json, application/json", -// "x-test": "test", -// }), -// ); -// }, -// ); - -it( - describeCreateRequestTests, - "should return error when url is invalid", - () => { - const request = createRequest({ - url: "", - query: `query { hello }`, - method: "GET", - }); - - expect(request[0]).toBeUndefined(); - expect(request[1]).toError(TypeError, "Invalid URL"); - }, -); - -const describeGetTests = describe("resolveGetRequest"); -const BASE_URL = "https://test.test"; - -it( - describe("resolveRequest"), - "return HttpError when The HTTP method is not GET or not POST", - async () => { - await Promise.all( - ["OPTION", "HEAD", "PUT", "DELETE", "PATCH"].map( - async (method) => { - const [, err] = await resolveRequest( - new BaseRequest(BASE_URL, { - method, - }), - ); - expect(err).toError( - HttpError, - `Invalid HTTP method. GraphQL only supports GET and POST requests.`, - ); - }, - ), - ); - }, -); - -it( - describeGetTests, - `should return error when header of "Accept" does not contain "application/graphql+json" or "application/json"`, - () => { - const result = resolveGetRequest( - new Request(BASE_URL, { - headers: { - accept: contentType("txt"), - }, - }), - ); - expect(result[0]).toBeUndefined(); - expect(result[1]).toError( - HttpError, - `The header is invalid. "Accept" must include "application/graphql+json" or "application/json"`, - ); - }, -); - -it( - describeGetTests, - `should return data when header of "Accept" contain "application/json"`, - () => { - const result = resolveGetRequest( - new Request(queryString(BASE_URL, { query: `query` }), { - headers: { - Accept: `application/json`, - }, - }), - ); - expect(result[1]).toBeUndefined(); - expect(result[0]).toEqual({ - query: "query", - variables: null, - operationName: null, - extensions: null, - }); - }, -); - -it( - describeGetTests, - `should return data when header of "Accept" contain "application/graphql+json"`, - () => { - const result = resolveGetRequest( - new Request(queryString(BASE_URL, { query: `query` }), { - headers: { - Accept: `application/graphql+json`, - }, - }), - ); - expect(result[1]).toBeUndefined(); - expect(result[0]).toEqual({ - query: "query", - variables: null, - operationName: null, - extensions: null, - }); - }, -); - -it( - describeGetTests, - `should return error when query string of "query" is not exists`, - () => { - const result = resolveGetRequest(new BaseRequest(BASE_URL)); - expect(result[0]).toBeUndefined(); - expect(result[1]).toError( - HttpError, - `The parameter is required. "query"`, - ); - }, -); - -it( - describeGetTests, - `should return error when query string of "variables" is invalid JSON`, - () => { - const url = new URL("?query=query&variables=test", BASE_URL); - const result = resolveGetRequest(new BaseRequest(url.toString())); - expect(result[0]).toBeUndefined(); - expect(result[1]).toError( - HttpError, - `The parameter is invalid. "variables" are invalid JSON.`, - ); - }, -); - -it( - describeGetTests, - `should return data when query string of valid`, - () => { - const url = new URL( - `?query=query&variables={"test":"test"}&operationName=query`, - BASE_URL, - ); - const result = resolveGetRequest(new BaseRequest(url.toString())); - expect(result[0]).toEqual({ - query: `query`, - variables: { - test: "test", - }, - operationName: "query", - extensions: null, - }); - expect(result[1]).toBeUndefined(); - }, -); - -const describePostTests = describe("resolvePostRequest"); - -it( - describePostTests, - `should return error when "Content-Type" header is missing`, - async () => { - const result = await resolvePostRequest( - new BaseRequest(BASE_URL, { - method: "POST", - }), - ); - - expect(result[0]).toBeUndefined(); - expect(result[1]).toError( - HttpError, - `The header is required. "Content-Type"`, - ); - }, -); - -it( - describePostTests, - `should return error when "Content-Type" header is unknown`, - async () => { - const result = await resolvePostRequest( - new BaseRequest(BASE_URL, { - method: "POST", - headers: { - "content-type": "plain/txt", - }, - }), - ); - - expect(result[0]).toBeUndefined(); - expect(result[1]).toError( - HttpError, - 'The header is invalid. "Content-Type" must be "application/json" or "application/graphql+json"', - ); - }, -); - -it(describePostTests, `application/json`, async (t) => { - await t.step( - `should return error when header of "Accept" does not contain "application/graphql" or "application/json"`, - async () => { - const result = await resolvePostRequest( - new Request(BASE_URL, { - method: "POST", - headers: { - accept: contentType("txt"), - }, - }), - ); - expect(result[0]).toBeUndefined(); - expect(result[1]).toError( - HttpError, - `The header is invalid. "Accept" must include "application/graphql+json" or "application/json"`, - ); - }, - ); - - await t.step( - `should return error when header of "Content-Type" is not exists`, - async () => { - const result = await resolvePostRequest( - new BaseRequest(BASE_URL, { - method: "POST", - }), - ); - expect(result[0]).toBeUndefined(); - expect(result[1]).toError( - HttpError, - `The header is required. "Content-Type"`, - ); - }, - ); - - await t.step( - `should return error when header of "Content-Type" does not contain "application/graphql+json" or "application/json"`, - async () => { - const result = await resolvePostRequest( - new BaseRequest(BASE_URL, { - method: "POST", - headers: { - "content-type": contentType("txt"), - }, - }), - ); - expect(result[0]).toBeUndefined(); - expect(result[1]).toError( - HttpError, - `The header is invalid. "Content-Type" must be "application/json" or "application/graphql+json"`, - ); - }, - ); - - await t.step( - `should return error when header of "Content-Type" charset is not "UTF-8"`, - async () => { - const result = await resolvePostRequest( - new BaseRequest(BASE_URL, { - method: "POST", - headers: { - "content-type": "application/graphql+json; charset=utf-16", - }, - }), - ); - expect(result[0]).toBeUndefined(); - expect(result[1]).toError( - HttpError, - `The header is invalid. Supported media type charset is "UTF-8".`, - ); - }, - ); - - await t.step( - "should return error when message body is invalid JSON format", - async () => { - const result = await resolvePostRequest( - new BaseRequest(BASE_URL, { - method: "POST", - headers: { - "content-type": "application/json", - }, - }), - ); - expect(result[0]).toBeUndefined(); - expect(result[1]).toError( - HttpError, - `The message body is invalid. Invalid JSON format.`, - ); - }, - ); - await t.step( - "should return error when message body is not JSON object format", - async () => { - const result = await resolvePostRequest( - new BaseRequest(BASE_URL, { - body: "true", - method: "POST", - headers: { - "content-type": "application/json", - }, - }), - ); - expect(result[0]).toBeUndefined(); - expect(result[1]).toError( - HttpError, - `The message body is invalid. Must be JSON object format.`, - ); - }, - ); - await t.step( - "should return error when query parameter from message body or query string is not exists", - async () => { - const result = await resolvePostRequest( - new BaseRequest(BASE_URL, { - body: `{"query":null}`, - method: "POST", - headers: { - "content-type": "application/json", - }, - }), - ); - expect(result[0]).toBeUndefined(); - expect(result[1]).toError( - HttpError, - `The parameter is required. "query"`, - ); - }, - ); - await t.step( - "should return error when query parameter from message body is not string format", - async () => { - const result = await resolvePostRequest( - new BaseRequest(BASE_URL, { - body: `{"query":0}`, - method: "POST", - headers: { - "content-type": "application/json", - }, - }), - ); - expect(result[0]).toBeUndefined(); - expect(result[1]).toError( - HttpError, - `The parameter is invalid. "query" must be string.`, - ); - }, - ); - await t.step( - "should return data when parameter of query string is exists", - async () => { - const url = new URL(`?query=test`, BASE_URL); - const result = await resolvePostRequest( - new BaseRequest(url.toString(), { - body: `{"query":null}`, - method: "POST", - headers: { - "content-type": "application/json", - }, - }), - ); - expect(result[0]).toEqual({ - query: "test", - operationName: null, - variables: null, - extensions: null, - }); - expect(result[1]).toBeUndefined(); - }, - ); - await t.step( - `should return data what "query" is from body when message body of "query" and query string of "query" are exist`, - async () => { - const url = new URL(`?query=from-query-string`, BASE_URL); - const result = await resolvePostRequest( - new BaseRequest(url.toString(), { - body: `{"query":"from body"}`, - method: "POST", - headers: { - "content-type": "application/json", - }, - }), - ); - expect(result[0]).toEqual({ - query: "from body", - operationName: null, - variables: null, - extensions: null, - }); - expect(result[1]).toBeUndefined(); - }, - ); - await t.step( - `should return data when "Content-Type" is application/json; charset=utf-8`, - async () => { - const result = await resolvePostRequest( - new BaseRequest(BASE_URL, { - body: `{"query":"from body"}`, - method: "POST", - headers: { - "content-type": "application/json; charset=utf-8", - }, - }), - ); - expect(result[0]).toEqual({ - query: "from body", - operationName: null, - variables: null, - extensions: null, - }); - expect(result[1]).toBeUndefined(); - }, - ); - await t.step( - `should return error when "variables" is not JSON object format`, - async () => { - const result = await resolvePostRequest( - new BaseRequest(BASE_URL, { - body: `{"query":"query","variables":false}`, - method: "POST", - headers: { - "content-type": "application/json", - }, - }), - ); - expect(result[0]).toBeUndefined(); - expect(result[1]).toError( - HttpError, - 'The parameter is invalid. "variables" must be JSON object format', - ); - }, - ); - await t.step( - `should return data when "variables" is null`, - async () => { - const result = await resolvePostRequest( - new BaseRequest(BASE_URL, { - body: `{"query":"query","variables":null}`, - method: "POST", - headers: { - "content-type": "application/json", - }, - }), - ); - expect(result[0]).toEqual({ - query: "query", - operationName: null, - variables: null, - extensions: null, - }); - expect(result[1]).toBeUndefined(); - }, - ); - await t.step( - `should return data when "variables" is JSON object format`, - async () => { - const result = await resolvePostRequest( - new BaseRequest(BASE_URL, { - body: `{"query":"query","variables":{"abc":[]}}`, - method: "POST", - headers: { - "content-type": "application/json", - }, - }), - ); - expect(result[0]).toEqual({ - query: "query", - operationName: null, - variables: { abc: [] }, - extensions: null, - }); - expect(result[1]).toBeUndefined(); - }, - ); - await t.step( - `should return error when "operationName" is not string or not null`, - async () => { - const result = await resolvePostRequest( - new BaseRequest(BASE_URL, { - body: `{"query":"query","operationName":0}`, - method: "POST", - headers: { - "content-type": "application/json", - }, - }), - ); - expect(result[0]).toBeUndefined(); - expect(result[1]).toError( - HttpError, - 'The parameter is invalid. "operationName" must be string or null.', - ); - }, - ); - await t.step( - `should return data when "operationName" is string`, - async () => { - const result = await resolvePostRequest( - new BaseRequest(BASE_URL, { - body: `{"query":"query","operationName":"subscription"}`, - method: "POST", - headers: { - "content-type": "application/json", - }, - }), - ); - expect(result[0]).toEqual({ - query: "query", - operationName: "subscription", - variables: null, - extensions: null, - }); - expect(result[1]).toBeUndefined(); - }, - ); -}); - -it( - describePostTests, - `application/graphql+json`, - async (t) => { - await t.step( - "should return error when the message body is empty", - async () => { - const result = await resolvePostRequest( - new BaseRequest(BASE_URL, { - method: "POST", - headers: { - "content-type": "application/graphql+json", - }, - }), - ); - expect(result[0]).toBeUndefined(); - expect(result[1]).toError( - HttpError, - `The message body is required. "GraphQL query"`, - ); - }, - ); - await t.step( - `should return data when the message body is exists`, - async () => { - const result = await resolvePostRequest( - new BaseRequest(BASE_URL, { - body: "test", - method: "POST", - headers: { - "content-type": "application/graphql+json", - }, - }), - ); - expect(result[0]).toEqual({ - query: "test", - operationName: null, - variables: null, - extensions: null, - }); - expect(result[1]).toBeUndefined(); - }, - ); - await t.step( - `should return data when the message body is exists and content-type with charset`, - async () => { - const result = await resolvePostRequest( - new BaseRequest(BASE_URL, { - body: "test", - method: "POST", - headers: { - "content-type": "application/graphql+json;charset=utf-8", - }, - }), - ); - expect(result[0]).toEqual({ - query: "test", - operationName: null, - variables: null, - extensions: null, - }); - expect(result[1]).toBeUndefined(); - }, - ); - - await t.step( - `should return data when the query string of "query" is exists`, - async () => { - const url = new URL("?query=test", BASE_URL); - const result = await resolvePostRequest( - new BaseRequest(url.toString(), { - method: "POST", - headers: { - "content-type": "application/graphql+json", - }, - }), - ); - expect(result[0]).toEqual({ - query: "test", - operationName: null, - variables: null, - extensions: null, - }); - expect(result[1]).toBeUndefined(); - }, - ); - await t.step( - `should return message body data when message body and query string of "query" is exists`, - async () => { - const url = new URL("?query=from-query", BASE_URL); - const result = await resolvePostRequest( - new BaseRequest(url.toString(), { - body: "from body", - method: "POST", - headers: { - "content-type": "application/graphql+json", - }, - }), - ); - expect(result[0]).toEqual({ - query: "from body", - operationName: null, - variables: null, - extensions: null, - }); - expect(result[1]).toBeUndefined(); - }, - ); - }, -); diff --git a/responses.ts b/responses.ts deleted file mode 100644 index 937f8a6..0000000 --- a/responses.ts +++ /dev/null @@ -1,302 +0,0 @@ -import { - executeSync, - ExecutionResult, - getOperationAST, - GraphQLArgs, - GraphQLError, - isString, - isUndefined, - jsonObject, - parse, - specifiedRules, - Status, - stringify, - tryCatchSync, - validate, -} from "./deps.ts"; -import { ApplicationGraphQLJson, ApplicationJson, Result } from "./types.ts"; -import { APPLICATION_GRAPHQL_JSON, APPLICATION_JSON } from "./constants.ts"; -import { mergeInit } from "./utils.ts"; - -export type Params = { - mimeType: ApplicationGraphQLJson | ApplicationJson; -} & ExecutionResult; - -export function createResponseFromResult( - { mimeType, errors, extensions, data }: Params, -): [data: Response] | [data: undefined, err: TypeError] { - const [resultStr, err] = stringify({ errors, extensions, data }); - - if (err) { - return [, err]; - } - switch (mimeType) { - case APPLICATION_JSON: { - return [ - new Response(resultStr, { - status: Status.OK, - headers: { - "content-type": withCharset(mimeType), - }, - }), - ]; - } - - case APPLICATION_GRAPHQL_JSON: { - const status = isUndefined(data) ? Status.BadRequest : Status.OK; - return [ - new Response(resultStr, { - status, - headers: { - "content-type": withCharset(mimeType), - }, - }), - ]; - } - } -} - -export function withCharset(value: T): `${T}; charset=UTF-8` { - return `${value}; charset=UTF-8`; -} - -import { PickPartial, PickRequired } from "./deps.ts"; - -type ResponseParams = PickRequired & { - method: "GET" | "POST"; -}; - -type ResponseOptions = PickPartial & { - mimeType: ApplicationGraphQLJson | ApplicationJson; -}; - -/** - * Create a GraphQL over HTTP compliant `Response` object. - * ```ts - * import { - * createResponse, - * } from "https://deno.land/x/graphql_http@$VERSION/mod.ts"; - * import { buildSchema } from "https://esm.sh/graphql@$VERSION"; - * - * const schema = buildSchema(`query { - * hello: String! - * }`); - * - * const res = createResponse({ - * schema, - * source: `query { hello }`, - * method: "POST", - * }, { - * rootValue: { - * hello: "world", - * }, - * }); - * ``` - */ -export function createResponse( - { source, method, schema }: Readonly, - { - operationName, - variableValues, - rootValue, - contextValue, - fieldResolver, - typeResolver, - mimeType = "application/graphql+json", - }: Readonly> = {}, -): Response { - const ContentType = withCharset(mimeType); - const errorStatus = getErrorStatus(mimeType); - - const parseResult = tryCatchSync(() => parse(source)); - if (!parseResult[0]) { - const result = createResult(parseResult[1]); - - const res = createJSONResponse(result, { - status: errorStatus, - headers: { - "content-type": ContentType, - }, - }); - return res; - } - const documentAST = parseResult[0]; - const operationAST = getOperationAST(documentAST, operationName); - - if ( - method === "GET" && operationAST && operationAST.operation !== "query" - ) { - const result = createResult( - `Invalid GraphQL operation. Can only perform a ${operationAST.operation} operation from a POST request.`, - ); - - const res = createJSONResponse(result, { - status: Status.MethodNotAllowed, - headers: { - "content-type": ContentType, - allow: "POST", - }, - }); - - return res; - } - - const validationErrors = validate(schema, documentAST, specifiedRules); - if (validationErrors.length > 0) { - const result: ExecutionResult = { errors: validationErrors }; - - const res = createJSONResponse(result, { - status: errorStatus, - headers: { - "content-type": ContentType, - }, - }); - - return res; - } - - const [executionResult, executionErrors] = tryCatchSync(() => - executeSync({ - schema, - document: documentAST, - operationName, - variableValues, - rootValue, - contextValue, - fieldResolver, - typeResolver, - }) - ); - if (executionResult) { - const [data, err] = createResponseFromResult({ - mimeType, - ...executionResult, - }); - - if (!data) { - const result = createResult(err); - - const res = createJSONResponse(result, { - status: Status.InternalServerError, - headers: { - "content-type": ContentType, - }, - }); - return res; - } - - return data; - } - - const result = createResult(executionErrors); - const res = createJSONResponse(result, { - status: Status.InternalServerError, - headers: { - "content-type": ContentType, - }, - }); - return res; -} - -export function createResult(error: unknown): ExecutionResult { - const graphqlError = resolveError(error); - const result: ExecutionResult = { errors: [graphqlError] }; - return result; -} - -export function createJSONResponse( - result: ExecutionResult, - responseInit: ResponseInit, -): Response { - const [data, err] = stringify(result); - - if (err) { - const result = createResult(err); - const [body] = JSON.stringify(result); - const init = mergeInit(responseInit, { - status: Status.InternalServerError, - }); - return new Response(body, init[0]); - } - - const response = new Response(data, responseInit); - - return response; -} - -// When "Content-Type" is `application/json`, all Request error should be `200` status code. @see https://graphql.github.io/graphql-over-http/draft/#sec-application-json -// Validation error is Request error. @see https://spec.graphql.org/draft/#sec-Errors.Request-errors -function getErrorStatus( - mediaType: "application/graphql+json" | "application/json", -): number { - return mediaType === "application/json" ? Status.OK : Status.BadRequest; -} - -function resolveError(er: unknown): GraphQLError { - if (er instanceof GraphQLError) return er; - if (isString(er)) return new GraphQLError(er); - - return er instanceof Error - ? new GraphQLError( - er.message, - undefined, - undefined, - undefined, - undefined, - er, - ) - : new GraphQLError("Unknown error has occurred."); -} - -/** - * Resolve GraphQL over HTTP response safety. - * @param res `Response` object - * @throws Error - * @throws AggregateError - * @throws SyntaxError - * @throws TypeError - * ```ts - * import { - * resolveResponse, - * } from "https://deno.land/x/graphql_http@$VERSION/mod.ts"; - * - * const res = new Response(); // any Response - * const result = await resolveResponse(res); - * ``` - */ -export async function resolveResponse( - res: Response, -): Promise> { - const contentType = res.headers.get("content-type"); - - if (!contentType) { - throw Error(`"Content-Type" header is required`); - } - - if (!isValidContentType(contentType)) { - throw Error( - `Valid "Content-Type" is application/graphql+json or application/json`, - ); - } - - const json = await res.json() as Result; - - 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"); - } -} - -export function isValidContentType(value: string): boolean { - return ["application/json", "application/graphql+json"].some((mimeType) => - value.includes(mimeType) - ); -} diff --git a/responses_test.ts b/responses_test.ts deleted file mode 100644 index 723f82d..0000000 --- a/responses_test.ts +++ /dev/null @@ -1,374 +0,0 @@ -import { - createResponse, - isValidContentType, - resolveResponse, -} from "./responses.ts"; -import { - buildSchema, - contentType, - describe, - expect, - it, - Status, -} from "./dev_deps.ts"; - -const schema = buildSchema(`type Query { - hello: String! -} -`); - -function assertHeaderAppJson(headers: Headers): void { - expect(headers).toEqualIterable( - new Headers({ - "content-type": "application/json; charset=UTF-8", - }), - ); -} - -function assertHeaderAppGraphqlJson(headers: Headers): void { - expect(headers).toEqualIterable( - new Headers({ - "content-type": "application/graphql+json; charset=UTF-8", - }), - ); -} - -function assertStatusOK(status: number): asserts status is 200 { - expect(status).toBe(Status.OK); -} - -function assertStatusBadRequest(status: number): asserts status is 200 { - expect(status).toBe(Status.BadRequest); -} - -describe("createResponse", () => { - describe("application/json", () => { - const mimeType = "application/json"; - it("should return status 200 when parse error has occurred", async () => { - const res = createResponse({ - source: ``, - method: "POST", - schema, - }, { - mimeType, - }); - - assertStatusOK(res.status); - assertHeaderAppJson(res.headers); - await expect(res.json()).resolves.toEqual({ - errors: [{ - message: "Syntax Error: Unexpected .", - locations: [{ "line": 1, "column": 1 }], - }], - }); - }); - it("should return status 200 when validate error has occurred", async () => { - const res = createResponse({ - source: `query { fail }`, - method: "POST", - schema, - }, { - mimeType, - }); - - assertStatusOK(res.status); - assertHeaderAppJson(res.headers); - await expect(res.json()).resolves.toEqual({ - errors: [ - { - message: 'Cannot query field "fail" on type "Query".', - locations: [{ "line": 1, "column": 9 }], - }, - ], - }); - }); - it("should return status 200 when execution error has occurred", async () => { - const res = createResponse({ - source: `query { hello }`, - method: "POST", - schema, - }, { - mimeType, - }); - - assertStatusOK(res.status); - assertHeaderAppJson(res.headers); - await expect(res.json()).resolves.toEqual({ - errors: [ - { - message: "Cannot return null for non-nullable field Query.hello.", - locations: [{ "line": 1, "column": 9 }], - path: ["hello"], - }, - ], - data: null, - }); - }); - it("should return status 200 when execution is complete", async () => { - const res = createResponse({ - source: `query { hello }`, - method: "POST", - schema, - }, { - rootValue: { - hello: "world", - }, - mimeType, - }); - - assertStatusOK(res.status); - assertHeaderAppJson(res.headers); - await expect(res.json()).resolves.toEqual({ - data: { hello: "world" }, - }); - }); - - it("should return status 405 when method is GET and operation type is mutation", async () => { - const res = createResponse({ - source: `mutation { hello }`, - method: "GET", - schema, - }, { - mimeType, - }); - - expect(res.status).toBe(Status.MethodNotAllowed); - expect(res.headers).toEqualIterable( - new Headers({ - "content-type": contentType(".json"), - allow: "POST", - }), - ); - await expect(res.json()).resolves.toEqual({ - errors: [ - { - message: - "Invalid GraphQL operation. Can only perform a mutation operation from a POST request.", - }, - ], - }); - }); - it("should return status 405 when method is GET and operation type is subscription", async () => { - const res = createResponse({ - source: `subscription { hello }`, - method: "GET", - schema, - }, { - mimeType, - }); - - expect(res.status).toBe(Status.MethodNotAllowed); - expect(res.headers).toEqualIterable( - new Headers({ - "content-type": contentType(".json"), - allow: "POST", - }), - ); - await expect(res.json()).resolves.toEqual({ - errors: [ - { - message: - "Invalid GraphQL operation. Can only perform a subscription operation from a POST request.", - }, - ], - }); - }); - }); - - describe("application/graphql+json", () => { - it("should return status 400 when parse error has occurred", async () => { - const res = createResponse({ - source: ``, - method: "POST", - schema, - }); - - assertStatusBadRequest(res.status); - assertHeaderAppGraphqlJson(res.headers); - await expect(res.json()).resolves.toEqual({ - errors: [{ - message: "Syntax Error: Unexpected .", - locations: [{ "line": 1, "column": 1 }], - }], - }); - }); - it("should return status 400 when validate error has occurred", async () => { - const res = createResponse({ - source: `query { fail }`, - method: "POST", - schema, - }); - - assertStatusBadRequest(res.status); - assertHeaderAppGraphqlJson(res.headers); - await expect(res.json()).resolves.toEqual({ - errors: [ - { - message: 'Cannot query field "fail" on type "Query".', - locations: [{ "line": 1, "column": 9 }], - }, - ], - }); - }); - it("should return status 200 when execution error has occurred", async () => { - const res = createResponse({ - source: `query { hello }`, - method: "POST", - schema, - }); - - assertStatusOK(res.status); - assertHeaderAppGraphqlJson(res.headers); - await expect(res.json()).resolves.toEqual({ - errors: [ - { - message: "Cannot return null for non-nullable field Query.hello.", - locations: [{ "line": 1, "column": 9 }], - path: ["hello"], - }, - ], - data: null, - }); - }); - it("should return status 200 when execution is complete", async () => { - const res = createResponse({ - source: `query { hello }`, - method: "POST", - schema, - }, { - rootValue: { - hello: "world", - }, - }); - - assertStatusOK(res.status); - assertHeaderAppGraphqlJson(res.headers); - await expect(res.json()).resolves.toEqual({ - data: { hello: "world" }, - }); - }); - }); -}); - -describe("isValidContentType", () => { - it("should pass application/json and application/graphql+json", () => { - expect(isValidContentType("application/graphql+json")).toBeTruthy(); - expect(isValidContentType("application/json")).toBeTruthy(); - expect(isValidContentType("application/json; charset=UTF-8")).toBeTruthy(); - expect(isValidContentType("application/json;charset=UTF-8")).toBeTruthy(); - expect(isValidContentType("application/graphql+json; charset=UTF-8")); - expect(isValidContentType("application/graphql+json;charset=UTF-8")) - .toBeTruthy(); - }); - - it("should not pass", () => { - expect(isValidContentType("text/html")).toBeFalsy(); - expect(isValidContentType("text/html; charset=UTF-8")).toBeFalsy(); - expect(isValidContentType("*/*")).toBeFalsy(); - }); -}); - -const describeResolveRequestTests = describe("resolveRequest"); - -it( - describeResolveRequestTests, - `should throw error when header of "Content-Type" is not exists`, - async () => { - const res = new Response(); - - await expect(resolveResponse(res)).rejects.toError( - Error, - `"Content-Type" header is required`, - ); - }, -); - -it( - describeResolveRequestTests, - `should throw error when header of "Content-Type" is not valid`, - async () => { - const res = new Response(""); - await expect(resolveResponse(res)).rejects.toError( - Error, - `Valid "Content-Type" is application/graphql+json or application/json`, - ); - }, -); - -it( - describeResolveRequestTests, - `should throw error when body is not JSON format`, - async () => { - const res = new Response("", { - headers: { - "Content-Type": "application/json", - }, - }); - await expect(resolveResponse(res)).rejects.toError( - SyntaxError, - ); - }, -); - -it( - describeResolveRequestTests, - `should throw error when ok is not true and body is not graphql response`, - async () => { - const res = new Response(JSON.stringify({ errors: [] }), { - headers: { - "Content-Type": "application/json", - }, - }); - - Object.defineProperty(res, "ok", { - value: false, - }); - - await expect(resolveResponse(res)).rejects.toError( - AggregateError, - "GraphQL request error has occurred", - ); - }, -); - -it( - describeResolveRequestTests, - `should throw error when ok is not true and body is not graphql response`, - async () => { - const res = new Response("{}", { - headers: { - "Content-Type": "application/json", - }, - }); - - Object.defineProperty(res, "ok", { - value: false, - }); - - await expect(resolveResponse(res)).rejects.toError( - Error, - "Unknown error has occurred", - ); - }, -); - -it( - describeResolveRequestTests, - `should return graphql response`, - async () => { - const res = new Response( - JSON.stringify({ - data: {}, - }), - { - headers: { - "Content-Type": "application/json", - }, - }, - ); - - await expect(resolveResponse(res)).resolves.toEqual({ - data: {}, - }); - }, -); diff --git a/types.ts b/types.ts index 6cfd30f..cb26836 100644 --- a/types.ts +++ b/types.ts @@ -1,70 +1,6 @@ -// deno-lint-ignore-file no-explicit-any -import { APPLICATION_GRAPHQL_JSON, APPLICATION_JSON } from "./constants.ts"; -import { - GraphQLError, - GraphQLFieldResolver, - GraphQLSchema, - GraphQLTypeResolver, - json, - PickBy, - Source, -} from "./deps.ts"; -export type ApplicationJson = typeof APPLICATION_JSON; -export type ApplicationGraphQLJson = typeof APPLICATION_GRAPHQL_JSON; +// Copyright 2022-latest the graphqland authors. All rights reserved. MIT license. +// This module is browser compatible. -export type SerializedGraphQLError = PickBy; +import { ExecutionParams } from "./deps.ts"; -export type Result< - T extends Record = Record, -> = { - data?: T | null; - errors?: SerializedGraphQLError[]; - extensions?: unknown; -}; - -/** GraphQL over HTTP Request parameters. - * @see https://graphql.github.io/graphql-over-http/draft/#sec-Request-Parameters - */ -export type GraphQLParameters = { - /** A Document containing GraphQL Operations and Fragments to execute. */ - query: string; - - /** Values for any Variables defined by the Operation. */ - variables: Record | null; - - /** The name of the Operation in the Document to execute. */ - operationName: string | null; - - /** Reserved for implementors to extend the protocol however they see fit. */ - extensions: Record | null; -}; - -export type GraphQLRequiredArgs = { - /** The GraphQL type system to use when validating and executing a query. */ - schema: GraphQLSchema; - - /** A GraphQL language formatted string representing the requested operation. */ - source: string | Source; -}; - -export type GraphQLOptionalArgs = { - /** The value provided as the first argument to resolver functions on the top level type (e.g. the query object type). */ - rootValue: unknown; - - /** The context value is provided as an argument to resolver functions after field arguments. It is used to pass shared information useful at any point during executing this query, for example the currently logged in user and connections to databases or other services. */ - contextValue: unknown; - - /** A mapping of variable name to runtime value to use for all variables defined in the requestString. */ - variableValues: { - readonly [variable: string]: unknown; - } | null; - - /** The name of the operation to use if requestString contains multiple possible operations. Can be omitted if requestString contains only one operation. */ - operationName: string | null; - - /** A resolver function to use when one is not provided by the schema. If not provided, the default field resolver is used (which looks for a value or method on the source value with the field's name). */ - fieldResolver: GraphQLFieldResolver | null; - - /** A type resolver function to use when none is provided by the schema. If not provided, the default type resolver is used (which looks for a `__typename` field or alternatively calls the `isTypeOf` method). */ - typeResolver: GraphQLTypeResolver | null; -}; +export type HandlerOptions = Omit; diff --git a/use/playground.ts b/use/playground.ts deleted file mode 100644 index 7b00344..0000000 --- a/use/playground.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { - contentType, - RenderPageOptions, - renderPlaygroundPage, - Status, -} from "../deps.ts"; -import { validatePlaygroundRequest } from "../validates.ts"; - -/** Use GraphQL Playground as handler. - * @param handler The handler for individual HTTP requests. - * @param options The [graphql-playground](https://github.com/graphql/graphql-playground) options. - * ```ts - * import { - * createHandler, - * usePlayground, - * } from "https://deno.land/x/graphql_http@$VERSION/mod.ts"; - * import { buildSchema } from "https://esm.sh/graphql@$VERSION"; - * - * const schema = buildSchema(`type Query { - * hello: String! - * }`); - * - * let handler = createHandler(schema, { - * rootValue: { - * hello: "world", - * }, - * }); - * handler = usePlayground(handler); - * const req = new Request(""); - * const res = await handler(req); - * ``` - */ -export default function usePlayground( - handler: (req: Request) => Promise | Response, - /** - * @default `{ endpoint: "/graphql"}` - */ - options: RenderPageOptions = { endpoint: "/graphql" }, -): (req: Request) => Promise | Response { - return (req) => { - const result = validatePlaygroundRequest(req); - if (result) { - const playground = renderPlaygroundPage(options); - const res = new Response(playground, { - status: Status.OK, - headers: { "content-type": contentType("text/html") }, - }); - - return res; - } - - return handler(req); - }; -} diff --git a/use/playground_test.ts b/use/playground_test.ts deleted file mode 100644 index 5446aff..0000000 --- a/use/playground_test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import usePlayground from "./playground.ts"; -import createHandler from "../handler.ts"; -import { describe, expect, it } from "../dev_deps.ts"; -import { buildSchema, contentType, Status } from "../deps.ts"; - -describe("usePlayground", () => { - it("should render graphql playground when method is GET and accept includes text/html", async () => { - const handler = usePlayground(() => new Response()); - const req = new Request("http://localhost/test.com", { - method: "GET", - headers: { - accept: "text/html", - }, - }); - const res = await handler(req); - const text = await res.text(); - expect(text.startsWith("\n ")).toBeTruthy(); - expect(res.status).toBe(Status.OK); - expect(res.headers).toEqualIterable( - new Headers({ - "content-type": contentType("text/html"), - }), - ); - }); - - it("should return next response when request is not to playground", async () => { - const nextRes = new Response(); - const handler = usePlayground(() => nextRes); - const req = new Request("http://localhost/test.com", { - method: "POST", - headers: { - accept: "text/html", - }, - }); - await expect(handler(req)).resolves.toEqual(nextRes); - }); - - it("should return graphql request result when the request is to graphql", async () => { - let handler = createHandler(buildSchema(`type Query { hello: String }`)); - - handler = usePlayground(handler); - const req = new Request("http://localhost/test.com?query=query{hello}", { - method: "GET", - }); - - const res = await handler(req); - - expect(res.status).toBe(Status.OK); - expect(res.headers).toEqualIterable( - new Headers({ - "content-type": "application/graphql+json; charset=UTF-8", - }), - ); - }); - - it("should return graphql playground result when the request is to graphql playground", async () => { - let handler = createHandler(buildSchema(`type Query { hello: String }`)); - - handler = usePlayground(handler); - const req = new Request("http://localhost/test.com", { - method: "GET", - headers: { - accept: "text/html", - }, - }); - - const res = await handler(req); - - expect(res.status).toBe(Status.OK); - expect(res.headers).toEqualIterable( - new Headers({ "content-type": contentType("text/html") }), - ); - }); -}); diff --git a/utils.ts b/utils.ts deleted file mode 100644 index d322013..0000000 --- a/utils.ts +++ /dev/null @@ -1,36 +0,0 @@ -export 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]; - } -} - -export function mergeInit< - T extends { headers?: HeadersInit }, ->( - a: T, - b: T, -): [data: T] | [data: undefined, err: TypeError] { - const [headers, err] = mergeHeaders(a.headers, b.headers); - - if (err) { - return [, err]; - } - return [{ - ...a, - ...b, - headers, - }]; -} diff --git a/validates.ts b/validates.ts deleted file mode 100644 index 6048da8..0000000 --- a/validates.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { parseMediaType } from "./deps.ts"; - -export function validatePlaygroundRequest(req: Request): boolean { - if (req.method !== "GET") { - return false; - } - - const accept = req.headers.get("accept"); - if (!accept) return false; - - const accepts = accept.split(","); - const acceptHTML = accepts.some((accept) => { - const [mediaType] = parseMediaType(accept); - return mediaType === "text/html"; - }); - - return acceptHTML; -}