diff --git a/README.md b/README.md index a81bdbe..02a5a4f 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,8 @@ In Windows: - GraphQL - [graphql-tools](https://github.com/apollographql/graphql-tools) + - [graphql-relay](https://github.com/graphql/graphql-relay-js) + - [graphql-relay-tools](https://github.com/excitement-engineer/graphql-relay-tools) - [graphql-import](https://github.com/prisma/graphql-import) - [graphql-tracing](https://github.com/apollographql/apollo-tracing) - [apollo-server](https://github.com/apollographql/apollo-server) @@ -116,6 +118,9 @@ In Windows: - [graphql-playground](https://github.com/graphcool/graphql-playground) - [graphqlgen](https://github.com/prisma/graphqlgen) +- DataLoader + - [dataloader](https://github.com/facebook/dataloader) + - Jest - [Documentation](https://facebook.github.io/jest/docs/en/getting-started.html) diff --git a/package.json b/package.json index a9e7027..0dd28b3 100644 --- a/package.json +++ b/package.json @@ -29,12 +29,15 @@ "apollo-server-koa": "1.3.6", "bluebird": "3.5.3", "chalk": "2.4.2", + "dataloader": "1.4.0", "envalid": "4.1.4", "flashheart": "2.9.0", "graphql": "14.0.2", "graphql-cost-analysis": "1.0.2", "graphql-import": "0.7.1", "graphql-playground-middleware-koa": "1.6.8", + "graphql-relay": "0.5.5", + "graphql-relay-tools": "0.1.1", "graphql-tools": "4.0.3", "graphql-voyager": "1.0.0-rc.26", "koa": "2.6.2", @@ -46,6 +49,8 @@ "lodash": "4.17.11", "moment": "2.23.0", "source-map-support": "0.5.9", + "stack-storage": "2.0.0", + "uuid": "3.3.2", "winston": "3.1.0" }, "devDependencies": { diff --git a/src/app.ts b/src/app.ts index 72413c3..36bc0f4 100644 --- a/src/app.ts +++ b/src/app.ts @@ -9,7 +9,10 @@ import koaMiddleware from 'graphql-voyager/middleware/koa'; const koaPlayground = require('graphql-playground-middleware-koa').default; import costAnalysis from 'graphql-cost-analysis'; import { promisifyAll } from 'bluebird'; +import { get } from 'lodash'; import { Client } from 'flashheart'; +import Storage from 'stack-storage'; +import uuid from 'uuid/v4'; import schema from './graphql/schema'; import logger from './logger'; import { entryPoint } from './entrypoint'; @@ -24,6 +27,12 @@ const router = new koaRouter(); // Entry Point router.get('/', entryPoint); +// Request ID creation +app.use(async (ctx, next) => { + process.storage = new Storage([['rid', get(ctx.req.headers, 'x-request-id', uuid())]]); + await next(); +}); + // CORS? if (process.env.CORS) { app.use(koaConvert(koaCors())); @@ -81,6 +90,14 @@ if (process.env.PLAYGROUND) { // Koa Heartbeat app.use(koaHeartbeat({ path: `/${paths.LIVENESS_PATH}`, body: 'ok' })); +// Time logging +app.use(async (ctx, next) => { + const start = Date.now(); + await next(); + const end = Date.now() - start; + logger.info(`${ctx.method} ${ctx.url} - ${end}ms`); +}); + app.use(router.routes()); app.use(router.allowedMethods()); diff --git a/src/connectors/swapi.ts b/src/connectors/swapi.ts new file mode 100644 index 0000000..aab2b6f --- /dev/null +++ b/src/connectors/swapi.ts @@ -0,0 +1,38 @@ +import DataLoader from 'dataloader'; +import { createClient } from 'flashheart'; +import logger from '../logger'; + +const http = createClient({ logger, timeout: 5000 }); + +async function getFromUrl(url) { + const response = await http.getAsync(url); + return response; +} + +export const dataLoader = new DataLoader(urls => + Promise.all(urls.map(getFromUrl)), +); + +export const getObjectFromUrl = async (url: string): Promise => dataLoader.load(url); + +export const getObjectsFromType = async (type: string): Promise => { + return await getObjectFromUrl(`${process.env.SWAPI_SERVICE_URL}/${type}/`); +}; + +export const getObjectFromTypeAndId = async (type: string, id: string): Promise => { + const data = await getObjectFromUrl(`${process.env.SWAPI_SERVICE_URL}/${type}/${id}/`); + return objectWithId(data); +}; + +export const getObjectsFromUrls = async (urls: string[]): Promise => { + const array = await Promise.all(urls.map(getObjectFromUrl)); + return array.map(objectWithId); +}; + +/** + * Objects returned from SWAPI don't have an ID field, so add one. + */ +export const objectWithId = (obj: {id: number, url: string}): Object => { + obj.id = parseInt(obj.url.split('/')[5], 10); + return obj; +}; diff --git a/src/declaration.ts b/src/declaration.ts new file mode 100644 index 0000000..83ede2b --- /dev/null +++ b/src/declaration.ts @@ -0,0 +1,16 @@ +/** + * Type definitions for third party libraries. + */ +interface Storage extends Map { + enter: Function; + exit: Function; +} + +/** + * Merging default type definitions with below. + */ +declare module NodeJS { + interface Process { + storage: Storage; + } +} diff --git a/src/env.ts b/src/env.ts index ca26d4b..8de9319 100644 --- a/src/env.ts +++ b/src/env.ts @@ -7,6 +7,7 @@ const env = envalid.cleanEnv(process.env, { SELF_URL: str({ devDefault: 'http://localhost:3001' }), NODE_ENV: str({ devDefault: 'development' }), JOKE_SERVICE_URL: url({ default: 'https://api.icndb.com' }), + SWAPI_SERVICE_URL: url({ default: 'https://swapi.co/api' }), GRAPHQL_TRACING: bool({ default: true }), GRAPHIQL: bool({ default: true }), VOYAGER: bool({ default: true }), diff --git a/src/graphql/__tests__/__snapshots__/swapi.test.ts.snap b/src/graphql/__tests__/__snapshots__/swapi.test.ts.snap new file mode 100644 index 0000000..7ab1920 --- /dev/null +++ b/src/graphql/__tests__/__snapshots__/swapi.test.ts.snap @@ -0,0 +1,56 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`query.swapi should match snapshot with pagination 1`] = ` +Object { + "data": Object { + "allFilms": Object { + "edges": Array [ + Object { + "cursor": "YXJyYXljb25uZWN0aW9uOjA=", + "node": Object { + "episodeID": 4, + "title": "A New Hope", + }, + }, + Object { + "cursor": "YXJyYXljb25uZWN0aW9uOjE=", + "node": Object { + "episodeID": 2, + "title": "Attack of the Clones", + }, + }, + ], + "films": Array [ + Object { + "episodeID": 4, + "title": "A New Hope", + }, + Object { + "episodeID": 2, + "title": "Attack of the Clones", + }, + ], + "pageInfo": Object { + "endCursor": "YXJyYXljb25uZWN0aW9uOjE=", + "hasNextPage": false, + "hasPreviousPage": false, + "startCursor": "YXJyYXljb25uZWN0aW9uOjA=", + }, + "totalCount": 2, + }, + }, + "errors": undefined, +} +`; + +exports[`query.swapi should match snapshot without pagination 1`] = ` +Object { + "data": Object { + "film": Object { + "episodeID": 5, + "title": "The Empire Strikes Back", + }, + }, + "errors": undefined, +} +`; diff --git a/src/graphql/__tests__/swapi.test.ts b/src/graphql/__tests__/swapi.test.ts new file mode 100644 index 0000000..611442f --- /dev/null +++ b/src/graphql/__tests__/swapi.test.ts @@ -0,0 +1,93 @@ +import { graphql } from 'graphql'; +import schema from '../schema'; +import nock from 'nock'; + +const rootValue = {}; +const context = {}; + +beforeAll(() => { + + // mock service endpoint + + const filmsResponse = { + count: 2, + next: null, + previous: null, + results: [ + { + title: 'A New Hope', + episode_id: 4, + url: 'https://swapi.co/api/films/', + }, + { + title: 'Attack of the Clones', + episode_id: 2, + url: 'https://swapi.co/api/films/', + }, + ], + }; + + nock(process.env.SWAPI_SERVICE_URL!) + .get('/films/') + .reply(200, filmsResponse); + + const filmResponse = { + title: 'The Empire Strikes Back', + episode_id: 5, + url: 'https://swapi.co/api/films/2/', + }; + + nock(process.env.SWAPI_SERVICE_URL!) + .get('/films/2/') + .reply(200, filmResponse); +}); + +describe('query.swapi', () => { + it('should match snapshot with pagination', async () => { + const query = ` + query Q { + allFilms { + edges { + node { + title + episodeID + } + cursor + } + pageInfo { + startCursor + endCursor + hasNextPage + hasPreviousPage + } + totalCount + films { + title + episodeID + } + } + } + `; + + const result = await graphql(schema, query, rootValue, context); + const { data, errors } = result; + + expect({ data, errors }).toMatchSnapshot(); + }); + + it('should match snapshot without pagination', async () => { + const query = ` + query Q { + film(filmID: 2) { + title + episodeID + } + } + `; + + const result = await graphql(schema, query, rootValue, context); + const { data, errors } = result; + + expect({ data, errors }).toMatchSnapshot(); + }); +}); diff --git a/src/graphql/connection.ts b/src/graphql/connection.ts new file mode 100644 index 0000000..b094aeb --- /dev/null +++ b/src/graphql/connection.ts @@ -0,0 +1,19 @@ +import { connectionDefinitions } from 'graphql-relay-tools'; + +/** + * Constructs a GraphQL connection field config; it is assumed + * that the object has a property named `prop`, and that property + * contains a list of types. + */ +export function connectTypes(name: string, prop: string, type: string) { + const { connectionType } = connectionDefinitions({ + name, + nodeType: type, + connectionFields: ` + totalCount: Int + ${prop}: [${type}] + `, + }); + + return connectionType; +} diff --git a/src/graphql/models/swapi.ts b/src/graphql/models/swapi.ts new file mode 100644 index 0000000..84e0f7b --- /dev/null +++ b/src/graphql/models/swapi.ts @@ -0,0 +1,59 @@ +import * as swapi from '../../connectors/swapi'; + +type ObjectsByType = { + objects: Object[], + totalCount: number, +}; + +/** + * Given a type, fetch all of the pages, and join the objects together + */ +const byType = async (type: string): Promise => { + const typeData = await swapi.getObjectsFromType(type); + let objects: Object[] = []; + let nextUrl = typeData.next; + + objects = objects.concat(typeData.results.map(swapi.objectWithId)); + while (nextUrl) { + const pageData = await swapi.getObjectFromUrl(nextUrl); + objects = objects.concat(pageData.results.map(swapi.objectWithId)); + nextUrl = pageData.next; + } + + objects = sortObjectsById(objects); + return { objects, totalCount: objects.length }; +}; + +const byTypeAndId = async (type: string, id: string): Promise => swapi.getObjectFromTypeAndId(type, id); + +const byUrl = async (url: string): Promise => { + const data = await swapi.getObjectFromUrl(url); + return swapi.objectWithId(data); +}; + +const byUrls = async (urls: string[]): Promise => { + const array = await swapi.getObjectsFromUrls(urls); + return sortObjectsById(array); +}; + +const sortObjectsById = (array: any[]): Object[] => { + return array.sort((a, b) => a.id - b.id); +}; + +const convertToNumber = (value: string): number | null => { + if (['unknown', 'n/a'].indexOf(value) !== -1) { + return null; + } + + // remove digit grouping + const numberString = value.replace(/,/, ''); + return Number(numberString); +}; + +export { + byTypeAndId, + byType, + byUrl, + byUrls, + convertToNumber, +}; diff --git a/src/graphql/resolvers/Node.ts b/src/graphql/resolvers/Node.ts new file mode 100644 index 0000000..217e105 --- /dev/null +++ b/src/graphql/resolvers/Node.ts @@ -0,0 +1,3 @@ +export const node = { + __resolveType: () => null, +}; diff --git a/src/graphql/resolvers/index.ts b/src/graphql/resolvers/index.ts index 7da4386..b875590 100644 --- a/src/graphql/resolvers/index.ts +++ b/src/graphql/resolvers/index.ts @@ -4,6 +4,9 @@ import { Resolvers } from '../_generated/types'; import { query as Query } from './Query'; import { jokes as Jokes } from './Jokes'; import { joke as Joke } from './Joke'; +import { node as Node } from './Node'; + +import swapiResolvers from './swapi'; const resolvers: Resolvers = { Query, @@ -11,4 +14,4 @@ const resolvers: Resolvers = { Joke, }; -export default merge(resolvers); +export default merge(resolvers, swapiResolvers, { Node }); diff --git a/src/graphql/resolvers/swapi/Connection.ts b/src/graphql/resolvers/swapi/Connection.ts new file mode 100644 index 0000000..225cfe1 --- /dev/null +++ b/src/graphql/resolvers/swapi/Connection.ts @@ -0,0 +1,31 @@ +import { connectionFromArray } from 'graphql-relay-tools'; +import { byUrls, byType } from '../../models/swapi'; + +function connection(prop: string) { + return async (obj, args) => { + const array = await byUrls(obj[prop] || []); + const connObj = connectionFromArray(array, args); + return { + ...connObj, + totalCount: array.length, + [prop]: _ => connObj.edges.map(edge => edge.node), + }; + }; +} + +function rootConnection(swapiType) { + return async (_, args) => { + const { objects, totalCount } = await byType(swapiType); + const connObj = connectionFromArray(objects, args); + return { + ...connObj, + totalCount, + [swapiType]: _ => connObj.edges.map(edge => edge.node), + }; + }; +} + +export { + rootConnection, + connection, +}; diff --git a/src/graphql/resolvers/swapi/Film.ts b/src/graphql/resolvers/swapi/Film.ts new file mode 100644 index 0000000..f10277b --- /dev/null +++ b/src/graphql/resolvers/swapi/Film.ts @@ -0,0 +1,15 @@ +import { globalIdResolver } from 'graphql-relay-tools'; +import { connection } from './Connection'; + +export const film = { + episodeID: film => film.episode_id, + openingCrawl: film => film.opening_crawl, + producers: film => film.producer.split(',').map(s => s.trim()), + releaseDate: film => film.release_date, + speciesConnection: connection('species'), + starshipConnection: connection('starships'), + vehicleConnection: connection('vehicles'), + characterConnection: connection('characters'), + planetConnection: connection('planets'), + id: globalIdResolver(), +}; diff --git a/src/graphql/resolvers/swapi/Node.ts b/src/graphql/resolvers/swapi/Node.ts new file mode 100644 index 0000000..8aa613d --- /dev/null +++ b/src/graphql/resolvers/swapi/Node.ts @@ -0,0 +1,12 @@ +import { nodeDefinitions, fromGlobalId } from 'graphql-relay-tools'; +import { byTypeAndId } from '../../models/swapi'; + +const { nodeResolver, nodesResolver } = nodeDefinitions((globalId) => { + const { type, id } = fromGlobalId(globalId); + return byTypeAndId(type, id); +}); + +export const node = { + node: nodeResolver, + nodes: nodesResolver, +}; diff --git a/src/graphql/resolvers/swapi/Person.ts b/src/graphql/resolvers/swapi/Person.ts new file mode 100644 index 0000000..3b79b5d --- /dev/null +++ b/src/graphql/resolvers/swapi/Person.ts @@ -0,0 +1,24 @@ +import { globalIdResolver } from 'graphql-relay-tools'; +import { convertToNumber, byUrl } from '../../models/swapi'; +import { connection } from './Connection'; + +export const person = { + birthYear: person => person.birth_year, + eyeColor: person => person.eye_color, + hairColor: person => person.hair_color, + height: person => convertToNumber(person.height), + mass: person => convertToNumber(person.mass), + skinColor: person => person.skin_color, + homeworld: person => person.homeworld ? byUrl(person.homeworld) : null, + species: (person) => { + if (!person.species || person.species.length === 0) { + return null; + } + + return byUrl(person.species[0]); + }, + filmConnection: connection('films'), + starshipConnection: connection('starships'), + vehicleConnection: connection('vehicles'), + id: globalIdResolver(), +}; diff --git a/src/graphql/resolvers/swapi/Planet.ts b/src/graphql/resolvers/swapi/Planet.ts new file mode 100644 index 0000000..0d03d68 --- /dev/null +++ b/src/graphql/resolvers/swapi/Planet.ts @@ -0,0 +1,16 @@ +import { globalIdResolver } from 'graphql-relay-tools'; +import { convertToNumber } from '../../models/swapi'; +import { connection } from './Connection'; + +export const planet = { + diameter: planet => convertToNumber(planet.diameter), + rotationPeriod: planet => convertToNumber(planet.rotation_period), + orbitalPeriod: planet => convertToNumber(planet.orbital_period), + population: planet => convertToNumber(planet.population), + climates: planet => planet.climate.split(',').map(s => s.trim()), + terrains: planet => planet.terrain.split(',').map(s => s.trim()), + surfaceWater: planet => convertToNumber(planet.surface_water), + residentConnection: connection('residents'), + filmConnection: connection('films'), + id: globalIdResolver(), +}; diff --git a/src/graphql/resolvers/swapi/Query.ts b/src/graphql/resolvers/swapi/Query.ts new file mode 100644 index 0000000..bebdf19 --- /dev/null +++ b/src/graphql/resolvers/swapi/Query.ts @@ -0,0 +1,40 @@ +import { fromGlobalId } from 'graphql-relay-tools'; +import { isEmpty } from 'lodash'; +import { byTypeAndId } from '../../models/swapi'; +import { rootConnection } from './Connection'; + +function rootField(idName, swapiType) { + return (_, args) => { + if (!isEmpty(args[idName])) { + return byTypeAndId(swapiType, args[idName]); + } + + if (!isEmpty(args.id)) { + const globalId = fromGlobalId(args.id); + + if (isEmpty(globalId.id)) { + throw new Error(`No valid ID extracted from ${args.id}`); + } + + return byTypeAndId(swapiType, globalId.id); + } + + throw new Error(`must provide id or ${idName}`); + }; +} + +export const query = { + allFilms: rootConnection('films'), + allPeople: rootConnection('people'), + allPlanets: rootConnection('planets'), + allSpecies: rootConnection('species'), + allStarships: rootConnection('starships'), + allVehicles: rootConnection('vehicles'), + + film: rootField('filmID', 'films'), + person: rootField('personID', 'people'), + planet: rootField('planetID', 'planets'), + species: rootField('speciesID', 'species'), + starship: rootField('starshipID', 'starships'), + vehicle: rootField('vehicleID', 'vehicles'), +}; diff --git a/src/graphql/resolvers/swapi/Species.ts b/src/graphql/resolvers/swapi/Species.ts new file mode 100644 index 0000000..84d0e00 --- /dev/null +++ b/src/graphql/resolvers/swapi/Species.ts @@ -0,0 +1,21 @@ +import { globalIdResolver } from 'graphql-relay-tools'; +import { convertToNumber, byUrl } from '../../models/swapi'; +import { connection } from './Connection'; + +export const species = { + averageHeight: species => convertToNumber(species.average_height), + averageLifespan: species => convertToNumber(species.average_lifespan), + eyeColors: species => species.eye_colors.split(',').map(s => s.trim()), + hairColors: (species) => { + if (species.hair_colors === 'none') { + return []; + } + + return species.hair_colors.split(',').map(s => s.trim()); + }, + skinColors: species => species.skin_colors.split(',').map(s => s.trim()), + homeworld: species => species.homeworld ? byUrl(species.homeworld) : null, + personConnection: connection('people'), + filmConnection: connection('films'), + id: globalIdResolver(), +}; diff --git a/src/graphql/resolvers/swapi/Starship.ts b/src/graphql/resolvers/swapi/Starship.ts new file mode 100644 index 0000000..1958e7e --- /dev/null +++ b/src/graphql/resolvers/swapi/Starship.ts @@ -0,0 +1,17 @@ +import { globalIdResolver } from 'graphql-relay-tools'; +import { convertToNumber } from '../../models/swapi'; +import { connection } from './Connection'; + +export const starship = { + starshipClass: ship => ship.starship_class, + manufacturers: ship => ship.manufacturer.split(',').map(s => s.trim()), + costInCredits: ship => convertToNumber(ship.cost_in_credits), + length: ship => convertToNumber(ship.length), + maxAtmospheringSpeed: ship => convertToNumber(ship.max_atmosphering_speed), + hyperdriveRating: ship => convertToNumber(ship.hyperdrive_rating), + MGLT: ship => convertToNumber(ship.MGLT), + cargoCapacity: ship => convertToNumber(ship.cargo_capacity), + pilotConnection: connection('pilots'), + filmConnection: connection('films'), + id: globalIdResolver(), +}; diff --git a/src/graphql/resolvers/swapi/Vehicle.ts b/src/graphql/resolvers/swapi/Vehicle.ts new file mode 100644 index 0000000..575e472 --- /dev/null +++ b/src/graphql/resolvers/swapi/Vehicle.ts @@ -0,0 +1,15 @@ +import { globalIdResolver } from 'graphql-relay-tools'; +import { convertToNumber } from '../../models/swapi'; +import { connection } from './Connection'; + +export const vehicle = { + vehicleClass: vehicle => vehicle.vehicle_class, + manufacturers: vehicle => vehicle.manufacturer.split(',').map(s => s.trim()), + costInCredits: vehicle => convertToNumber(vehicle.cost_in_credits), + length: vehicle => convertToNumber(vehicle.length), + maxAtmospheringSpeed: vehicle => convertToNumber(vehicle.max_atmosphering_speed), + cargoCapacity: vehicle => convertToNumber(vehicle.cargo_capacity), + pilotConnection: connection('pilots'), + filmConnection: connection('films'), + id: globalIdResolver(), +}; diff --git a/src/graphql/resolvers/swapi/index.ts b/src/graphql/resolvers/swapi/index.ts new file mode 100644 index 0000000..1706b7a --- /dev/null +++ b/src/graphql/resolvers/swapi/index.ts @@ -0,0 +1,22 @@ +import { merge } from 'lodash'; + +import { query } from './Query'; +import { node } from './Node'; +import { film as Film } from './Film'; +import { person as Person } from './Person'; +import { planet as Planet } from './Planet'; +import { species as Species } from './Species'; +import { starship as Starship } from './Starship'; +import { vehicle as Vehicle } from './Vehicle'; + +const resolvers = { + Film, + Person, + Planet, + Species, + Starship, + Vehicle, + Query: merge(query, node), +}; + +export default resolvers; diff --git a/src/graphql/schema.ts b/src/graphql/schema.ts index 5cfb318..988ef4a 100644 --- a/src/graphql/schema.ts +++ b/src/graphql/schema.ts @@ -1,9 +1,18 @@ import { makeExecutableSchema } from 'graphql-tools'; +import { nodeInterface, pageInfoType } from 'graphql-relay-tools'; import { importSchema } from 'graphql-import'; import resolvers from './resolvers'; -const typeDefs: string = importSchema('src/graphql/schema/schema.graphql'); +import { swapiDef } from './schema/swapi'; + +const schemas = { + nodeInterface, + pageInfoType, + ...swapiDef, +}; + +const typeDefs: string = importSchema('src/graphql/schema/schema.graphql', schemas); const schema = makeExecutableSchema({ typeDefs, diff --git a/src/graphql/schema/schema.graphql b/src/graphql/schema/schema.graphql index ee1bbda..7863ac7 100644 --- a/src/graphql/schema/schema.graphql +++ b/src/graphql/schema/schema.graphql @@ -1 +1,5 @@ +# import * from "nodeInterface" +# import * from "pageInfoType" + # import Query.* from "jokes.graphql" +# import Query.* from "swapi" diff --git a/src/graphql/schema/swapi/film.ts b/src/graphql/schema/swapi/film.ts new file mode 100644 index 0000000..16f909e --- /dev/null +++ b/src/graphql/schema/swapi/film.ts @@ -0,0 +1,46 @@ +import { connectionArgs } from 'graphql-relay-tools'; +import { connectTypes } from '../../connection'; + +const typeDef: string = ` +type Film implements Node { + """The title of this film.""" + title: String + + """The episode number of this film.""" + episodeID: Int + + """The opening paragraphs at the beginning of this film.""" + openingCrawl: String + + """The name of the director of this film.""" + director: String + + """The name(s) of the producer(s) of this film.""" + producers: [String] + + """The ISO 8601 date format of film release at original creator country.""" + releaseDate: String + speciesConnection${connectionArgs()}: FilmSpeciesConnection + starshipConnection${connectionArgs()}: FilmStarshipsConnection + vehicleConnection${connectionArgs()}: FilmVehiclesConnection + characterConnection${connectionArgs()}: FilmCharactersConnection + planetConnection${connectionArgs()}: FilmPlanetsConnection + + """The ISO 8601 date format of the time that this resource was created.""" + created: String + + """The ISO 8601 date format of the time that this resource was edited.""" + edited: String + + """The ID of an object""" + id: ID! +} + +${connectTypes('FilmSpecies', 'species', 'Species')}, +${connectTypes('FilmStarships', 'starships', 'Starship')}, +${connectTypes('FilmVehicles', 'vehicles', 'Vehicle')}, +${connectTypes('FilmCharacters', 'characters', 'Person')}, +${connectTypes('FilmPlanets', 'planets', 'Planet')} +`; + +export default typeDef; diff --git a/src/graphql/schema/swapi/index.ts b/src/graphql/schema/swapi/index.ts new file mode 100644 index 0000000..421135d --- /dev/null +++ b/src/graphql/schema/swapi/index.ts @@ -0,0 +1,58 @@ +import { nodeField, nodesField, connectionArgs } from 'graphql-relay-tools'; +import { connectTypes } from '../../connection'; + +import film from './film'; +import person from './person'; +import planet from './planet'; +import species from './species'; +import starship from './starship'; +import vehicle from './vehicle'; + +const swapi: string = ` +# import * from "film" +# import * from "person" +# import * from "planet" +# import * from "species" +# import * from "starship" +# import * from "vehicle" + +type Query { + allFilms${connectionArgs()}: FilmsConnection + film(id: ID, filmID: ID): Film + + allPeople${connectionArgs()}: PeopleConnection + person(id: ID, personID: ID): Person + + allPlanets${connectionArgs()}: PlanetsConnection + planet(id: ID, planetID: ID): Planet + + allSpecies${connectionArgs()}: SpeciesConnection + species(id: ID, speciesID: ID): Species + + allStarships${connectionArgs()}: StarshipsConnection + starship(id: ID, starshipID: ID): Starship + + allVehicles${connectionArgs()}: VehiclesConnection + vehicle(id: ID, vehicleID: ID): Vehicle + + ${nodeField} + ${nodesField} +} + +${connectTypes('Films', 'films', 'Film')} +${connectTypes('People', 'people', 'Person')} +${connectTypes('Planets', 'planets', 'Planet')} +${connectTypes('Species', 'species', 'Species')} +${connectTypes('Starships', 'starships', 'Starship')} +${connectTypes('Vehicles', 'vehicles', 'Vehicle')} +`; + +export const swapiDef = { + swapi, + film, + person, + planet, + species, + starship, + vehicle, +}; diff --git a/src/graphql/schema/swapi/person.ts b/src/graphql/schema/swapi/person.ts new file mode 100644 index 0000000..c7a4436 --- /dev/null +++ b/src/graphql/schema/swapi/person.ts @@ -0,0 +1,67 @@ +import { connectionArgs } from 'graphql-relay-tools'; +import { connectTypes } from '../../connection'; + +const typeDef: string = ` +type Person implements Node { + """The name of this person.""" + name: String + + """ + The birth year of the person, using the in-universe standard of BBY or ABY - + Before the Battle of Yavin or After the Battle of Yavin. The Battle of Yavin is + a battle that occurs at the end of Star Wars episode IV: A New Hope. + """ + birthYear: String + + """ + The eye color of this person. Will be "unknown" if not known or "n/a" if the + person does not have an eye. + """ + eyeColor: String + + """ + The gender of this person. Either "Male", "Female" or "unknown", + "n/a" if the person does not have a gender. + """ + gender: String + + """ + The hair color of this person. Will be "unknown" if not known or "n/a" if the + person does not have hair. + """ + hairColor: String + + """The height of the person in centimeters.""" + height: Int + + """The mass of the person in kilograms.""" + mass: Float + + """The skin color of this person.""" + skinColor: String + + """A planet that this person was born on or inhabits.""" + homeworld: Planet + filmConnection${connectionArgs()}: PersonFilmsConnection + + """The species that this person belongs to, or null if unknown.""" + species: Species + starshipConnection${connectionArgs()}: PersonStarshipsConnection + vehicleConnection${connectionArgs()}: PersonVehiclesConnection + + """The ISO 8601 date format of the time that this resource was created.""" + created: String + + """The ISO 8601 date format of the time that this resource was edited.""" + edited: String + + """The ID of an object""" + id: ID! +} + +${connectTypes('PersonFilms', 'films', 'Film')} +${connectTypes('PersonStarships', 'starships', 'Starship')} +${connectTypes('PersonVehicles', 'vehicles', 'Vehicle')} +`; + +export default typeDef; diff --git a/src/graphql/schema/swapi/planet.ts b/src/graphql/schema/swapi/planet.ts new file mode 100644 index 0000000..c5dd291 --- /dev/null +++ b/src/graphql/schema/swapi/planet.ts @@ -0,0 +1,61 @@ +import { connectionArgs } from 'graphql-relay-tools'; +import { connectTypes } from '../../connection'; + +const typeDef: string = ` +type Planet implements Node { + """The name of this planet.""" + name: String + + """The diameter of this planet in kilometers.""" + diameter: Int + + """ + The number of standard hours it takes for this planet to complete a single + rotation on its axis. + """ + rotationPeriod: Int + + """ + The number of standard days it takes for this planet to complete a single orbit + of its local star. + """ + orbitalPeriod: Int + + """ + A number denoting the gravity of this planet, where "1" is normal or 1 standard + G. "2" is twice or 2 standard Gs. "0.5" is half or 0.5 standard Gs. + """ + gravity: String + + """The average population of sentient beings inhabiting this planet.""" + population: Float + + """The climates of this planet.""" + climates: [String] + + """The terrains of this planet.""" + terrains: [String] + + """ + The percentage of the planet surface that is naturally occuring water or bodies + of water. + """ + surfaceWater: Float + residentConnection${connectionArgs()}: PlanetResidentsConnection + filmConnection${connectionArgs()}: PlanetFilmsConnection + + """The ISO 8601 date format of the time that this resource was created.""" + created: String + + """The ISO 8601 date format of the time that this resource was edited.""" + edited: String + + """The ID of an object""" + id: ID! +} + +${connectTypes('PlanetResidents', 'residents', 'Person')} +${connectTypes('PlanetFilms', 'films', 'Film')} +`; + +export default typeDef; diff --git a/src/graphql/schema/swapi/species.ts b/src/graphql/schema/swapi/species.ts new file mode 100644 index 0000000..38ecc25 --- /dev/null +++ b/src/graphql/schema/swapi/species.ts @@ -0,0 +1,61 @@ +import { connectionArgs } from 'graphql-relay-tools'; +import { connectTypes } from '../../connection'; + +const typeDef: string = ` +type Species implements Node { + """The name of this species.""" + name: String + + """The classification of this species, such as "mammal" or "reptile".""" + classification: String + + """The designation of this species, such as "sentient".""" + designation: String + + """The average height of this species in centimeters.""" + averageHeight: Float + + """The average lifespan of this species in years, null if unknown.""" + averageLifespan: Int + + """ + Common eye colors for this species, null if this species does not typically + have eyes. + """ + eyeColors: [String] + + """ + Common hair colors for this species, null if this species does not typically + have hair. + """ + hairColors: [String] + + """ + Common skin colors for this species, null if this species does not typically + have skin. + """ + skinColors: [String] + + """The language commonly spoken by this species.""" + language: String + + """A planet that this species originates from.""" + homeworld: Planet + personConnection${connectionArgs()}: SpeciesPeopleConnection + filmConnection${connectionArgs()}: SpeciesFilmsConnection + + """The ISO 8601 date format of the time that this resource was created.""" + created: String + + """The ISO 8601 date format of the time that this resource was edited.""" + edited: String + + """The ID of an object""" + id: ID! +} + +${connectTypes('SpeciesPeople', 'people', 'Person')} +${connectTypes('SpeciesFilms', 'films', 'Film')} +`; + +export default typeDef; diff --git a/src/graphql/schema/swapi/starship.ts b/src/graphql/schema/swapi/starship.ts new file mode 100644 index 0000000..77d5670 --- /dev/null +++ b/src/graphql/schema/swapi/starship.ts @@ -0,0 +1,79 @@ +import { connectionArgs } from 'graphql-relay-tools'; +import { connectTypes } from '../../connection'; + +const typeDef: string = ` +type Starship implements Node { + """The name of this starship. The common name, such as "Death Star".""" + name: String + + """ + The model or official name of this starship. Such as "T-65 X-wing" or "DS-1 + Orbital Battle Station". + """ + model: String + + """ + The class of this starship, such as "Starfighter" or "Deep Space Mobile + Battlestation" + """ + starshipClass: String + + """The manufacturers of this starship.""" + manufacturers: [String] + + """The cost of this starship new, in galactic credits.""" + costInCredits: Float + + """The length of this starship in meters.""" + length: Float + + """The number of personnel needed to run or pilot this starship.""" + crew: String + + """The number of non-essential people this starship can transport.""" + passengers: String + + """ + The maximum speed of this starship in atmosphere. null if this starship is + incapable of atmosphering flight. + """ + maxAtmospheringSpeed: Int + + """The class of this starships hyperdrive.""" + hyperdriveRating: Float + + """ + The Maximum number of Megalights this starship can travel in a standard hour. + A "Megalight" is a standard unit of distance and has never been defined before + within the Star Wars universe. This figure is only really useful for measuring + the difference in speed of starships. We can assume it is similar to AU, the + distance between our Sun (Sol) and Earth. + """ + MGLT: Int + + """The maximum number of kilograms that this starship can transport.""" + cargoCapacity: Float + + """ + The maximum length of time that this starship can provide consumables for its + entire crew without having to resupply. + """ + consumables: String + pilotConnection${connectionArgs()}: StarshipPilotsConnection + filmConnection${connectionArgs()}: StarshipFilmsConnection + + """The ISO 8601 date format of the time that this resource was created.""" + created: String + + """The ISO 8601 date format of the time that this resource was edited.""" + edited: String + + """The ID of an object""" + id: ID! +} + +${connectTypes('StarshipPilots', 'pilots', 'Person')} +${connectTypes('StarshipFilms', 'films', 'Film')} +`; + +export default typeDef; diff --git a/src/graphql/schema/swapi/vehicle.ts b/src/graphql/schema/swapi/vehicle.ts new file mode 100644 index 0000000..3252f80 --- /dev/null +++ b/src/graphql/schema/swapi/vehicle.ts @@ -0,0 +1,64 @@ +import { connectionArgs } from 'graphql-relay-tools'; +import { connectTypes } from '../../connection'; + +const typeDef: string = ` +type Vehicle implements Node { + """ + The name of this vehicle. The common name, such as "Sand Crawler" or "Speeder + bike". + """ + name: String + + """ + The model or official name of this vehicle. Such as "All-Terrain Attack + Transport". + """ + model: String + + """The class of this vehicle, such as "Wheeled" or "Repulsorcraft".""" + vehicleClass: String + + """The manufacturers of this vehicle.""" + manufacturers: [String] + + """The cost of this vehicle new, in Galactic Credits.""" + costInCredits: Float + + """The length of this vehicle in meters.""" + length: Float + + """The number of personnel needed to run or pilot this vehicle.""" + crew: String + + """The number of non-essential people this vehicle can transport.""" + passengers: String + + """The maximum speed of this vehicle in atmosphere.""" + maxAtmospheringSpeed: Int + + """The maximum number of kilograms that this vehicle can transport.""" + cargoCapacity: Float + + """ + The maximum length of time that this vehicle can provide consumables for its + entire crew without having to resupply. + """ + consumables: String + pilotConnection${connectionArgs()}: VehiclePilotsConnection + filmConnection${connectionArgs()}: VehicleFilmsConnection + + """The ISO 8601 date format of the time that this resource was created.""" + created: String + + """The ISO 8601 date format of the time that this resource was edited.""" + edited: String + + """The ID of an object""" + id: ID! +} + +${connectTypes('VehiclePilots', 'pilots', 'Person')} +${connectTypes('VehicleFilms', 'films', 'Film')} +`; + +export default typeDef; diff --git a/src/logger.ts b/src/logger.ts index 50d9da9..4abd449 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -2,9 +2,11 @@ import { createLogger, transports, format } from 'winston'; import moment from 'moment'; const timestamp = () => moment().format('YYYY-MM-DD HH:mm:ss.SSSS'); +const rid = () => process.storage && process.storage.get('rid') ? `[${process.storage.get('rid')}]` : ''; const customFormat = format.printf(options => - `${timestamp()} ${options.level.toUpperCase()} ${(options.message ? options.message : '')} + `${timestamp()} ${options.level.toUpperCase()} ${rid()} + ${(options.message ? options.message : '')} ${(options.meta && Object.keys(options.meta).length ? '\n\t' + JSON.stringify(options.meta) // tslint:disable-line:prefer-template : '')}`); diff --git a/src/server.ts b/src/server.ts index 5ee41d5..570abe5 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,4 +1,5 @@ import 'source-map-support/register'; +import './declaration'; import paths from './paths'; import env from './env'; process.env = env; diff --git a/yarn.lock b/yarn.lock index 053a92a..8b12b66 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1500,6 +1500,11 @@ data-urls@^1.0.0: whatwg-mimetype "^2.2.0" whatwg-url "^7.0.0" +dataloader@1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/dataloader/-/dataloader-1.4.0.tgz#bca11d867f5d3f1b9ed9f737bd15970c65dff5c8" + integrity sha512-68s5jYdlvasItOJnCuI2Q9s4q98g0pCyL3HrcKJu8KNugUl8ahgmZYg38ysLTgQjjXX3H8CJLkAvWrclWfcalw== + debounce@^1.1.0: version "1.2.0" resolved "https://registry.yarnpkg.com/debounce/-/debounce-1.2.0.tgz#44a540abc0ea9943018dc0eaa95cce87f65cd131" @@ -2380,7 +2385,7 @@ graphql-extensions@^0.0.x, graphql-extensions@~0.0.9: core-js "^2.5.3" source-map-support "^0.5.1" -graphql-import@0.7.1, graphql-import@^0.7.1: +graphql-import@0.7.1: version "0.7.1" resolved "https://registry.yarnpkg.com/graphql-import/-/graphql-import-0.7.1.tgz#4add8d91a5f752d764b0a4a7a461fcd93136f223" integrity sha512-YpwpaPjRUVlw2SN3OPljpWbVRWAhMAyfSba5U47qGMOSsPLi2gYeJtngGpymjm9nk57RFWEpjqwh4+dpYuFAPw== @@ -2400,6 +2405,18 @@ graphql-playground-middleware-koa@1.6.8: dependencies: graphql-playground-html "1.6.6" +graphql-relay-tools@0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/graphql-relay-tools/-/graphql-relay-tools-0.1.1.tgz#c6ca0d00c1ceddf08854544299d186ec5af474ca" + integrity sha1-xsoNAMHO3fCIVFRCmdGG7Fr0dMo= + dependencies: + graphql-relay "^0.5.3" + +graphql-relay@0.5.5, graphql-relay@^0.5.3: + version "0.5.5" + resolved "https://registry.yarnpkg.com/graphql-relay/-/graphql-relay-0.5.5.tgz#d6815e6edd618e878d5d921c13fc66033ec867e2" + integrity sha1-1oFebt1hjoeNXZIcE/xmAz7IZ+I= + graphql-tag@2.10.0: version "2.10.0" resolved "https://registry.yarnpkg.com/graphql-tag/-/graphql-tag-2.10.0.tgz#87da024be863e357551b2b8700e496ee2d4353ae" @@ -5521,6 +5538,11 @@ sshpk@^1.7.0: safer-buffer "^2.0.2" tweetnacl "~0.14.0" +stack-storage@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/stack-storage/-/stack-storage-2.0.0.tgz#c0b2052e561991ab7bd5d6b1d84e31559e120d28" + integrity sha512-C3EYNHBlBQFwaPNndOxWj6nQQQS/iVsZwWcXV6VgR1ViGrROpZlpS3kh1GJknC5Qi5Z0sadFBPT2t8cAPa7MeQ== + stack-trace@0.0.x: version "0.0.10" resolved "https://registry.yarnpkg.com/stack-trace/-/stack-trace-0.0.10.tgz#547c70b347e8d32b4e108ea1a2a159e5fdde19c0"