@financial-times/cp-content-pipeline-schema 2.14.2 → 2.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/lib/datasources/capi.d.ts +2 -0
  3. package/lib/datasources/capi.js +17 -1
  4. package/lib/datasources/capi.js.map +1 -1
  5. package/lib/datasources/capi.test.js +6 -1
  6. package/lib/datasources/capi.test.js.map +1 -1
  7. package/lib/datasources/instrumented.js.map +1 -1
  8. package/lib/datasources/origami-image.js.map +1 -1
  9. package/lib/datasources/twitter.js.map +1 -1
  10. package/lib/datasources/url-management.js.map +1 -1
  11. package/lib/fixtures/dummyContext.js.map +1 -1
  12. package/lib/generated/index.d.ts +62 -0
  13. package/lib/helpers/decorateHeadshotUrl.js +1 -1
  14. package/lib/helpers/decorateHeadshotUrl.js.map +1 -1
  15. package/lib/helpers/flatten-formatted-zod-errors.d.ts +6 -0
  16. package/lib/helpers/flatten-formatted-zod-errors.js +39 -0
  17. package/lib/helpers/flatten-formatted-zod-errors.js.map +1 -0
  18. package/lib/helpers/imageService.js +1 -1
  19. package/lib/helpers/imageService.js.map +1 -1
  20. package/lib/helpers/isError.js +1 -1
  21. package/lib/helpers/isError.js.map +1 -1
  22. package/lib/helpers/metadata.js +1 -2
  23. package/lib/helpers/metadata.js.map +1 -1
  24. package/lib/index.d.ts +5 -1
  25. package/lib/index.js.map +1 -1
  26. package/lib/model/Byline.js +1 -1
  27. package/lib/model/Byline.js.map +1 -1
  28. package/lib/model/CapiList.d.ts +17 -0
  29. package/lib/model/CapiList.js +87 -0
  30. package/lib/model/CapiList.js.map +1 -0
  31. package/lib/model/CapiResponse.js +7 -38
  32. package/lib/model/CapiResponse.js.map +1 -1
  33. package/lib/model/Clip.js.map +1 -1
  34. package/lib/model/Concept.js.map +1 -1
  35. package/lib/model/FlourishSource.js.map +1 -1
  36. package/lib/model/Image.d.ts +2 -34
  37. package/lib/model/Image.js +1 -1
  38. package/lib/model/Image.js.map +1 -1
  39. package/lib/model/Person.js.map +1 -1
  40. package/lib/model/Picture.js.map +1 -1
  41. package/lib/model/RichText.d.ts +1 -1
  42. package/lib/model/RichText.js.map +1 -1
  43. package/lib/model/Topper.d.ts +2 -2
  44. package/lib/model/Topper.js.map +1 -1
  45. package/lib/model/schemas/capi/index.js.map +1 -1
  46. package/lib/model/schemas/capi/list.d.ts +48 -0
  47. package/lib/model/schemas/capi/list.js +35 -0
  48. package/lib/model/schemas/capi/list.js.map +1 -0
  49. package/lib/resolvers/clip.d.ts +5 -5
  50. package/lib/resolvers/content-tree/bodyXMLToTree.js +1 -1
  51. package/lib/resolvers/content-tree/bodyXMLToTree.js.map +1 -1
  52. package/lib/resolvers/content-tree/extractText.js +1 -1
  53. package/lib/resolvers/content-tree/extractText.js.map +1 -1
  54. package/lib/resolvers/content-tree/nodePredicates.d.ts +3 -3
  55. package/lib/resolvers/content-tree/nodePredicates.js.map +1 -1
  56. package/lib/resolvers/content-tree/references/ClipSet.d.ts +2 -2
  57. package/lib/resolvers/content-tree/references/ClipSet.js.map +1 -1
  58. package/lib/resolvers/content-tree/references/Flourish.js.map +1 -1
  59. package/lib/resolvers/content-tree/references/RawImage.js.map +1 -1
  60. package/lib/resolvers/content-tree/references/Recommended.js.map +1 -1
  61. package/lib/resolvers/content-tree/references/Reference.d.ts +2 -2
  62. package/lib/resolvers/content-tree/references/Reference.js.map +1 -1
  63. package/lib/resolvers/content-tree/references/Tweet.js.map +1 -1
  64. package/lib/resolvers/content-tree/references/Video.js.map +1 -1
  65. package/lib/resolvers/content-tree/tagMappings.js +5 -5
  66. package/lib/resolvers/content-tree/tagMappings.js.map +1 -1
  67. package/lib/resolvers/content-tree/updateTreeWithReferenceIds.js +1 -1
  68. package/lib/resolvers/content-tree/updateTreeWithReferenceIds.js.map +1 -1
  69. package/lib/resolvers/content.d.ts +8 -8
  70. package/lib/resolvers/content.js.map +1 -1
  71. package/lib/resolvers/core.d.ts +1 -0
  72. package/lib/resolvers/core.js +27 -21
  73. package/lib/resolvers/core.js.map +1 -1
  74. package/lib/resolvers/image.js.map +1 -1
  75. package/lib/resolvers/index.d.ts +48 -36
  76. package/lib/resolvers/index.js +2 -0
  77. package/lib/resolvers/index.js.map +1 -1
  78. package/lib/resolvers/list.d.ts +14 -0
  79. package/lib/resolvers/list.js +16 -0
  80. package/lib/resolvers/list.js.map +1 -0
  81. package/lib/resolvers/literal-union.js.map +1 -1
  82. package/lib/resolvers/meta-link.js.map +1 -1
  83. package/lib/resolvers/richText.d.ts +5 -5
  84. package/lib/resolvers/scalars.d.ts +4 -0
  85. package/lib/resolvers/scalars.js +18 -1
  86. package/lib/resolvers/scalars.js.map +1 -1
  87. package/lib/types/cache.js +1 -2
  88. package/lib/types/cache.js.map +1 -1
  89. package/package.json +1 -1
  90. package/src/datasources/capi.test.ts +6 -1
  91. package/src/datasources/capi.ts +19 -1
  92. package/src/fixtures/dummyContext.ts +1 -1
  93. package/src/generated/index.ts +62 -0
  94. package/src/helpers/flatten-formatted-zod-errors.ts +67 -0
  95. package/src/index.ts +3 -1
  96. package/src/model/CapiList.ts +103 -0
  97. package/src/model/CapiResponse.ts +7 -69
  98. package/src/model/schemas/capi/list.ts +36 -0
  99. package/src/resolvers/core.ts +29 -22
  100. package/src/resolvers/index.ts +2 -0
  101. package/src/resolvers/list.ts +15 -0
  102. package/src/resolvers/scalars.ts +30 -0
  103. package/tsconfig.tsbuildinfo +1 -1
  104. package/typedefs/core.graphql +3 -0
  105. package/typedefs/list.graphql +20 -0
  106. package/typedefs/scalars.graphql +2 -0
@@ -1,6 +1,7 @@
1
1
  import type { GraphQLResolveInfo, GraphQLScalarType, GraphQLScalarTypeConfig } from 'graphql';
2
2
  import type { Concept as ConceptModel } from '../model/Concept';
3
3
  import type { Person as PersonModel } from '../model/Person';
4
+ import type { CapiList } from '../model/CapiList';
4
5
  import type { CapiResponse } from '../model/CapiResponse';
5
6
  import type { Image as ImageModel } from '../model/Image';
6
7
  import type { Clip as ClipModel } from '../model/Clip';
@@ -37,6 +38,8 @@ export type Scalars = {
37
38
  ImageFormat: { input: 'standard' | 'standard-inline' | 'desktop' | 'mobile' | 'wide' | 'square' | 'square-ftedit' | 'portrait' | 'landscape'; output: 'standard' | 'standard-inline' | 'desktop' | 'mobile' | 'wide' | 'square' | 'square-ftedit' | 'portrait' | 'landscape'; }
38
39
  ImageType: { input: 'image' | 'graphic'; output: 'image' | 'graphic'; }
39
40
  JSON: { input: any; output: any; }
41
+ LayoutHint: { input: 'standaloneimage' | 'landscape' | 'bigstory' | 'assassination'; output: 'standaloneimage' | 'landscape' | 'bigstory' | 'assassination'; }
42
+ ListType: { input: 'OpinionAnalysis' | 'Promotional' | 'Recommended' | 'TopStories' | 'TopStoriesBeta' | 'KeyDevelopments'; output: 'OpinionAnalysis' | 'Promotional' | 'Recommended' | 'TopStories' | 'TopStoriesBeta' | 'KeyDevelopments'; }
40
43
  PackageDesign: { input: 'special-report' | 'extra' | 'basic' | 'extra-wide'; output: 'special-report' | 'extra' | 'basic' | 'extra-wide'; }
41
44
  RichTextSource: { input: 'standfirst' | 'summary' | 'bodyXML' | 'transcript'; output: 'standfirst' | 'summary' | 'bodyXML' | 'transcript'; }
42
45
  TopperBackgroundColour: { input: 'paper' | 'wheat' | 'white' | 'black' | 'claret' | 'oxford' | 'slate' | 'crimson' | 'sky' | 'matisse'; output: 'paper' | 'wheat' | 'white' | 'black' | 'claret' | 'oxford' | 'slate' | 'crimson' | 'sky' | 'matisse'; }
@@ -942,6 +945,27 @@ export type LeadFlourish = {
942
945
  readonly type?: Maybe<Scalars['String']['output']>;
943
946
  };
944
947
 
948
+ export type List = {
949
+ /** The upstream API URL for the list */
950
+ readonly apiUrl: Scalars['String']['output'];
951
+ /** The UUID of the list */
952
+ readonly id: Scalars['String']['output'];
953
+ /** The content items referenced by this list */
954
+ readonly items: ReadonlyArray<Content>;
955
+ /** Used by ft.com to determine the visual style to use on the website */
956
+ readonly layoutHint?: Maybe<Scalars['LayoutHint']['output']>;
957
+ /** The type of the list, e.g. Recommended */
958
+ readonly listType: Scalars['ListType']['output'];
959
+ /** Array of publication IDs this list pertains to, e.g. 88fdde6c-2aa4-4f78-af02-9f680097cfd6 for FT Pink */
960
+ readonly publication?: Maybe<ReadonlyArray<Scalars['String']['output']>>;
961
+ /** A string ID used to trace content publishes through the pipeline, e.g. 'republish_tid_JNhUrBjdXo'. */
962
+ readonly publishReference?: Maybe<Scalars['String']['output']>;
963
+ /** The date when this list was published */
964
+ readonly publishedDate: Scalars['String']['output'];
965
+ /** The title of the list */
966
+ readonly title: Scalars['String']['output'];
967
+ };
968
+
945
969
  export type LiveBlogPackage = Content & {
946
970
  /** A scalar representing the access level of the article, eg. 'free'. */
947
971
  readonly accessLevel?: Maybe<Scalars['AccessLevel']['output']>;
@@ -1359,6 +1383,8 @@ export type Query = {
1359
1383
  readonly content: Content;
1360
1384
  /** The article content as resolved from passed-in content-api json data. */
1361
1385
  readonly contentFromJSON: Content;
1386
+ /** Resolve a content list from a uuid. */
1387
+ readonly list: List;
1362
1388
  /** The version of the schema, eg. 2.0.0. */
1363
1389
  readonly version: Scalars['String']['output'];
1364
1390
  };
@@ -1373,6 +1399,11 @@ export type QueryContentFromJsonArgs = {
1373
1399
  content: Scalars['JSON']['input'];
1374
1400
  };
1375
1401
 
1402
+
1403
+ export type QueryListArgs = {
1404
+ uuid: Scalars['String']['input'];
1405
+ };
1406
+
1376
1407
  export type RawImage = Reference & {
1377
1408
  /** The raw image object. */
1378
1409
  readonly image: Image;
@@ -1793,8 +1824,11 @@ export type ResolversTypes = ResolversObject<{
1793
1824
  Indicators: ResolverTypeWrapper<Indicators>;
1794
1825
  Int: ResolverTypeWrapper<Scalars['Int']['output']>;
1795
1826
  JSON: ResolverTypeWrapper<Scalars['JSON']['output']>;
1827
+ LayoutHint: ResolverTypeWrapper<Scalars['LayoutHint']['output']>;
1796
1828
  LayoutImage: ResolverTypeWrapper<ReferenceWithCAPIData<ContentTree.LayoutImage>>;
1797
1829
  LeadFlourish: ResolverTypeWrapper<LeadFlourishModel>;
1830
+ List: ResolverTypeWrapper<CapiList>;
1831
+ ListType: ResolverTypeWrapper<Scalars['ListType']['output']>;
1798
1832
  LiveBlogPackage: ResolverTypeWrapper<CapiResponse>;
1799
1833
  LiveBlogPost: ResolverTypeWrapper<CapiResponse>;
1800
1834
  MainImage: ResolverTypeWrapper<ReferenceWithCAPIData<ContentTree.ImageSet>>;
@@ -1884,8 +1918,11 @@ export type ResolversParentTypes = ResolversObject<{
1884
1918
  Indicators: Indicators;
1885
1919
  Int: Scalars['Int']['output'];
1886
1920
  JSON: Scalars['JSON']['output'];
1921
+ LayoutHint: Scalars['LayoutHint']['output'];
1887
1922
  LayoutImage: ReferenceWithCAPIData<ContentTree.LayoutImage>;
1888
1923
  LeadFlourish: LeadFlourishModel;
1924
+ List: CapiList;
1925
+ ListType: Scalars['ListType']['output'];
1889
1926
  LiveBlogPackage: CapiResponse;
1890
1927
  LiveBlogPost: CapiResponse;
1891
1928
  MainImage: ReferenceWithCAPIData<ContentTree.ImageSet>;
@@ -2426,6 +2463,10 @@ export interface JsonScalarConfig extends GraphQLScalarTypeConfig<ResolversTypes
2426
2463
  name: 'JSON';
2427
2464
  }
2428
2465
 
2466
+ export interface LayoutHintScalarConfig extends GraphQLScalarTypeConfig<ResolversTypes['LayoutHint'], any> {
2467
+ name: 'LayoutHint';
2468
+ }
2469
+
2429
2470
  export type LayoutImageResolvers<ContextType = QueryContext, ParentType extends ResolversParentTypes['LayoutImage'] = ResolversParentTypes['LayoutImage']> = ResolversObject<{
2430
2471
  picture: Resolver<Maybe<ResolversTypes['Picture']>, ParentType, ContextType>;
2431
2472
  type: Resolver<ResolversTypes['String'], ParentType, ContextType>;
@@ -2440,6 +2481,23 @@ export type LeadFlourishResolvers<ContextType = QueryContext, ParentType extends
2440
2481
  __isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
2441
2482
  }>;
2442
2483
 
2484
+ export type ListResolvers<ContextType = QueryContext, ParentType extends ResolversParentTypes['List'] = ResolversParentTypes['List']> = ResolversObject<{
2485
+ apiUrl: Resolver<ResolversTypes['String'], ParentType, ContextType>;
2486
+ id: Resolver<ResolversTypes['String'], ParentType, ContextType>;
2487
+ items: Resolver<ReadonlyArray<ResolversTypes['Content']>, ParentType, ContextType>;
2488
+ layoutHint: Resolver<Maybe<ResolversTypes['LayoutHint']>, ParentType, ContextType>;
2489
+ listType: Resolver<ResolversTypes['ListType'], ParentType, ContextType>;
2490
+ publication: Resolver<Maybe<ReadonlyArray<ResolversTypes['String']>>, ParentType, ContextType>;
2491
+ publishReference: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
2492
+ publishedDate: Resolver<ResolversTypes['String'], ParentType, ContextType>;
2493
+ title: Resolver<ResolversTypes['String'], ParentType, ContextType>;
2494
+ __isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
2495
+ }>;
2496
+
2497
+ export interface ListTypeScalarConfig extends GraphQLScalarTypeConfig<ResolversTypes['ListType'], any> {
2498
+ name: 'ListType';
2499
+ }
2500
+
2443
2501
  export type LiveBlogPackageResolvers<ContextType = QueryContext, ParentType extends ResolversParentTypes['LiveBlogPackage'] = ResolversParentTypes['LiveBlogPackage']> = ResolversObject<{
2444
2502
  accessLevel: Resolver<Maybe<ResolversTypes['AccessLevel']>, ParentType, ContextType>;
2445
2503
  altStandfirst: Resolver<Maybe<ResolversTypes['AltStandfirst']>, ParentType, ContextType>;
@@ -2660,6 +2718,7 @@ export type PodcastTopperResolvers<ContextType = QueryContext, ParentType extend
2660
2718
  export type QueryResolvers<ContextType = QueryContext, ParentType extends ResolversParentTypes['Query'] = ResolversParentTypes['Query']> = ResolversObject<{
2661
2719
  content: Resolver<ResolversTypes['Content'], ParentType, ContextType, RequireFields<QueryContentArgs, 'uuid'>>;
2662
2720
  contentFromJSON: Resolver<ResolversTypes['Content'], ParentType, ContextType, RequireFields<QueryContentFromJsonArgs, 'content'>>;
2721
+ list: Resolver<ResolversTypes['List'], ParentType, ContextType, RequireFields<QueryListArgs, 'uuid'>>;
2663
2722
  version: Resolver<ResolversTypes['String'], ParentType, ContextType>;
2664
2723
  }>;
2665
2724
 
@@ -2892,8 +2951,11 @@ export type Resolvers<ContextType = QueryContext> = ResolversObject<{
2892
2951
  ImageWide: ImageWideResolvers<ContextType>;
2893
2952
  Indicators: IndicatorsResolvers<ContextType>;
2894
2953
  JSON: GraphQLScalarType;
2954
+ LayoutHint: GraphQLScalarType;
2895
2955
  LayoutImage: LayoutImageResolvers<ContextType>;
2896
2956
  LeadFlourish: LeadFlourishResolvers<ContextType>;
2957
+ List: ListResolvers<ContextType>;
2958
+ ListType: GraphQLScalarType;
2897
2959
  LiveBlogPackage: LiveBlogPackageResolvers<ContextType>;
2898
2960
  LiveBlogPost: LiveBlogPostResolvers<ContextType>;
2899
2961
  MainImage: MainImageResolvers<ContextType>;
@@ -0,0 +1,67 @@
1
+ function isPlainObject(object: unknown): object is Record<string, unknown> {
2
+ if (object && typeof object === 'object') {
3
+ return true
4
+ }
5
+
6
+ return false
7
+ }
8
+
9
+ type FormattedZodIssues = {
10
+ _errors: string[]
11
+ [key: string]: FormattedZodIssues | string[]
12
+ }
13
+
14
+ export default function flattenFormattedZodIssues(
15
+ issues: FormattedZodIssues,
16
+ object: unknown,
17
+ path: string[] = [],
18
+ flattened: string[] = []
19
+ ): string[] {
20
+ if (issues._errors.length > 0) {
21
+ const literalErrors = issues._errors.filter((error) =>
22
+ error.startsWith('Invalid literal value')
23
+ )
24
+ const otherErrors = issues._errors.filter(
25
+ (error) => !error.startsWith('Invalid literal value')
26
+ )
27
+
28
+ if (literalErrors.length > 0) {
29
+ flattened.push(
30
+ `${path.join(
31
+ '.'
32
+ )}: Invalid literal value, received "${object}", expected one of ` +
33
+ literalErrors
34
+ .flatMap((error) => {
35
+ const match = error.match(/expected (".+")$/)
36
+ if (match && match[1]) {
37
+ return [match[1]]
38
+ }
39
+
40
+ return []
41
+ })
42
+ .join()
43
+ )
44
+ }
45
+
46
+ flattened.push(...otherErrors.map((error) => `${path.join('.')}: ${error}`))
47
+ }
48
+
49
+ for (const [key, value] of Object.entries(issues)) {
50
+ if (
51
+ key !== '_errors' &&
52
+ !Array.isArray(value) &&
53
+ object &&
54
+ (Array.isArray(object) || isPlainObject(object))
55
+ ) {
56
+ const isArrayKey = !Number.isNaN(parseInt(key))
57
+ flattenFormattedZodIssues(
58
+ value,
59
+ Array.isArray(object) ? object[parseInt(key)] : object[key],
60
+ [...path, isArrayKey ? '[]' : key],
61
+ flattened
62
+ )
63
+ }
64
+ }
65
+
66
+ return flattened
67
+ }
package/src/index.ts CHANGED
@@ -17,6 +17,8 @@ export type BodyXMLToTreeError = {
17
17
  actual: AnyNode['type'] | AnyNode['type'][]
18
18
  }
19
19
 
20
+ export type SurrogateKey = { prefix: string; id: string }
21
+
20
22
  export interface QueryContext {
21
23
  redisAdapter: KeyValueCache
22
24
  metrics: Metrics
@@ -30,7 +32,7 @@ export interface QueryContext {
30
32
  schema: string
31
33
  }
32
34
  aggregatedErrors?: { bodyXMLToTree: BodyXMLToTreeError[] }
33
- addSurrogateKeys(keys: string[]): void
35
+ addSurrogateKeys(keys: SurrogateKey[]): void
34
36
  }
35
37
 
36
38
  export const articleDocumentQuery = readFileSync(
@@ -0,0 +1,103 @@
1
+ import { BaseError, OperationalError } from '@dotcom-reliability-kit/errors'
2
+ import { QueryContext } from '..'
3
+ import { uuidFromUrl } from '../helpers/metadata'
4
+ import { List, listSchema } from './schemas/capi/list'
5
+ import flattenFormattedZodIssues from '../helpers/flatten-formatted-zod-errors'
6
+
7
+ export class CapiList {
8
+ static fromJSON(list: unknown, context: QueryContext): CapiList {
9
+ const result = listSchema.safeParse(list)
10
+
11
+ if (!result.success) {
12
+ context.logger.warn({
13
+ event: 'RECOVERABLE_ERROR',
14
+ error: new OperationalError({
15
+ message:
16
+ 'The data received from the CAPI data source does not match our data source schema. It is likely that our schema will require updating to handle all possible responses from CAPI.',
17
+ code: 'CAPI_SCHEMA_VALIDATION_FAILURE',
18
+ schemaError: flattenFormattedZodIssues(result.error.format(), list),
19
+ contentId: (list as { id?: string }).id,
20
+ contentType: 'List',
21
+ }),
22
+ })
23
+ context.metrics?.count(
24
+ `graphql.datasource.CapiDataSource.List.validation.failure.count`,
25
+ 1
26
+ )
27
+ } else {
28
+ context.metrics?.count(
29
+ `graphql.datasource.CapiDataSource.List.validation.success.count`,
30
+ 1
31
+ )
32
+ }
33
+
34
+ return new CapiList(list as List, context)
35
+ }
36
+
37
+ constructor(private list: List, private context: QueryContext) {}
38
+
39
+ async items() {
40
+ if (!this.list.items) return []
41
+
42
+ const items = await Promise.all(
43
+ this.list.items.map(({ id }) =>
44
+ this.context.dataSources.capi
45
+ .getContent(uuidFromUrl(id))
46
+ .catch((error) => {
47
+ if (error instanceof BaseError) {
48
+ if (error.data.upstreamStatusCode === 404) {
49
+ this.context.logger.warn({
50
+ event: 'RECOVERABLE_ERROR',
51
+ error: new OperationalError({
52
+ code: 'LIST_CONTENT_NOT_FOUND',
53
+ message: 'List content not found in Content API',
54
+ uuid: uuidFromUrl(error.data.upstreamUrl),
55
+ cause: error,
56
+ }),
57
+ })
58
+
59
+ return null
60
+ }
61
+ }
62
+
63
+ throw error
64
+ })
65
+ )
66
+ )
67
+
68
+ // filter out missing list items to match n-lists-client behaviour
69
+ return items.filter((item) => item !== null)
70
+ }
71
+
72
+ id() {
73
+ return uuidFromUrl(this.list.id)
74
+ }
75
+
76
+ apiUrl() {
77
+ return this.list.apiUrl
78
+ }
79
+
80
+ title() {
81
+ return this.list.title
82
+ }
83
+
84
+ listType() {
85
+ return this.list.listType
86
+ }
87
+
88
+ publishedDate() {
89
+ return this.list.publishedDate
90
+ }
91
+
92
+ layoutHint() {
93
+ return this.list.layoutHint ?? null
94
+ }
95
+
96
+ publication() {
97
+ return this.list.publication ?? null
98
+ }
99
+
100
+ publishReference() {
101
+ return this.list.publishReference ?? null
102
+ }
103
+ }
@@ -39,25 +39,13 @@ import { Topper } from './Topper'
39
39
  import { z } from 'zod'
40
40
  import { Byline } from './Byline'
41
41
  import { RichText } from './RichText'
42
-
43
- function isPlainObject(object: unknown): object is Record<string, unknown> {
44
- if (object && typeof object === 'object') {
45
- return true
46
- }
47
-
48
- return false
49
- }
42
+ import flattenFormattedZodIssues from '../helpers/flatten-formatted-zod-errors'
50
43
 
51
44
  type Design = {
52
45
  theme: LiteralUnionScalarValues<typeof PackageDesign>
53
46
  layout?: 'default' | 'wide'
54
47
  }
55
48
 
56
- type FormattedZodIssues = {
57
- _errors: string[]
58
- [key: string]: FormattedZodIssues | string[]
59
- }
60
-
61
49
  type ZodInputValue =
62
50
  | ZodInputObject
63
51
  | ZodInputValue[]
@@ -71,61 +59,6 @@ type ZodInputObject = {
71
59
  [key: string]: ZodInputValue
72
60
  }
73
61
 
74
- function flattenFormattedZodIssues(
75
- issues: FormattedZodIssues,
76
- object: unknown,
77
- path: string[] = [],
78
- flattened: string[] = []
79
- ): string[] {
80
- if (issues._errors.length > 0) {
81
- const literalErrors = issues._errors.filter((error) =>
82
- error.startsWith('Invalid literal value')
83
- )
84
- const otherErrors = issues._errors.filter(
85
- (error) => !error.startsWith('Invalid literal value')
86
- )
87
-
88
- if (literalErrors.length > 0) {
89
- flattened.push(
90
- `${path.join(
91
- '.'
92
- )}: Invalid literal value, received "${object}", expected one of ` +
93
- literalErrors
94
- .flatMap((error) => {
95
- const match = error.match(/expected (".+")$/)
96
- if (match && match[1]) {
97
- return [match[1]]
98
- }
99
-
100
- return []
101
- })
102
- .join()
103
- )
104
- }
105
-
106
- flattened.push(...otherErrors.map((error) => `${path.join('.')}: ${error}`))
107
- }
108
-
109
- for (const [key, value] of Object.entries(issues)) {
110
- if (
111
- key !== '_errors' &&
112
- !Array.isArray(value) &&
113
- object &&
114
- (Array.isArray(object) || isPlainObject(object))
115
- ) {
116
- const isArrayKey = !Number.isNaN(parseInt(key))
117
- flattenFormattedZodIssues(
118
- value,
119
- Array.isArray(object) ? object[parseInt(key)] : object[key],
120
- [...path, isArrayKey ? '[]' : key],
121
- flattened
122
- )
123
- }
124
- }
125
-
126
- return flattened
127
- }
128
-
129
62
  function getContentType(
130
63
  content: BaselineContent,
131
64
  validate?: true
@@ -730,7 +663,12 @@ export class CapiResponse {
730
663
  const contains = await this.contains()
731
664
 
732
665
  if (contains && contains.length) {
733
- this.context.addSurrogateKeys(contains.map((article) => article.id()))
666
+ this.context.addSurrogateKeys(
667
+ contains.map((article) => ({
668
+ prefix: 'contentPipelineArticle',
669
+ id: article.id(),
670
+ }))
671
+ )
734
672
 
735
673
  const liveBlogPosts = contains.sort(
736
674
  (a, b) => b.publishedTimestamp() - a.publishedTimestamp()
@@ -0,0 +1,36 @@
1
+ import { z } from 'zod'
2
+
3
+ export const listSchema = z.object({
4
+ id: z.string(),
5
+ apiUrl: z.string(),
6
+ title: z.string(),
7
+ items: z
8
+ .array(
9
+ z.object({
10
+ id: z.string(),
11
+ apiUrl: z.string(),
12
+ })
13
+ )
14
+ .optional(),
15
+ listType: z.union([
16
+ z.literal('OpinionAnalysis'),
17
+ z.literal('Promotional'),
18
+ z.literal('Recommended'),
19
+ z.literal('TopStories'),
20
+ z.literal('TopStoriesBeta'),
21
+ z.literal('KeyDevelopments'),
22
+ ]),
23
+ publishedDate: z.string(),
24
+ layoutHint: z
25
+ .union([
26
+ z.literal('standaloneimage'),
27
+ z.literal('landscape'),
28
+ z.literal('bigstory'),
29
+ z.literal('assassination'),
30
+ ])
31
+ .optional(),
32
+ publication: z.array(z.string()).optional(),
33
+ publishReference: z.string().optional(),
34
+ })
35
+
36
+ export type List = z.input<typeof listSchema>
@@ -10,6 +10,31 @@ const packageJson = JSON.parse(
10
10
 
11
11
  export const version = packageJson.version
12
12
 
13
+ function handleContentError(error: unknown, uuid: string): Error | unknown {
14
+ if (error instanceof BaseError) {
15
+ if (error.data.upstreamStatusCode === 404) {
16
+ return new HttpError({
17
+ code: 'CONTENT_NOT_FOUND',
18
+ message: 'Content not found in Content API',
19
+ cause: error,
20
+ uuid,
21
+ statusCode: 404,
22
+ relatesToSystems: ['up-ica'],
23
+ })
24
+ }
25
+ if (error.code === 'UNEXPECTED_CONTENT_TYPE') {
26
+ throw new HttpError({
27
+ code: error.code,
28
+ cause: error,
29
+ message: `Requested a content type we don't handle`,
30
+ statusCode: 404,
31
+ })
32
+ }
33
+ }
34
+
35
+ return error
36
+ }
37
+
13
38
  const resolvers = {
14
39
  Query: {
15
40
  version: () => version,
@@ -17,30 +42,12 @@ const resolvers = {
17
42
  try {
18
43
  return await context.dataSources.capi.getContent(args.uuid)
19
44
  } catch (error) {
20
- if (error instanceof BaseError) {
21
- if (error.data.upstreamStatusCode === 404) {
22
- throw new HttpError({
23
- code: 'CONTENT_NOT_FOUND',
24
- message: 'Content not found in Content API',
25
- cause: error,
26
- uuid: args.uuid,
27
- statusCode: 404,
28
- relatesToSystems: ['up-ica'],
29
- })
30
- }
31
- if (error.code === 'UNEXPECTED_CONTENT_TYPE') {
32
- throw new HttpError({
33
- code: error.code,
34
- cause: error,
35
- message: `Requested a content type we don't handle`,
36
- statusCode: 404,
37
- })
38
- }
39
- }
40
-
41
- throw error
45
+ throw handleContentError(error, args.uuid)
42
46
  }
43
47
  },
48
+ list(_, args, context) {
49
+ return context.dataSources.capi.getList(args.uuid)
50
+ },
44
51
  contentFromJSON(_, { content }, context) {
45
52
  return CapiResponse.fromJSON(content, context)
46
53
  },
@@ -11,6 +11,7 @@ import { default as teaser } from './teaser'
11
11
  import { default as topper } from './topper'
12
12
  import { default as person } from './person'
13
13
  import { default as leadFlourish } from './leadFlourish'
14
+ import { default as list } from './list'
14
15
  import { resolvers as references } from './content-tree/references'
15
16
  import { Resolvers } from '../generated'
16
17
 
@@ -29,6 +30,7 @@ const resolvers = {
29
30
  ...topper,
30
31
  ...person,
31
32
  ...leadFlourish,
33
+ ...list,
32
34
  } satisfies Resolvers
33
35
 
34
36
  export default resolvers
@@ -0,0 +1,15 @@
1
+ import { ListResolvers } from '../generated'
2
+
3
+ export default {
4
+ List: {
5
+ id: (parent) => parent.id(),
6
+ apiUrl: (parent) => parent.apiUrl(),
7
+ title: (parent) => parent.title(),
8
+ items: (parent) => parent.items(),
9
+ listType: (parent) => parent.listType(),
10
+ publishedDate: (parent) => parent.publishedDate(),
11
+ layoutHint: (parent) => parent.layoutHint(),
12
+ publication: (parent) => parent.publication(),
13
+ publishReference: (parent) => parent.publishReference(),
14
+ },
15
+ } satisfies { List: ListResolvers }
@@ -145,6 +145,34 @@ export const ContentType = new LiteralUnionScalar<
145
145
  ],
146
146
  })
147
147
 
148
+ export const ListType = new LiteralUnionScalar<
149
+ [
150
+ 'OpinionAnalysis',
151
+ 'Promotional',
152
+ 'Recommended',
153
+ 'TopStories',
154
+ 'TopStoriesBeta',
155
+ 'KeyDevelopments'
156
+ ]
157
+ >({
158
+ name: 'ListType',
159
+ values: [
160
+ 'OpinionAnalysis',
161
+ 'Promotional',
162
+ 'Recommended',
163
+ 'TopStories',
164
+ 'TopStoriesBeta',
165
+ 'KeyDevelopments',
166
+ ],
167
+ })
168
+
169
+ export const LayoutHint = new LiteralUnionScalar<
170
+ ['standaloneimage', 'landscape', 'bigstory', 'assassination']
171
+ >({
172
+ name: 'LayoutHint',
173
+ values: ['standaloneimage', 'landscape', 'bigstory', 'assassination'],
174
+ })
175
+
148
176
  const resolvers = {
149
177
  ClipFormat,
150
178
  ImageFormat,
@@ -156,6 +184,8 @@ const resolvers = {
156
184
  ImageType,
157
185
  RichTextSource,
158
186
  ContentType,
187
+ ListType,
188
+ LayoutHint,
159
189
  JSON: new GraphQLScalarType({ name: 'JSON' }),
160
190
  }
161
191