diff --git a/__test__/test-website/src/app/[...slug]/page.tsx b/__test__/test-website/src/app/[...slug]/page.tsx index ddcccf2..0a58928 100644 --- a/__test__/test-website/src/app/[...slug]/page.tsx +++ b/__test__/test-website/src/app/[...slug]/page.tsx @@ -1,4 +1,4 @@ -import { GraphClient } from '@episerver/cms-sdk'; +import { GraphClient, GraphErrors } from '@episerver/cms-sdk'; import { OptimizelyComponent } from '@episerver/cms-sdk/react/server'; import React from 'react'; @@ -6,15 +6,34 @@ type Props = { params: Promise<{ slug: string[]; }>; + // Assume that search params are correct: + searchParams: Promise<{ variation?: string }>; }; -export default async function Page({ params }: Props) { +function handleGraphErrors(err: unknown): never { + if (err instanceof GraphErrors.GraphResponseError) { + console.log('Error message:', err.message); + console.log('Query:', err.request.query); + console.log('Variables:', err.request.variables); + } + if (err instanceof GraphErrors.GraphContentResponseError) { + console.log('Detailed errors: ', err.errors); + } + + throw err; +} + +export default async function Page({ params, searchParams }: Props) { const { slug } = await params; + const { variation } = await searchParams; const client = new GraphClient(process.env.OPTIMIZELY_GRAPH_SINGLE_KEY!, { graphUrl: process.env.OPTIMIZELY_GRAPH_URL, }); - const c = await client.fetchContent(`/${slug.join('/')}/`); - return ; + const content = await client + .fetchContent({ path: `/${slug.join('/')}/`, variation }) + .catch(handleGraphErrors); + + return ; } diff --git a/packages/optimizely-cms-sdk/src/graph/createQuery.ts b/packages/optimizely-cms-sdk/src/graph/createQuery.ts index 0178d14..3e83c18 100644 --- a/packages/optimizely-cms-sdk/src/graph/createQuery.ts +++ b/packages/optimizely-cms-sdk/src/graph/createQuery.ts @@ -252,8 +252,8 @@ export function createQuery(contentType: string) { return ` ${fragment.join('\n')} -query FetchContent($filter: _ContentWhereInput) { - _Content(where: $filter) { +query FetchContent($where: _ContentWhereInput, $variation: VariationInput) { + _Content(where: $where, variation: $variation) { item { __typename ...${contentType} diff --git a/packages/optimizely-cms-sdk/src/graph/error.ts b/packages/optimizely-cms-sdk/src/graph/error.ts index 2d059a0..7fdda4e 100644 --- a/packages/optimizely-cms-sdk/src/graph/error.ts +++ b/packages/optimizely-cms-sdk/src/graph/error.ts @@ -1,4 +1,4 @@ -import type { GraphVariables } from './index.js'; +import type { ContentInput } from './filters.js'; /** Represents the request sent to graph */ type GraphRequest = { @@ -6,7 +6,7 @@ type GraphRequest = { query: string; /** Variables sent to Graph */ - variables: GraphVariables; + variables: ContentInput; }; /** Super-class for all errors related to Optimizely Graph */ diff --git a/packages/optimizely-cms-sdk/src/graph/filters.ts b/packages/optimizely-cms-sdk/src/graph/filters.ts new file mode 100644 index 0000000..cfc4b94 --- /dev/null +++ b/packages/optimizely-cms-sdk/src/graph/filters.ts @@ -0,0 +1,156 @@ +/** + * This module contains the TypeScript definitions of a Graph Query + * and functions to build those filters based on path, + * preview parameters, etc. + * + * This is used internally in the SDK + */ + +/** + * Creates a {@linkcode ContentInput} object that filters results by a specific URL path. + * + * @param path - The URL path to filter by. + * @returns A `GraphQueryArguments` object with a `where` clause that matches the given path. + */ +export function pathFilter(path: string): ContentInput { + return { + where: { + _metadata: { + url: { + default: { + eq: path, + }, + }, + }, + }, + }; +} + +/** + * Creates a {@linkcode ContentInput} object for previewing content based on key, version, and locale. + * + * @param params - An object containing the following properties: + * @param params.key - The unique key identifying the content. + * @param params.ver - The version of the content to preview. + * @param params.loc - The locale of the content to preview. + * + * @returns A `GraphQueryArguments` object with a `where` clause filtering by key, version, and locale. + */ +export function previewFilter(params: { + key: string; + ver: string; + loc: string; +}): ContentInput { + return { + where: { + _metadata: { + key: { eq: params.key }, + version: { eq: params.ver }, + locale: { eq: params.loc }, + }, + }, + }; +} + +export function variationFilter(value: string): ContentInput { + return { + variation: { + include: 'SOME', + value: [value], + }, + }; +} + +/** + * Arguments for querying content via the Graph API. + */ +export type ContentInput = { + variation?: VariationInput; + where?: ContentWhereInput; +}; + +type VariationInput = { + include?: VariationIncludeMode; + value?: string[]; + includeOriginal?: boolean; +}; + +type VariationIncludeMode = 'ALL' | 'SOME' | 'NONE'; + +type ContentWhereInput = { + _and?: ContentWhereInput[]; + _or?: ContentWhereInput[]; + _fulltext?: StringFilterInput; + _modified?: DateFilterInput; + _metadata?: IContentMetadataWhereInput; +}; + +type StringFilterInput = ScalarFilterInput & { + like?: string; + startsWith?: string; + endsWith?: string; + in?: string[]; + notIn?: string[]; + match?: string; + contains?: string; + synonyms?: ('ONE' | 'TWO')[]; + fuzzly?: boolean; +}; + +type DateFilterInput = ScalarFilterInput & { + gt?: string; + gte?: string; + lt?: string; + lte?: string; + decay?: { + origin?: string; + scale?: number; + rate?: number; + }; +}; + +type IContentMetadataWhereInput = { + key?: StringFilterInput; + locale?: StringFilterInput; + fallbackForLocale?: StringFilterInput; + version?: StringFilterInput; + displayName?: StringFilterInput; + url?: ContentUrlInput; + types?: StringFilterInput; + published?: DateFilterInput; + status?: StringFilterInput; + changeset?: StringFilterInput; + created?: DateFilterInput; + lastModified?: DateFilterInput; + sortOrder?: IntFilterInput; + variation?: StringFilterInput; +}; + +type IntFilterInput = ScalarFilterInput & { + gt?: number; + gte?: number; + lt?: number; + lte?: number; + in?: number[]; + notIn?: number[]; + factor?: { + value?: number; + modifier?: 'NONE' | 'SQUARE' | 'SQRT' | 'LOG' | 'RECIPROCAL'; + }; +}; + +type ContentUrlInput = { + type?: T; + default?: T; + hierarchical?: T; + internal?: T; + graph?: T; + base?: T; +}; + +type ScalarFilterInput = { + eq?: T; + notEq?: T; + exist?: boolean; + boost?: number; +}; diff --git a/packages/optimizely-cms-sdk/src/graph/index.ts b/packages/optimizely-cms-sdk/src/graph/index.ts index e7c9cce..4e95741 100644 --- a/packages/optimizely-cms-sdk/src/graph/index.ts +++ b/packages/optimizely-cms-sdk/src/graph/index.ts @@ -4,6 +4,12 @@ import { GraphHttpResponseError, GraphResponseError, } from './error.js'; +import { + ContentInput as GraphVariables, + pathFilter, + previewFilter, + variationFilter, +} from './filters.js'; /** Options for Graph */ type GraphOptions = { @@ -19,20 +25,38 @@ export type PreviewParams = { loc: string; }; -// TODO: this type definition is provisional -export type GraphFilter = { - _metadata: { - [key: string]: any; - }; +/** Arguments for the public methods `fetchContent`, `fetchContentType` */ +type FetchContentOptions = { + path?: string; + variation?: string; }; -export type GraphVariables = { - filter: GraphFilter; -}; +/** + * Builds the variables object for a GraphQL query based on the provided options. + * + * If a string is provided, it is treated as a path and passed to `pathFilter`. + * If an object is provided, it may contain `path` and/or `variation` properties, + * which are processed by `pathFilter` and `variationsFilter` respectively. + * + * @param options - Either a string representing the content path, or an object containing fetch options. + * @returns A `GraphVariables` object containing the appropriate filters for the query. + */ +function buildGraphVariables( + options: string | FetchContentOptions +): GraphVariables { + if (typeof options === 'string') { + return pathFilter(options); + } + + return { + ...(options.path && pathFilter(options.path)), + ...(options.variation && variationFilter(options.variation)), + }; +} const FETCH_CONTENT_TYPE_QUERY = ` -query FetchContentType($filter: _ContentWhereInput) { - _Content(where: $filter) { +query FetchContentType($where: _ContentWhereInput, $variation: VariationInput) { + _Content(where: $where, variation: $variation) { item { _metadata { types @@ -42,26 +66,6 @@ query FetchContentType($filter: _ContentWhereInput) { } `; -export function getFilterFromPreviewParams(params: PreviewParams): GraphFilter { - return { - _metadata: { - key: { eq: params.key }, - version: { eq: params.ver }, - locale: { eq: params.loc }, - }, - }; -} - -export function getFilterFromPath(path: string): GraphFilter { - return { - _metadata: { - url: { - default: { eq: path }, - }, - }, - }; -} - /** Adds an extra `__context` property next to each `__typename` property */ function decorateWithContext(obj: any, params: PreviewParams): any { if (Array.isArray(obj)) { @@ -149,57 +153,79 @@ export class GraphClient { return json.data; } - /** Fetches the content type of a content. Returns `undefined` if the content doesn't exist */ - async fetchContentType(filter: GraphFilter, previewToken?: string) { + /** + * Fetches the content type metadata for a given content input. + * + * @param input - The content input used to query the content type. + * @param previewToken - Optional preview token for fetching preview content. + * @returns A promise that resolves to the first content type metadata object, or `undefined` if not found. + */ + private async getContentType(input: GraphVariables, previewToken?: string) { const data = await this.request( FETCH_CONTENT_TYPE_QUERY, - { filter }, + input, previewToken ); return data._Content?.item?._metadata?.types?.[0]; } - /** Fetches a content given its path */ - async fetchContent(path: string) { - const filter = getFilterFromPath(path); - const contentTypeName = await this.fetchContentType(filter); + /** + * Fetches a content type from the CMS using the provided options. + * + * @param options - A string representing the content path, + * or an {@linkcode FetchContentOptions} containing path and variation filters. + * @returns A promise that resolves to the requested content type. + */ + async fetchContentType(options: string | FetchContentOptions) { + let input: GraphVariables = buildGraphVariables(options); + + return this.getContentType(input); + } + + /** + * Fetches content from the CMS based on the provided path or options. + * + * If a string is provided, it is treated as a content path. If an object is provided, + * it may include both a path and a variation to filter the content. + * + * @param options - A string representing the content path, + * or an {@linkcode FetchContentOptions} containing path and variation filters. + * + * @returns A promise that resolves to the fetched content item. + */ + async fetchContent(options: string | FetchContentOptions) { + const input: GraphVariables = buildGraphVariables(options); + const contentTypeName = await this.getContentType(input); if (!contentTypeName) { - throw new GraphResponseError( - `No content found for path [${path}]. Check that your CMS contains something in the given path`, - { request: { variables: { filter }, query: FETCH_CONTENT_TYPE_QUERY } } - ); + throw new GraphResponseError(`No content found.`, { + request: { variables: input, query: FETCH_CONTENT_TYPE_QUERY }, + }); } const query = createQuery(contentTypeName); - - const response = await this.request(query, { filter }); + const response = await this.request(query, input); return response?._Content?.item; } /** Fetches a content given the preview parameters (preview_token, ctx, ver, loc, key) */ async fetchPreviewContent(params: PreviewParams) { - const filter = getFilterFromPreviewParams(params); - const contentTypeName = await this.fetchContentType( - filter, + const input = previewFilter(params); + const contentTypeName = await this.getContentType( + input, params.preview_token ); if (!contentTypeName) { throw new GraphResponseError( `No content found for key [${params.key}]. Check that your CMS contains something there`, - { request: { variables: { filter }, query: FETCH_CONTENT_TYPE_QUERY } } + { request: { variables: input, query: FETCH_CONTENT_TYPE_QUERY } } ); } const query = createQuery(contentTypeName); - - const response = await this.request( - query, - { filter }, - params.preview_token - ); + const response = await this.request(query, input, params.preview_token); return decorateWithContext(response?._Content?.item, params); } diff --git a/packages/optimizely-cms-sdk/src/index.ts b/packages/optimizely-cms-sdk/src/index.ts index 226de4b..c806d5c 100644 --- a/packages/optimizely-cms-sdk/src/index.ts +++ b/packages/optimizely-cms-sdk/src/index.ts @@ -8,7 +8,7 @@ export { initContentTypeRegistry, initDisplayTemplateRegistry, } from './model/index.js'; -export { GraphClient, getFilterFromPath } from './graph/index.js'; +export { GraphClient } from './graph/index.js'; export { createQuery } from './graph/createQuery.js'; export type { PreviewParams } from './graph/index.js'; export { diff --git a/samples/nextjs-template/src/app/json/[...slug]/page.tsx b/samples/nextjs-template/src/app/json/[...slug]/page.tsx index 57caa28..5b8698d 100644 --- a/samples/nextjs-template/src/app/json/[...slug]/page.tsx +++ b/samples/nextjs-template/src/app/json/[...slug]/page.tsx @@ -1,8 +1,4 @@ -import { - getFilterFromPath, - GraphClient, - GraphErrors, -} from '@episerver/cms-sdk'; +import { GraphClient, GraphErrors } from '@episerver/cms-sdk'; import { createQuery } from '@episerver/cms-sdk'; type Props = { @@ -36,7 +32,7 @@ export default async function Page({ params }: Props) { // Note: this is shown for demo purposes. // `fetchContentType` and `createQuery` are not needed const contentType = await client - .fetchContentType(getFilterFromPath(path)) + .fetchContentType(path) .catch(handleGraphErrors); const query = createQuery(contentType); const response = await client.fetchContent(path).catch(handleGraphErrors);