diff --git a/.changeset/sour-onions-serve.md b/.changeset/sour-onions-serve.md new file mode 100644 index 00000000000..d5258a127ab --- /dev/null +++ b/.changeset/sour-onions-serve.md @@ -0,0 +1,5 @@ +--- +'@keystone-next/keystone': minor +--- + +Added the config option `config.graphql.path` to configure the endpoint of the GraphQL API (default `'/api/graphql'`). diff --git a/docs/pages/docs/apis/config.mdx b/docs/pages/docs/apis/config.mdx index 88992f9b946..7e5bbefe702 100644 --- a/docs/pages/docs/apis/config.mdx +++ b/docs/pages/docs/apis/config.mdx @@ -287,6 +287,7 @@ Options: These can be filtered out with `apolloConfig.formatError` if you need to process them, but do not want them returned over the GraphQL API. - `queryLimits` (default: `undefined`): Allows you to limit the total number of results returned from a query to your GraphQL API. See also the per-list `graphql.queryLimits` option in the [Schema API](./schema). +- `path` (default: `'/api/graphql'`): The path of the GraphQL API endpoint. - `apolloConfig` (default: `undefined`): Allows you to pass extra options into the `ApolloServer` constructor. - `playground` (default: `process.env.NODE_ENV !== 'production'`): If truthy, will enable the GraphQL Playground for testing queries and mutations in the browser. To configure behaviour, pass an object of [GraphQL Playground settings](https://github.com/graphql/graphql-playground#settings). See the [Apollo docs](https://www.apollographql.com/docs/apollo-server/api/apollo-server/#constructor) for more supported options. @@ -297,6 +298,7 @@ export default config({ graphql: { debug: process.env.NODE_ENV !== 'production', queryLimits: { maxTotalResults: 100 }, + path: '/api/graphql', apolloConfig: { playground: process.env.NODE_ENV !== 'production', introspection: process.env.NODE_ENV !== 'production', diff --git a/examples-staging/basic/keystone.ts b/examples-staging/basic/keystone.ts index 01b2395ff74..1ae053b3351 100644 --- a/examples-staging/basic/keystone.ts +++ b/examples-staging/basic/keystone.ts @@ -29,10 +29,6 @@ export default auth.withAuth( provider: 'sqlite', url: process.env.DATABASE_URL || 'file:./keystone-example.db', }, - // NOTE -- this is not implemented, keystone currently always provides a graphql api at /api/graphql - // graphql: { - // path: '/api/graphql', - // }, ui: { // NOTE -- this is not implemented, keystone currently always provides an admin ui at / // path: '/admin', diff --git a/packages/keystone/src/___internal-do-not-use-will-break-in-patch/admin-ui/pages/App/index.tsx b/packages/keystone/src/___internal-do-not-use-will-break-in-patch/admin-ui/pages/App/index.tsx index 89695d0b655..0b50aa8ee90 100644 --- a/packages/keystone/src/___internal-do-not-use-will-break-in-patch/admin-ui/pages/App/index.tsx +++ b/packages/keystone/src/___internal-do-not-use-will-break-in-patch/admin-ui/pages/App/index.tsx @@ -11,6 +11,7 @@ type AppConfig = { adminMetaHash: string; fieldViews: FieldViews; lazyMetadataQuery: DocumentNode; + apiPath: string; }; export const getApp = diff --git a/packages/keystone/src/___internal-do-not-use-will-break-in-patch/next-graphql.ts b/packages/keystone/src/___internal-do-not-use-will-break-in-patch/next-graphql.ts index 91565438128..1cf2125c87f 100644 --- a/packages/keystone/src/___internal-do-not-use-will-break-in-patch/next-graphql.ts +++ b/packages/keystone/src/___internal-do-not-use-will-break-in-patch/next-graphql.ts @@ -19,5 +19,5 @@ export function nextGraphQLAPIRoute(keystoneConfig: KeystoneConfig, prismaClient connectionPromise: keystone.connect(), }); - return apolloServer.createHandler({ path: '/api/graphql' }); + return apolloServer.createHandler({ path: keystoneConfig.graphql?.path || '/api/graphql' }); } diff --git a/packages/keystone/src/admin-ui/components/Navigation.tsx b/packages/keystone/src/admin-ui/components/Navigation.tsx index 800e9d24621..aac48c67c08 100644 --- a/packages/keystone/src/admin-ui/components/Navigation.tsx +++ b/packages/keystone/src/admin-ui/components/Navigation.tsx @@ -61,6 +61,7 @@ export const NavItem = ({ href, children, isSelected: _isSelected }: NavItemProp }; const AuthenticatedItemDialog = ({ item }: { item: AuthenticatedItem | undefined }) => { + const { apiPath } = useKeystone(); const { spacing, typography } = useTheme(); return (
- {/* FIXME: Use config.graphql.path */} - + API Explorer diff --git a/packages/keystone/src/admin-ui/context.tsx b/packages/keystone/src/admin-ui/context.tsx index d5b1bfd86f7..35468e8da2a 100644 --- a/packages/keystone/src/admin-ui/context.tsx +++ b/packages/keystone/src/admin-ui/context.tsx @@ -24,6 +24,7 @@ type KeystoneContextType = { visibleLists: VisibleLists; createViewFieldModes: CreateViewFieldModes; reinitContext: () => void; + apiPath: string; }; const KeystoneContext = createContext(undefined); @@ -34,6 +35,7 @@ type KeystoneProviderProps = { adminMetaHash: string; fieldViews: FieldViews; lazyMetadataQuery: DocumentNode; + apiPath: string; }; function InternalKeystoneProvider({ @@ -42,6 +44,7 @@ function InternalKeystoneProvider({ adminMetaHash, children, lazyMetadataQuery, + apiPath, }: KeystoneProviderProps) { const adminMeta = useAdminMeta(adminMetaHash, fieldViews); const { authenticatedItem, visibleLists, createViewFieldModes, refetch } = @@ -70,6 +73,7 @@ function InternalKeystoneProvider({ reinitContext, visibleLists, createViewFieldModes, + apiPath, }} > {children} @@ -84,10 +88,9 @@ export const KeystoneProvider = (props: KeystoneProviderProps) => { () => new ApolloClient({ cache: new InMemoryCache(), - // FIXME: Use config.graphql.path - link: createUploadLink({ uri: '/api/graphql' }), + link: createUploadLink({ uri: props.apiPath }), }), - [] + [props.apiPath] ); return ( @@ -103,6 +106,7 @@ export const useKeystone = (): { authenticatedItem: AuthenticatedItem; visibleLists: VisibleLists; createViewFieldModes: CreateViewFieldModes; + apiPath: string; } => { const value = useContext(KeystoneContext); if (!value) { @@ -124,6 +128,7 @@ export const useKeystone = (): { authenticatedItem: value.authenticatedItem, visibleLists: value.visibleLists, createViewFieldModes: value.createViewFieldModes, + apiPath: value.apiPath, }; }; diff --git a/packages/keystone/src/admin-ui/system/createAdminUIServer.ts b/packages/keystone/src/admin-ui/system/createAdminUIServer.ts index a217ed48546..dfeec92dbc8 100644 --- a/packages/keystone/src/admin-ui/system/createAdminUIServer.ts +++ b/packages/keystone/src/admin-ui/system/createAdminUIServer.ts @@ -4,13 +4,14 @@ import type { KeystoneConfig, SessionStrategy, CreateContext } from '../../types import { createSessionContext } from '../../session'; export const createAdminUIServer = async ( - ui: KeystoneConfig['ui'], + config: KeystoneConfig, createContext: CreateContext, dev: boolean, projectAdminPath: string, sessionStrategy?: SessionStrategy ) => { /** We do this to stop webpack from bundling next inside of next */ + const { ui, graphql } = config; const thing = 'next'; const next = require(thing); const app = next({ dev, dir: projectAdminPath }); @@ -20,7 +21,7 @@ export const createAdminUIServer = async ( const publicPages = ui?.publicPages ?? []; return async (req: express.Request, res: express.Response) => { const { pathname } = url.parse(req.url); - if (pathname?.startsWith('/_next') || pathname === '/api/graphql') { + if (pathname?.startsWith('/_next') || pathname === (graphql?.path || '/api/graphql')) { handle(req, res); return; } diff --git a/packages/keystone/src/admin-ui/templates/api.ts b/packages/keystone/src/admin-ui/templates/api.ts index 47e014bc370..f9274171114 100644 --- a/packages/keystone/src/admin-ui/templates/api.ts +++ b/packages/keystone/src/admin-ui/templates/api.ts @@ -18,5 +18,5 @@ export const config = { bodyParser: false, }, }; -export default apolloServer.createHandler({ path: '/api/graphql' }); +export default apolloServer.createHandler({ path: initializedKeystoneConfig.graphql?.path || '/api/graphql' }); `; diff --git a/packages/keystone/src/admin-ui/templates/app.ts b/packages/keystone/src/admin-ui/templates/app.ts index d6151f68936..5167fa4da3f 100644 --- a/packages/keystone/src/admin-ui/templates/app.ts +++ b/packages/keystone/src/admin-ui/templates/app.ts @@ -19,7 +19,8 @@ type AppTemplateOptions = { configFileExists: boolean; projectAdminPath: string export const appTemplate = ( adminMetaRootVal: AdminMetaRootVal, graphQLSchema: GraphQLSchema, - { configFileExists, projectAdminPath }: AppTemplateOptions + { configFileExists, projectAdminPath }: AppTemplateOptions, + apiPath: string ) => { const result = executeSync({ document: staticAdminMetaQuery, @@ -53,7 +54,8 @@ export default getApp({ lazyMetadataQuery: ${JSON.stringify(getLazyMetadataQuery(graphQLSchema, adminMeta))}, fieldViews: [${allViews.map((_, i) => `view${i}`)}], adminMetaHash: "${adminMetaQueryResultHash}", - adminConfig: adminConfig + adminConfig: adminConfig, + apiPath: "${apiPath}", }); `; // -- TEMPLATE END diff --git a/packages/keystone/src/admin-ui/templates/index.ts b/packages/keystone/src/admin-ui/templates/index.ts index d6e7c7f34e5..cc2c1039c28 100644 --- a/packages/keystone/src/admin-ui/templates/index.ts +++ b/packages/keystone/src/admin-ui/templates/index.ts @@ -16,33 +16,49 @@ export const writeAdminFiles = ( adminMeta: AdminMetaRootVal, configFileExists: boolean, projectAdminPath: string -): AdminFileToWrite[] => [ - ...['next.config.js', 'tsconfig.json'].map( - outputPath => - ({ mode: 'copy', inputPath: Path.join(pkgDir, 'static', outputPath), outputPath } as const) - ), - { mode: 'write', src: noAccessTemplate(config.session), outputPath: 'pages/no-access.js' }, - { - mode: 'write', - src: appTemplate(adminMeta, graphQLSchema, { configFileExists, projectAdminPath }), - outputPath: 'pages/_app.js', - }, - { mode: 'write', src: homeTemplate, outputPath: 'pages/index.js' }, - ...adminMeta.lists.map( - ({ path, key }) => - ({ mode: 'write', src: listTemplate(key), outputPath: `pages/${path}/index.js` } as const) - ), - ...adminMeta.lists.map( - ({ path, key }) => - ({ mode: 'write', src: itemTemplate(key), outputPath: `pages/${path}/[id].js` } as const) - ), - ...(config.experimental?.enableNextJsGraphqlApiEndpoint - ? [ - { - mode: 'write' as const, - src: apiTemplate, - outputPath: 'pages/api/graphql.js', - }, - ] - : []), -]; +): AdminFileToWrite[] => { + if ( + config.experimental?.enableNextJsGraphqlApiEndpoint && + config.graphql?.path && + !config.graphql.path.startsWith('/api/') + ) { + throw new Error( + 'config.graphql.path must start with "/api/" when using config.experimental.enableNextJsGraphqlApiEndpoint' + ); + } + return [ + ...['next.config.js', 'tsconfig.json'].map( + outputPath => + ({ mode: 'copy', inputPath: Path.join(pkgDir, 'static', outputPath), outputPath } as const) + ), + { mode: 'write', src: noAccessTemplate(config.session), outputPath: 'pages/no-access.js' }, + { + mode: 'write', + src: appTemplate( + adminMeta, + graphQLSchema, + { configFileExists, projectAdminPath }, + config.graphql?.path || '/api/graphql' + ), + outputPath: 'pages/_app.js', + }, + { mode: 'write', src: homeTemplate, outputPath: 'pages/index.js' }, + ...adminMeta.lists.map( + ({ path, key }) => + ({ mode: 'write', src: listTemplate(key), outputPath: `pages/${path}/index.js` } as const) + ), + ...adminMeta.lists.map( + ({ path, key }) => + ({ mode: 'write', src: itemTemplate(key), outputPath: `pages/${path}/[id].js` } as const) + ), + ...(config.experimental?.enableNextJsGraphqlApiEndpoint + ? [ + { + mode: 'write' as const, + src: apiTemplate, + outputPath: `pages/${config.graphql?.path || '/api/graphql'}.js`, + }, + ] + : []), + ]; +}; diff --git a/packages/keystone/src/lib/server/createApolloServer.ts b/packages/keystone/src/lib/server/createApolloServer.ts index 33df36ca655..0948635c80d 100644 --- a/packages/keystone/src/lib/server/createApolloServer.ts +++ b/packages/keystone/src/lib/server/createApolloServer.ts @@ -61,7 +61,7 @@ const _createApolloServerConfig = ({ graphQLSchema: GraphQLSchema; graphqlConfig?: GraphQLConfig; }) => { - // Playground config, is /api/graphql available? + // Playground config const apolloConfig = graphqlConfig?.apolloConfig; const apolloConfigPlayground = apolloConfig?.playground; let playground: Config['playground']; diff --git a/packages/keystone/src/lib/server/createExpressServer.ts b/packages/keystone/src/lib/server/createExpressServer.ts index 922d4e92ec0..d4527de8381 100644 --- a/packages/keystone/src/lib/server/createExpressServer.ts +++ b/packages/keystone/src/lib/server/createExpressServer.ts @@ -33,9 +33,11 @@ const addApolloServer = ({ const maxFileSize = config.server?.maxFileSize || DEFAULT_MAX_FILE_SIZE; server.use(graphqlUploadExpress({ maxFileSize })); - // FIXME: Support custom API path via config.graphql.path. - // Note: Core keystone uses '/admin/api' as the default. - apolloServer.applyMiddleware({ app: server, path: '/api/graphql', cors: false }); + apolloServer.applyMiddleware({ + app: server, + path: config.graphql?.path || '/api/graphql', + cors: false, + }); }; export const createExpressServer = async ( @@ -75,7 +77,7 @@ export const createExpressServer = async ( } else { if (isVerbose) console.log('✨ Preparing Admin UI Next.js app'); server.use( - await createAdminUIServer(config.ui, createContext, dev, projectAdminPath, config.session) + await createAdminUIServer(config, createContext, dev, projectAdminPath, config.session) ); } diff --git a/packages/keystone/src/testing.ts b/packages/keystone/src/testing.ts index 506680a98b8..e7c458ed40d 100644 --- a/packages/keystone/src/testing.ts +++ b/packages/keystone/src/testing.ts @@ -70,7 +70,7 @@ export async function setupTestEnv({ const graphQLRequest: GraphQLRequest = ({ query, variables = undefined, operationName }) => supertest(app) - .post('/api/graphql') + .post(config.graphql?.path || '/api/graphql') .send({ query, variables, operationName }) .set('Accept', 'application/json'); diff --git a/packages/keystone/src/types/config/index.ts b/packages/keystone/src/types/config/index.ts index 1c131e8aec4..2a8b62e72ed 100644 --- a/packages/keystone/src/types/config/index.ts +++ b/packages/keystone/src/types/config/index.ts @@ -136,9 +136,8 @@ export type ServerConfig = { // config.graphql export type GraphQLConfig = { - // FIXME: We currently hardcode `/api/graphql` in a bunch of places - // We should be able to use config.graphql.path to set this path. - // path?: string; + // The path of the GraphQL API endpoint. Default: '/api/graphql'. + path?: string; queryLimits?: { maxTotalResults?: number; };