diff --git a/graphql-upload/README.md b/graphql-upload/README.md new file mode 100644 index 0000000..22b5f83 --- /dev/null +++ b/graphql-upload/README.md @@ -0,0 +1,56 @@ +# GraphQL Upload + +This example is based off of `combining-local-and-remote-schemas`. + +**This example demonstrates:** + +- Adding a locally-executable schema. +- Adding a remote schema, fetched via introspection. +- Adding GraphQL Upload + +## Setup + +```shell +cd graphql-upload + +yarn install +yarn start +``` + +The following services are available for interactive queries: + +- **Stitched gateway:** http://localhost:4000/graphql +- _Products subservice_: http://localhost:4001/graphql + +## Summary + +Visit the [stitched gateway](http://localhost:4000/graphql) and try running the following query: + +```graphql +query { + product(upc: "1") { + upc + name + } +} +``` + +The results of this query are live-proxied from the underlying subschemas by the stitched gateway: + +- `product` comes from the remote Products server. This service is added into the stitched schema using introspection, i.e.: `introspectSchema` from the `@graphql-tools/wrap` package. Introspection is a tidy way to incorporate remote schemas, but be careful: not all GraphQL servers enable introspection, and those that do will not include custom directives. + +- `errorCodes` comes from a locally-executable schema running on the gateway server itself. This schema is built using `makeExecutableSchema` from the `@graphql-tools/schema` package, and then stitched directly into the combined schema. Note that this still operates as a standalone schema instance that is proxied by the top-level gateway schema. + +## Upload a File + +Run the following command from the terminal to upload the file `file.txt`. To learn more, visit [graphql-multipart-request-spec](https://github.com/jaydenseric/graphql-multipart-request-spec) + +```bash +curl localhost:4000/graphql \ + -F operations='{ "query": "mutation($file: Upload!) { uploadFile(input: $file) { filename mimetype content } }", "variables": { "file": null } }' \ + -F map='{ "0": ["variables.file"] }' \ + -F 0=@graphql-upload/file.txt + +# output +# {"data":{"uploadFile":{"filename":"file.txt","mimetype":"text/plain","content":"hello upload\n"}}} +``` diff --git a/graphql-upload/__tests__/mutation.test.js b/graphql-upload/__tests__/mutation.test.js new file mode 100644 index 0000000..62010bd --- /dev/null +++ b/graphql-upload/__tests__/mutation.test.js @@ -0,0 +1,98 @@ +const { ApolloServer, gql } = require("apollo-server-express"); +const { buildSchema } = require("graphql"); +const { Upload } = require("graphql-upload/public"); +const { Readable } = require("stream"); +const { stitchSchemas } = require("@graphql-tools/stitch"); +const { GraphQLUpload: GatewayGraphQLUpload } = require("@graphql-tools/links"); + +const makeRemoteExecutor = require("../lib/make_remote_executor"); +const localSchema = require("../services/local/schema"); + +async function makeGatewaySchema() { + // Make remote executors: + // these are simple functions that query a remote GraphQL API for JSON. + const productsExec = makeRemoteExecutor("http://localhost:4001/graphql"); + + return stitchSchemas({ + subschemas: [ + { + // 1. Introspect a remote schema. Simple, but there are caveats: + // - Remote server must enable introspection. + // - Custom directives are not included in introspection. + schema: buildSchema(` + type Product { + name: String! + price: Float! + upc: ID! + } + + type Query { + product(upc: ID!): Product + } + `), + executor: productsExec, + }, + { + // 4. Incorporate a locally-executable subschema. + // No need for a remote executor! + // Note that that the gateway still proxies through + // to this same underlying executable schema instance. + schema: localSchema, + }, + ], + resolvers: { + Upload: GatewayGraphQLUpload, + }, + }); +} + +async function createApolloserver() { + const schema = await makeGatewaySchema(); + + const server = new ApolloServer({ + schema, + uploads: false, + }); + + return server; +} + +test("mutation", async () => { + const THE_MUTATION = gql` + mutation uploadFile($input: Upload!) { + uploadFile(input: $input) { + filename + mimetype + content + } + } + `; + + const upload = new Upload(); + const filename = "some_file.jpeg"; + + const buffer = Buffer.from('hello upload', 'utf-8'); + const stream = Readable.from(buffer); + upload.promise = new Promise(resolve => resolve({ + createReadStream: () => stream, + filename, + mimetype: 'text/plain' + })) + + const server = await createApolloserver(); + const result = await server.executeOperation({ + query: THE_MUTATION, + variables: { + input: upload, + }, + }); + + expect(result.errors).toBeUndefined(); + expect(result.data).toMatchObject({ + uploadFile: { + filename: "some_file.jpeg", + mimetype: "text/plain", + content: "hello upload", + }, + }); +}); diff --git a/graphql-upload/file.txt b/graphql-upload/file.txt new file mode 100644 index 0000000..485bd3b --- /dev/null +++ b/graphql-upload/file.txt @@ -0,0 +1 @@ +hello upload diff --git a/graphql-upload/index.js b/graphql-upload/index.js new file mode 100644 index 0000000..3749214 --- /dev/null +++ b/graphql-upload/index.js @@ -0,0 +1,68 @@ +const waitOn = require("wait-on"); +const express = require("express"); +const { introspectSchema } = require("@graphql-tools/wrap"); +const { stitchSchemas } = require("@graphql-tools/stitch"); + +const { GraphQLUpload: GatewayGraphQLUpload } = require("@graphql-tools/links"); +const { graphqlUploadExpress } = require("graphql-upload"); +const { ApolloServer } = require("apollo-server-express"); + +const makeRemoteExecutor = require("./lib/make_remote_executor"); +const localSchema = require("./services/local/schema"); + +async function makeGatewaySchema() { + // Make remote executors: + // these are simple functions that query a remote GraphQL API for JSON. + const productsExec = makeRemoteExecutor("http://localhost:4001/graphql"); + const adminContext = { authHeader: "Bearer my-app-to-app-token" }; + + return stitchSchemas({ + subschemas: [ + { + // 1. Introspect a remote schema. Simple, but there are caveats: + // - Remote server must enable introspection. + // - Custom directives are not included in introspection. + schema: await introspectSchema(productsExec, adminContext), + executor: productsExec, + }, + { + // 4. Incorporate a locally-executable subschema. + // No need for a remote executor! + // Note that that the gateway still proxies through + // to this same underlying executable schema instance. + schema: localSchema, + }, + ], + resolvers: { + Upload: GatewayGraphQLUpload, + }, + }); +} + +async function startApolloServer() { + const schema = await makeGatewaySchema(); + const server = new ApolloServer({ + schema, + uploads: false, + }); + await server.start(); + const app = express(); + + // Additional middleware can be mounted at this point to run before Apollo. + app.use( + graphqlUploadExpress({ + maxFileSize: 10000000, // 10 MB + maxFiles: 5, + }) + ); + + // Mount Apollo middleware here. + server.applyMiddleware({ app, path: "/", cors: false }); + + await new Promise((resolve) => app.listen({ port: 4000 }, resolve)); + console.log(`🚀 Server ready at http://localhost:4000${server.graphqlPath}`); +} + +waitOn({ resources: ["tcp:4001"] }, async () => { + startApolloServer(); +}); diff --git a/graphql-upload/lib/make_remote_executor.js b/graphql-upload/lib/make_remote_executor.js new file mode 100644 index 0000000..9e21704 --- /dev/null +++ b/graphql-upload/lib/make_remote_executor.js @@ -0,0 +1,21 @@ +const { fetch } = require('cross-fetch'); +const { print } = require('graphql'); + +// Builds a remote schema executor function, +// customize any way that you need (auth, headers, etc). +// Expects to receive an object with "document" and "variable" params, +// and asynchronously returns a JSON response from the remote. +module.exports = function makeRemoteExecutor(url) { + return async ({ document, variables, context }) => { + const query = typeof document === 'string' ? document : print(document); + const fetchResult = await fetch(url, { + method: 'POST', + headers: { + 'Authorization': context.authHeader, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ query, variables }), + }); + return fetchResult.json(); + }; +}; diff --git a/graphql-upload/lib/not_found_error.js b/graphql-upload/lib/not_found_error.js new file mode 100644 index 0000000..ffee03c --- /dev/null +++ b/graphql-upload/lib/not_found_error.js @@ -0,0 +1,6 @@ +module.exports = class NotFoundError extends Error { + constructor(message) { + super(message || 'Record not found'); + this.extensions = { code: 'NOT_FOUND' }; + } +}; diff --git a/graphql-upload/lib/read_file_sync.js b/graphql-upload/lib/read_file_sync.js new file mode 100644 index 0000000..5748f02 --- /dev/null +++ b/graphql-upload/lib/read_file_sync.js @@ -0,0 +1,6 @@ +const fs = require('fs'); +const path = require('path'); + +module.exports = function readFileSync(dir, filename) { + return fs.readFileSync(path.join(dir, filename), 'utf8'); +}; diff --git a/graphql-upload/package.json b/graphql-upload/package.json new file mode 100644 index 0000000..114139d --- /dev/null +++ b/graphql-upload/package.json @@ -0,0 +1,26 @@ +{ + "name": "combining-local-and-remote-schemas", + "version": "0.0.0", + "main": "index.js", + "license": "MIT", + "scripts": { + "start-products": "nodemon --watch services/products services/products/index.js", + "start-gateway": "nodemon index.js", + "start": "concurrently \"yarn:start-*\"" + }, + "dependencies": { + "@graphql-tools/links": "^7.1.0", + "@graphql-tools/schema": "^7.0.0", + "@graphql-tools/stitch": "^7.0.4", + "@graphql-tools/wrap": "^7.0.1", + "apollo-server-express": "^2.25.0", + "concurrently": "^5.3.0", + "cross-fetch": "^3.0.6", + "express": "^4.17.1", + "express-graphql": "^0.12.0", + "graphql": "^15.4.0", + "graphql-upload": "^12.0.0", + "nodemon": "^2.0.6", + "wait-on": "^5.2.1" + } +} diff --git a/graphql-upload/services/local/schema.js b/graphql-upload/services/local/schema.js new file mode 100644 index 0000000..560d687 --- /dev/null +++ b/graphql-upload/services/local/schema.js @@ -0,0 +1,51 @@ +const { makeExecutableSchema } = require("@graphql-tools/schema"); + +// does not work +// const { GraphQLUpload } = require("graphql-upload"); + +// does work +const { GraphQLUpload } =require("@graphql-tools/links"); + +module.exports = makeExecutableSchema({ + typeDefs: ` + scalar Upload + type SomeFile { + filename: String + mimetype: String + content: String + } + type Mutation { + uploadFile(input: Upload!): SomeFile! + } + type Query { + errorCodes: [String!]! + } + `, + resolvers: { + Upload: GraphQLUpload, + Mutation: { + uploadFile: async (_, { input }) => { + const { createReadStream, filename, mimetype } = await input; + const chunks = []; + const stream = createReadStream(); + for await (const chunk of stream) { + chunks.push(chunk); + } + const buf = Buffer.concat(chunks); + + return { + filename, + mimetype, + content: buf.toString(), + }; + }, + }, + Query: { + errorCodes: () => [ + "NOT_FOUND", + "GRAPHQL_PARSE_FAILED", + "GRAPHQL_VALIDATION_FAILED", + ], + }, + }, +}); diff --git a/graphql-upload/services/products/index.js b/graphql-upload/services/products/index.js new file mode 100644 index 0000000..56f365e --- /dev/null +++ b/graphql-upload/services/products/index.js @@ -0,0 +1,7 @@ +const express = require('express'); +const { graphqlHTTP } = require('express-graphql'); +const schema = require('./schema'); + +const app = express(); +app.use('/graphql', graphqlHTTP({ schema, graphiql: true })); +app.listen(4001, () => console.log('products running at http://localhost:4001/graphql')); diff --git a/graphql-upload/services/products/schema.graphql b/graphql-upload/services/products/schema.graphql new file mode 100644 index 0000000..ca67e37 --- /dev/null +++ b/graphql-upload/services/products/schema.graphql @@ -0,0 +1,9 @@ +type Product { + name: String! + price: Float! + upc: ID! +} + +type Query { + product(upc: ID!): Product +} diff --git a/graphql-upload/services/products/schema.js b/graphql-upload/services/products/schema.js new file mode 100644 index 0000000..90b5b7e --- /dev/null +++ b/graphql-upload/services/products/schema.js @@ -0,0 +1,19 @@ +const { makeExecutableSchema } = require('@graphql-tools/schema'); +const NotFoundError = require('../../lib/not_found_error'); +const readFileSync = require('../../lib/read_file_sync'); +const typeDefs = readFileSync(__dirname, 'schema.graphql'); + +// data fixtures +const products = [ + { upc: '1', name: 'Cookbook', price: 15.99 }, + { upc: '2', name: 'Toothbrush', price: 3.99 }, +]; + +module.exports = makeExecutableSchema({ + typeDefs, + resolvers: { + Query: { + product: (root, { upc }) => products.find(p => p.upc === upc) || new NotFoundError() + } + } +});