Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

GraphQL Upload #30

Merged
merged 3 commits into from Sep 13, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
56 changes: 56 additions & 0 deletions 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"}}}
```
98 changes: 98 additions & 0 deletions 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",
},
});
});
1 change: 1 addition & 0 deletions graphql-upload/file.txt
@@ -0,0 +1 @@
hello upload
68 changes: 68 additions & 0 deletions 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();
});
21 changes: 21 additions & 0 deletions 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();
};
};
6 changes: 6 additions & 0 deletions 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' };
}
};
6 changes: 6 additions & 0 deletions 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');
};
26 changes: 26 additions & 0 deletions 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"
}
}
51 changes: 51 additions & 0 deletions 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");
Comment on lines +3 to +7
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@yaacovCR this is the issue I was having in this thread ardatan/graphql-tools#671 (comment). The original GraphQLUpload throws an error.


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",
],
},
},
});
7 changes: 7 additions & 0 deletions 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'));
9 changes: 9 additions & 0 deletions graphql-upload/services/products/schema.graphql
@@ -0,0 +1,9 @@
type Product {
name: String!
price: Float!
upc: ID!
}

type Query {
product(upc: ID!): Product
}
19 changes: 19 additions & 0 deletions 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()
}
}
});