Skip to content
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 83 additions & 4 deletions apps/docs/app/api/graphql/route.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { POST } from './route'

describe('/api/graphql basic error statuses', () => {
vi.mock('~/lib/logger', async () => {
const actual = await vi.importActual<typeof import('~/lib/logger')>('~/lib/logger')
return {
...actual,
sendToLogflare: vi.fn(),
}
})

import { GET, POST } from './route'

describe('/api/graphql POST basic error statuses', () => {
beforeAll(() => {
vi.spyOn(console, 'error').mockImplementation(() => {})
})
Expand Down Expand Up @@ -35,7 +44,7 @@ describe('/api/graphql basic error statuses', () => {
const response = await POST(request)
const json = await response.json()
expect(json.errors[0].message).toContain(
'Invalid request: Request body must be valid GraphQL request object'
'Invalid request: GraphQL request payload must be valid GraphQL request object'
)
})

Expand All @@ -48,7 +57,7 @@ describe('/api/graphql basic error statuses', () => {
const response = await POST(request)
const json = await response.json()
expect(json.errors[0].message).toContain(
'Invalid request: Request body must be valid GraphQL request object'
'Invalid request: GraphQL request payload must be valid GraphQL request object'
)
})

Expand Down Expand Up @@ -90,3 +99,73 @@ describe('/api/graphql schema snapshot', () => {
expect(schema).toMatchSnapshot()
})
})

describe('/api/graphql GET support', () => {
beforeAll(() => {
vi.spyOn(console, 'error').mockImplementation(() => {})
})

afterAll(() => {
vi.restoreAllMocks()
})

it('should return error if query parameter is missing', async () => {
const request = new Request('http://localhost/api/graphql', {
method: 'GET',
})

const response = await GET(request)
const json = await response.json()
expect(json.errors[0].message).toContain(
'Invalid request: GraphQL request payload must be valid GraphQL request object'
)
})

it('should return error if variables parameter is not valid JSON', async () => {
const url = new URL('http://localhost/api/graphql')
url.searchParams.set('query', '{ schema }')
url.searchParams.set('variables', '{not json}')
const request = new Request(url, {
method: 'GET',
})

const response = await GET(request)
const json = await response.json()
expect(json.errors[0].message).toBe(
'Invalid request: Variables query parameter must be valid JSON'
)
})

it('should not allow mutations on GET requests', async () => {
const url = new URL('http://localhost/api/graphql')
url.searchParams.set('query', 'mutation { __typename }')
const request = new Request(url, {
method: 'GET',
})

const response = await GET(request)
const json = await response.json()
expect(
json.errors.some((error: { message: string }) =>
error.message.includes('GET requests may only execute query operations')
)
).toBe(true)
})

it('should return schema via GET and set cache headers', async () => {
const schemaQuery = 'query { schema }'
const url = new URL('http://localhost/api/graphql')
url.searchParams.set('query', schemaQuery)
const request = new Request(url, {
method: 'GET',
})

const response = await GET(request)
const json = await response.json()
expect(json.errors).toBeUndefined()

const cacheControl = response.headers.get('Cache-Control')
expect(cacheControl).toBe('public, s-maxage=3600, stale-while-revalidate=300')
expect(json.data.schema).toBeDefined()
})
})
171 changes: 143 additions & 28 deletions apps/docs/app/api/graphql/route.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
import * as Sentry from '@sentry/nextjs'
import { type DocumentNode, graphql, GraphQLError, parse, specifiedRules, validate } from 'graphql'
import {
getOperationAST,
graphql,
GraphQLError,
parse,
specifiedRules,
validate,
type DocumentNode,
} from 'graphql'
import { createComplexityLimitRule } from 'graphql-validation-complexity'
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { ApiError, convertZodToInvalidRequestError, InvalidRequestError } from '~/app/api/utils'
import { BASE_PATH, IS_DEV } from '~/lib/constants'
import { sendToLogflare, LOGGING_CODES } from '~/lib/logger'
import { LOGGING_CODES, sendToLogflare } from '~/lib/logger'
import { rootGraphQLSchema } from '~/resources/rootSchema'
import { createQueryDepthLimiter } from './validators'

Expand Down Expand Up @@ -55,14 +63,24 @@ function getCorsHeaders(request: Request): Record<string, string> {
if (origin && isAllowedCorsOrigin(origin)) {
return {
'Access-Control-Allow-Origin': origin,
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Accept',
}
}

return {}
}

function getCacheHeaders(): Record<string, string> {
return {
/**
* Cache on CDN for 1 hour
* Serve stale content while revalidating for 5 minutes
*/
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=300',
}
}

const validationRules = [
...specifiedRules,
createQueryDepthLimiter(MAX_DEPTH),
Expand All @@ -88,21 +106,18 @@ const graphQLRequestSchema = z.object({
variables: z.record(z.any()).optional(),
operationName: z.string().optional(),
})
type GraphQLRequestPayload = z.infer<typeof graphQLRequestSchema>

async function handleGraphQLRequest(request: Request): Promise<NextResponse> {
const body = await request.json().catch((error) => {
throw new InvalidRequestError('Request body must be valid JSON', error)
})
const parsedBody = graphQLRequestSchema.safeParse(body)
if (!parsedBody.success) {
throw convertZodToInvalidRequestError(
parsedBody.error,
'Request body must be valid GraphQL request object'
)
}
const { method } = request
const isGetRequest = method === 'GET'

const { query, variables, operationName } = parsedBody.data
const validationErrors = validateGraphQLRequest(query, isDevGraphiQL(request))
const { query, variables, operationName } = await parseGraphQLRequestPayload(request)
const validationErrors = validateGraphQLRequest(query, {
isDevGraphiQL: isDevGraphiQL(request),
isGetRequest,
operationName,
})
if (validationErrors.length > 0) {
return NextResponse.json(
{
Expand All @@ -113,7 +128,7 @@ async function handleGraphQLRequest(request: Request): Promise<NextResponse> {
})),
},
{
headers: getCorsHeaders(request),
headers: getResponseHeaders(request, isGetRequest),
}
)
}
Expand All @@ -126,11 +141,83 @@ async function handleGraphQLRequest(request: Request): Promise<NextResponse> {
operationName,
})
return NextResponse.json(result, {
headers: getCorsHeaders(request),
headers: getResponseHeaders(request, isGetRequest),
})
}

function getResponseHeaders(request: Request, isGetRequest: boolean): Record<string, string> {
const headers = {
...getCorsHeaders(request),
}

if (isGetRequest) {
Object.assign(headers, getCacheHeaders())
}

return headers
}

async function parseGraphQLRequestPayload(request: Request): Promise<GraphQLRequestPayload> {
if (request.method === 'GET') {
return parseGraphQLGetRequest(request)
}

return parseGraphQLJsonBody(request)
}

async function parseGraphQLJsonBody(request: Request): Promise<GraphQLRequestPayload> {
const body = await request.json().catch((error) => {
throw new InvalidRequestError('Request body must be valid JSON', error)
})
const parsedBody = graphQLRequestSchema.safeParse(body)
if (!parsedBody.success) {
throw convertZodToInvalidRequestError(
parsedBody.error,
'GraphQL request payload must be valid GraphQL request object'
)
}

return parsedBody.data
}

function parseGraphQLGetRequest(request: Request): GraphQLRequestPayload {
const url = new URL(request.url)
const query = url.searchParams.get('query')
const operationName = url.searchParams.get('operationName') ?? undefined

const variablesParam = url.searchParams.get('variables')
let variables: GraphQLRequestPayload['variables'] = undefined
if (variablesParam !== null) {
try {
variables = JSON.parse(variablesParam)
} catch (error) {
throw new InvalidRequestError('Variables query parameter must be valid JSON', error)
}
}

const parsedBody = graphQLRequestSchema.safeParse({
query,
variables,
operationName,
})
if (!parsedBody.success) {
throw convertZodToInvalidRequestError(
parsedBody.error,
'GraphQL request payload must be valid GraphQL request object'
)
}

return parsedBody.data
}

function validateGraphQLRequest(query: string, isDevGraphiQL = false): ReadonlyArray<GraphQLError> {
function validateGraphQLRequest(
query: string,
options?: {
isDevGraphiQL?: boolean
isGetRequest?: boolean
operationName?: string
}
): ReadonlyArray<GraphQLError> {
let documentAST: DocumentNode
try {
documentAST = parse(query)
Expand All @@ -141,23 +228,35 @@ function validateGraphQLRequest(query: string, isDevGraphiQL = false): ReadonlyA
throw error
}
}
const rules = isDevGraphiQL ? specifiedRules : validationRules
return validate(rootGraphQLSchema, documentAST, rules)
}
const rules = options?.isDevGraphiQL ? specifiedRules : validationRules
const validationErrors = validate(rootGraphQLSchema, documentAST, rules)
if (!options?.isGetRequest) {
return validationErrors
}

export async function OPTIONS(request: Request): Promise<NextResponse> {
const corsHeaders = getCorsHeaders(request)
return new NextResponse(null, {
status: 204,
headers: corsHeaders,
})
const operationAST = getOperationAST(documentAST, options.operationName)
if (!operationAST) {
return [
...validationErrors,
new GraphQLError(
'GET requests must specify an operation name or send only a single operation'
),
]
}
if (operationAST.operation !== 'query') {
return [...validationErrors, new GraphQLError('GET requests may only execute query operations')]
}

return validationErrors
}

export async function POST(request: Request): Promise<NextResponse> {
async function handleRequest(request: Request): Promise<NextResponse> {
try {
const method = request.method
const vercelId = request.headers.get('x-vercel-id')
sendToLogflare(LOGGING_CODES.CONTENT_API_REQUEST_RECEIVED, {
vercelId,
method,
origin: request.headers.get('Origin'),
userAgent: request.headers.get('User-Agent'),
})
Expand Down Expand Up @@ -203,3 +302,19 @@ export async function POST(request: Request): Promise<NextResponse> {
}
}
}

export async function OPTIONS(request: Request): Promise<NextResponse> {
const corsHeaders = getCorsHeaders(request)
return new NextResponse(null, {
status: 204,
headers: corsHeaders,
})
}

export async function GET(request: Request): Promise<NextResponse> {
return handleRequest(request)
}

export async function POST(request: Request): Promise<NextResponse> {
return handleRequest(request)
}
Loading
Loading