diff --git a/doc/example_queries/09_union.graphql b/doc/example_queries/09_union.graphql new file mode 100644 index 0000000..a07dcca --- /dev/null +++ b/doc/example_queries/09_union.graphql @@ -0,0 +1,16 @@ +{ + allMachines { + machines { + ... on Vehicle { + id, + name, + vehicleClass + }, + ... on Starship { + id, + name, + starshipClass + } + } + } +} diff --git a/src/schema/__tests__/machine.js b/src/schema/__tests__/machine.js new file mode 100644 index 0000000..dc5e248 --- /dev/null +++ b/src/schema/__tests__/machine.js @@ -0,0 +1,178 @@ +/** + * Copyright (c) 2015, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the license found in the + * LICENSE-examples file in the root directory of this source tree. + */ + +import { expect } from 'chai'; +import { describe, it } from 'mocha'; +import { swapi } from './swapi'; + +// 80+ char lines are useful in describe/it, so ignore in this file. +/* eslint-disable max-len */ + +function getDocument(query) { + return `${query} + fragment AllStarshipProperties on Starship { + MGLT + starshipCargoCapacity: cargoCapacity + consumables + starshipCostInCredits: costInCredits + crew + hyperdriveRating + length + manufacturers + maxAtmospheringSpeed + model + name + passengers + starshipClass + filmConnection(first:1) { edges { node { title } } } + pilotConnection(first:1) { edges { node { name } } } + } + + fragment AllVehicleProperties on Vehicle { + vehicleCargoCapacity: cargoCapacity + consumables + vehicleCostInCredits: costInCredits + crew + length + manufacturers + maxAtmospheringSpeed + model + name + passengers + vehicleClass + filmConnection(first:1) { edges { node { title } } } + pilotConnection(first:1) { edges { node { name } } } + } + + fragment AllPersonProperties on Person { + birthYear + eyeColor + gender + hairColor + height + homeworld { name } + mass + name + skinColor + species { name } + filmConnection(first:1) { edges { node { title } } } + starshipConnection(first:1) { edges { node { name } } } + vehicleConnection(first:1) { edges { node { name } } } + } + `; +} + +describe('Machine type', async () => { + it('Gets an object by global ID', async () => { + const query = '{ starship(starshipID: 5) { id, name } }'; + const result = await swapi(query); + const nextQuery = ` + { + machine(id: "${result.data.starship.id}") { + ... on Vehicle { id, name }, + ... on Starship { id, name }, + ... on Person { id, name } + } + } + `; + const nextResult = await swapi(nextQuery); + expect(result.data.starship.name).to.equal('Sentinel-class landing craft'); + expect(nextResult.data.machine.name).to.equal( + 'Sentinel-class landing craft', + ); + expect(result.data.starship.id).to.equal(nextResult.data.machine.id); + }); + + it('Gets all properties', async () => { + const query = '{ starship(starshipID: 5) { id, name } }'; + const idResult = await swapi(query); + + const nextQuery = getDocument( + `{ + machine(id: "${idResult.data.starship.id}") { + ... on Starship { + ...AllStarshipProperties + } + ... on Vehicle { + ...AllVehicleProperties + } + ... on Person { + ...AllPersonProperties + } + } + }`, + ); + const result = await swapi(nextQuery); + const expected = { + MGLT: 70, + starshipCargoCapacity: 180000, + consumables: '1 month', + starshipCostInCredits: 240000, + crew: '5', + filmConnection: { edges: [{ node: { title: 'A New Hope' } }] }, + hyperdriveRating: 1, + length: 38, + manufacturers: ['Sienar Fleet Systems', 'Cyngus Spaceworks'], + maxAtmospheringSpeed: 1000, + model: 'Sentinel-class landing craft', + name: 'Sentinel-class landing craft', + passengers: '75', + pilotConnection: { edges: [] }, + starshipClass: 'landing craft', + }; + expect(result.data.machine).to.deep.equal(expected); + }); + + it('All objects query', async () => { + const query = getDocument( + `{ + allMachines { + edges { + cursor, + node { + ... on Starship { ...AllStarshipProperties }, + ... on Vehicle { ...AllVehicleProperties }, + ... on Person { ... AllPersonProperties } + } + } + } + }`, + ); + const result = await swapi(query); + expect(result.data.allMachines.edges.length).to.equal(81); + }); + + it('Pagination query', async () => { + const query = `{ + allMachines(first: 2) { + edges { + cursor, + node { + ... on Vehicle { name }, + ... on Starship { name }, + ... on Person { name } + } + } + } + }`; + const result = await swapi(query); + expect(result.data.allMachines.edges.map(e => e.node.name)).to.deep.equal([ + 'Sand Crawler', + 'T-16 skyhopper', + ]); + const nextCursor = result.data.allMachines.edges[1].cursor; + + const nextQuery = `{ allMachines(first: 2, after:"${nextCursor}") { + edges { cursor, node { ... on Vehicle { name }, ... on Starship { name }, ... on Person { name } } } } + }`; + const nextResult = await swapi(nextQuery); + expect( + nextResult.data.allMachines.edges.map(e => e.node.name), + ).to.deep.equal(['X-34 landspeeder', 'TIE/LN starfighter']); + }); +}); diff --git a/src/schema/apiHelper.js b/src/schema/apiHelper.js index cfdacf0..8d810e4 100644 --- a/src/schema/apiHelper.js +++ b/src/schema/apiHelper.js @@ -32,6 +32,14 @@ export async function getObjectFromUrl(url: string): Promise { return objectWithId(data); } +/** + * Given an object URL, return the Swapi type from it + * @param url + */ +export function getSwapiTypeFromUrl(url: string): string { + return url.split('/')[4]; +} + /** * Given a type and ID, get the object with the ID. */ diff --git a/src/schema/graphQLFilteredUnionType.js b/src/schema/graphQLFilteredUnionType.js new file mode 100644 index 0000000..8af5cb1 --- /dev/null +++ b/src/schema/graphQLFilteredUnionType.js @@ -0,0 +1,26 @@ +/** + * Copyright (c) 2015, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the license found in the + * LICENSE-examples file in the root directory of this source tree. + */ + +import { GraphQLUnionType } from 'graphql'; +import { GraphQLUnionTypeConfig } from 'graphql/type/definition'; + +/** + * GraphQLUnionType with a 'filter' method that allow to filter the + * elements of the union + */ +export default class GraphQLFilteredUnionType extends GraphQLUnionType { + constructor(config: GraphQLUnionTypeConfig<*, *>): void { + super(config); + + if ('filter' in config) { + this.filter = config.filter; + } else { + this.filter = (type, objects) => objects; + } + } +} diff --git a/src/schema/index.js b/src/schema/index.js index 5f61c2c..190e7f7 100644 --- a/src/schema/index.js +++ b/src/schema/index.js @@ -13,6 +13,7 @@ import { GraphQLInt, GraphQLList, GraphQLObjectType, + GraphQLUnionType, GraphQLSchema, } from 'graphql'; @@ -25,26 +26,37 @@ import { import { getObjectsByType, getObjectFromTypeAndId } from './apiHelper'; -import { swapiTypeToGraphQLType, nodeField } from './relayNode'; +import { + swapiTypeToGraphQLType, + graphQLTypeToSwapiType, + nodeField, +} from './relayNode'; +import GraphQLFilteredUnionType from './graphQLFilteredUnionType'; /** * Creates a root field to get an object of a given type. * Accepts either `id`, the globally unique ID used in GraphQL, - * or `idName`, the per-type ID used in SWAPI. + * or `idName`, the per-type ID used in SWAPI (idName is only + * usable on non-union elements). */ function rootFieldByID(idName, swapiType) { - const getter = id => getObjectFromTypeAndId(swapiType, id); + const graphQLType = swapiTypeToGraphQLType(swapiType); const argDefs = {}; argDefs.id = { type: GraphQLID }; - argDefs[idName] = { type: GraphQLID }; + if (!(graphQLType instanceof GraphQLUnionType)) { + argDefs[idName] = { type: GraphQLID }; + } return { - type: swapiTypeToGraphQLType(swapiType), + type: graphQLType, args: argDefs, resolve: (_, args) => { - if (args[idName] !== undefined && args[idName] !== null) { - return getter(args[idName]); + if ( + !(swapiType instanceof GraphQLUnionType) && + args[idName] !== undefined && + args[idName] !== null + ) { + return getObjectFromTypeAndId(swapiType, args[idName]); } - if (args.id !== undefined && args.id !== null) { const globalId = fromGlobalId(args.id); if ( @@ -54,7 +66,7 @@ function rootFieldByID(idName, swapiType) { ) { throw new Error('No valid ID extracted from ' + args.id); } - return getter(globalId.id); + return getObjectFromTypeAndId(globalId.type, globalId.id); } throw new Error('must provide id or ' + idName); }, @@ -95,7 +107,31 @@ full "{ edges { node } }" version should be used instead.`, type: connectionType, args: connectionArgs, resolve: async (_, args) => { - const { objects, totalCount } = await getObjectsByType(swapiType); + const graphQLType = swapiTypeToGraphQLType(swapiType); + let objects = []; + let totalCount = 0; + if (graphQLType instanceof GraphQLUnionType) { + for (const type of graphQLType.getTypes()) { + // eslint-disable-next-line no-await-in-loop + const objectsByType = await getObjectsByType( + graphQLTypeToSwapiType(type), + ); + if (graphQLType instanceof GraphQLFilteredUnionType) { + objectsByType.objects = graphQLType.filter( + type, + objectsByType.objects, + ); + objectsByType.totalCount = objectsByType.objects.length; + } + objects = objects.concat(objectsByType.objects); + totalCount += objectsByType.totalCount; + } + } else { + const objectsByType = await getObjectsByType(swapiType); + objects = objects.concat(objectsByType.objects); + totalCount = objectsByType.totalCount; + } + return { ...connectionFromArray(objects, args), totalCount, @@ -122,6 +158,8 @@ const rootType = new GraphQLObjectType({ starship: rootFieldByID('starshipID', 'starships'), allVehicles: rootConnection('Vehicles', 'vehicles'), vehicle: rootFieldByID('vehicleID', 'vehicles'), + machine: rootFieldByID('machineID', 'machines'), + allMachines: rootConnection('Machines', 'machines'), node: nodeField, }), }); diff --git a/src/schema/relayNode.js b/src/schema/relayNode.js index dfa7ac6..e4f1151 100644 --- a/src/schema/relayNode.js +++ b/src/schema/relayNode.js @@ -10,20 +10,23 @@ import { getObjectFromTypeAndId } from './apiHelper'; -import type { GraphQLObjectType } from 'graphql'; +import type { GraphQLObjectType, GraphQLUnionType } from 'graphql'; import { nodeDefinitions, fromGlobalId } from 'graphql-relay'; /** * Given a "type" in SWAPI, returns the corresponding GraphQL type. */ -export function swapiTypeToGraphQLType(swapiType: string): GraphQLObjectType { +export function swapiTypeToGraphQLType( + swapiType: string, +): GraphQLObjectType | GraphQLUnionType { const FilmType = require('./types/film').default; const PersonType = require('./types/person').default; const PlanetType = require('./types/planet').default; const SpeciesType = require('./types/species').default; const StarshipType = require('./types/starship').default; const VehicleType = require('./types/vehicle').default; + const MachineType = require('./types/machine').default; switch (swapiType) { case 'films': @@ -38,11 +41,34 @@ export function swapiTypeToGraphQLType(swapiType: string): GraphQLObjectType { return VehicleType; case 'species': return SpeciesType; + case 'machines': + return MachineType; default: throw new Error('Unrecognized type `' + swapiType + '`.'); } } +/** + * Given a GraphQL type, return the corresponding SWAPI type + */ +export function graphQLTypeToSwapiType(graphQLType: GraphQLObjectType): string { + const typeMap = { + Film: 'films', + Person: 'people', + Planet: 'planets', + Starship: 'starships', + Vehicle: 'vehicles', + Species: 'species', + Machine: 'machines', + }; + + if (graphQLType.name in typeMap) { + return typeMap[graphQLType.name]; + } + + throw new Error('Unrecognized type `' + graphQLType.name + '`.'); +} + const { nodeInterface, nodeField } = nodeDefinitions( globalId => { const { type, id } = fromGlobalId(globalId); diff --git a/src/schema/types/machine.js b/src/schema/types/machine.js new file mode 100644 index 0000000..1c9baa7 --- /dev/null +++ b/src/schema/types/machine.js @@ -0,0 +1,50 @@ +/* @flow */ +/** + * Copyright (c) 2015, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the license found in the + * LICENSE-examples file in the root directory of this source tree. + */ + +import GraphQLFilteredUnionType from '../graphQLFilteredUnionType'; + +import { getSwapiTypeFromUrl } from '../apiHelper'; + +import VehicleType from './vehicle'; +import StarshipType from './starship'; +import PersonType from './person'; + +/** + * GraphQL equivalent of every "machine" in the SW univers (from SWAPI) + */ +const MachineType = new GraphQLFilteredUnionType({ + name: 'Machine', + types: [VehicleType, StarshipType, PersonType], + resolveType: value => { + const swapiType = getSwapiTypeFromUrl(value.url); + + switch (swapiType) { + case 'vehicles': + return VehicleType; + case 'starships': + return StarshipType; + case 'people': + return PersonType; + default: + throw new Error('Type `' + swapiType + '` not in Machine type.'); + } + }, + filter: (type, objects) => { + if (type.name === PersonType.name) { + // filter Person to return only droid (species ID : 2) + return objects.filter(person => + person.species.includes('https://swapi.co/api/species/2/'), + ); + } + return objects; + }, + description: 'Union of Vehicle, Starship and Droid : every available machine', +}); + +export default MachineType;