@financial-times/cp-content-pipeline-schema 3.4.0 → 3.5.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 (76) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/lib/datasources/capi.d.ts +0 -2
  3. package/lib/datasources/capi.js +10 -23
  4. package/lib/datasources/capi.js.map +1 -1
  5. package/lib/datasources/instrumented.js +4 -1
  6. package/lib/datasources/instrumented.js.map +1 -1
  7. package/lib/datasources/origami-image.d.ts +0 -2
  8. package/lib/datasources/origami-image.js +5 -19
  9. package/lib/datasources/origami-image.js.map +1 -1
  10. package/lib/datasources/twitter.d.ts +0 -2
  11. package/lib/datasources/twitter.js +2 -16
  12. package/lib/datasources/twitter.js.map +1 -1
  13. package/lib/datasources/url-management.js +5 -3
  14. package/lib/datasources/url-management.js.map +1 -1
  15. package/lib/fixtures/dummyContext.js +1 -1
  16. package/lib/generated/index.d.ts +69 -1
  17. package/lib/index.d.ts +1 -0
  18. package/lib/index.js.map +1 -1
  19. package/lib/model/Byline.js +11 -14
  20. package/lib/model/Byline.js.map +1 -1
  21. package/lib/model/CapiList.js +2 -0
  22. package/lib/model/CapiList.js.map +1 -1
  23. package/lib/model/CapiResponse.d.ts +7 -3
  24. package/lib/model/CapiResponse.js +125 -57
  25. package/lib/model/CapiResponse.js.map +1 -1
  26. package/lib/model/CapiResponse.test.js +92 -7
  27. package/lib/model/CapiResponse.test.js.map +1 -1
  28. package/lib/model/Clip.js +2 -0
  29. package/lib/model/Clip.js.map +1 -1
  30. package/lib/model/Concept.js +4 -10
  31. package/lib/model/Concept.js.map +1 -1
  32. package/lib/model/FlourishSource.js +5 -10
  33. package/lib/model/FlourishSource.js.map +1 -1
  34. package/lib/model/Image.js +10 -20
  35. package/lib/model/Image.js.map +1 -1
  36. package/lib/model/LeadFlourish.js +4 -10
  37. package/lib/model/LeadFlourish.js.map +1 -1
  38. package/lib/model/Person.js +5 -16
  39. package/lib/model/Person.js.map +1 -1
  40. package/lib/model/Picture.js +3 -0
  41. package/lib/model/Picture.js.map +1 -1
  42. package/lib/model/RichText.js +3 -0
  43. package/lib/model/RichText.js.map +1 -1
  44. package/lib/model/Topper.js +5 -16
  45. package/lib/model/Topper.js.map +1 -1
  46. package/lib/model/schemas/capi/internal-content.d.ts +1 -1
  47. package/lib/resolvers/content-tree/references/RawImage.js +2 -0
  48. package/lib/resolvers/content-tree/references/RawImage.js.map +1 -1
  49. package/lib/resolvers/content.d.ts +26 -1
  50. package/lib/resolvers/content.js +21 -1
  51. package/lib/resolvers/content.js.map +1 -1
  52. package/lib/resolvers/index.d.ts +26 -1
  53. package/lib/resolvers/literal-union.js +1 -0
  54. package/lib/resolvers/literal-union.js.map +1 -1
  55. package/lib/types/connection.d.ts +21 -0
  56. package/lib/types/connection.js +5 -0
  57. package/lib/types/connection.js.map +1 -0
  58. package/package.json +1 -1
  59. package/queries/article.graphql +8 -2
  60. package/src/datasources/capi.ts +1 -14
  61. package/src/datasources/origami-image.ts +1 -13
  62. package/src/datasources/twitter.ts +1 -14
  63. package/src/fixtures/dummyContext.ts +1 -1
  64. package/src/generated/index.ts +71 -1
  65. package/src/index.ts +1 -0
  66. package/src/model/CapiResponse.test.ts +137 -7
  67. package/src/model/CapiResponse.ts +129 -37
  68. package/src/model/schemas/capi/internal-content.ts +1 -1
  69. package/src/resolvers/content.ts +31 -1
  70. package/src/types/connection.ts +28 -0
  71. package/tsconfig.tsbuildinfo +1 -1
  72. package/typedefs/content.graphql +40 -2
  73. package/lib/helpers/timeout-error.d.ts +0 -6
  74. package/lib/helpers/timeout-error.js +0 -15
  75. package/lib/helpers/timeout-error.js.map +0 -1
  76. package/src/helpers/timeout-error.ts +0 -13
@@ -4,6 +4,7 @@ import type {
4
4
  MainImage,
5
5
  ClipSet,
6
6
  CustomCodeComponentReference,
7
+ LiveBlogPackage,
7
8
  } from './schemas/capi/internal-content'
8
9
  import conceptIds from '@financial-times/n-concept-ids'
9
10
  import metadata from '@financial-times/n-display-metadata'
@@ -34,6 +35,7 @@ import {
34
35
  ContentAnnotationsArgs,
35
36
  ContentPackageContainsArgs,
36
37
  ContentUrlArgs,
38
+ LiveBlogPackageLiveBlogPostsArgs,
37
39
  Media,
38
40
  TableOfContents,
39
41
  } from '../generated'
@@ -42,6 +44,7 @@ import { z } from 'zod'
42
44
  import { Byline } from './Byline'
43
45
  import { RichText } from './RichText'
44
46
  import flattenFormattedZodIssues from '../helpers/flatten-formatted-zod-errors'
47
+ import { Connection, ConnectionArguments } from '../types/connection'
45
48
 
46
49
  type Design = {
47
50
  theme: LiteralUnionScalarValues<typeof PackageDesign>
@@ -708,21 +711,54 @@ export class CapiResponse {
708
711
  surroundingArticles,
709
712
  fromId,
710
713
  }: Partial<ContentPackageContainsArgs> = {}): Promise<CapiResponse[] | null> {
714
+ let edges
715
+ if (fromId && surroundingArticles) {
716
+ const afterId = await this.containsConnection({
717
+ after: fromId,
718
+ first: surroundingArticles,
719
+ })
720
+ const beforeAndIncludingId = await this.containsConnection({
721
+ before: afterId.pageInfo.startCursor,
722
+ last: surroundingArticles + 1,
723
+ })
724
+ edges = afterId.edges.concat(beforeAndIncludingId.edges)
725
+ } else {
726
+ const connection = await this.containsConnection()
727
+ edges = connection.edges
728
+ }
729
+ return edges.map((edge) => edge.node)
730
+ }
731
+
732
+ async containsConnection(
733
+ { before, after, first, last }: ConnectionArguments = {},
734
+ transformer?: (
735
+ contains: LiveBlogPackage['contains']
736
+ ) => LiveBlogPackage['contains']
737
+ ): Promise<Connection<CapiResponse>> {
711
738
  if ('contains' in this.capiData) {
712
- const fromIndex = this.capiData.contains.findIndex(
713
- (contains) => contains.id === `http://api.ft.com/things/${fromId}`
739
+ const { contains: containsUntransformed } = this.capiData
740
+ const contains = transformer
741
+ ? transformer(containsUntransformed)
742
+ : containsUntransformed
743
+
744
+ const beforeIndex = before
745
+ ? contains.findLastIndex(
746
+ (post) => post.id === `http://api.ft.com/things/${before}`
747
+ )
748
+ : -1
749
+ const afterIndex = after
750
+ ? contains.findIndex(
751
+ (post) => post.id === `http://api.ft.com/things/${after}`
752
+ )
753
+ : -1
754
+ const cursoredContains = contains.slice(
755
+ afterIndex >= 0 ? afterIndex + 1 : undefined,
756
+ beforeIndex >= 0 ? beforeIndex : undefined
714
757
  )
715
-
716
- const contains =
717
- fromId && surroundingArticles
718
- ? this.capiData.contains.slice(
719
- Math.max(0, fromIndex - surroundingArticles),
720
- fromIndex + surroundingArticles
721
- )
722
- : this.capiData.contains
758
+ const slicedContains = cursoredContains.slice(-(last ?? 0), first)
723
759
 
724
760
  const results = await Promise.allSettled(
725
- contains.map(({ id }) =>
761
+ slicedContains.map(({ id }) =>
726
762
  this.context.dataSources.capi.getContent(uuidFromUrl(id), this)
727
763
  )
728
764
  )
@@ -741,17 +777,49 @@ export class CapiResponse {
741
777
  cause: new AggregateError(failed.map((result) => result.reason)),
742
778
  }),
743
779
  })
780
+
781
+ if (
782
+ failed.some(
783
+ (result) =>
784
+ (result.reason as OperationalError).code === 'FETCH_TIMEOUT_ERROR'
785
+ )
786
+ ) {
787
+ this.context.contentTimedOut = true
788
+ }
744
789
  }
745
790
 
746
- return results
791
+ const edges = results
747
792
  .filter(
748
793
  (result): result is PromiseFulfilledResult<CapiResponse> =>
749
794
  result.status === 'fulfilled'
750
795
  )
751
- .map((result) => result.value)
796
+ .map((result) => {
797
+ const node = result.value
798
+ return { node, cursor: node.id() }
799
+ })
800
+
801
+ return {
802
+ edges,
803
+ pageInfo: {
804
+ hasPreviousPage: last
805
+ ? cursoredContains.length > last
806
+ : afterIndex > 0,
807
+ hasNextPage: first
808
+ ? cursoredContains.length > first
809
+ : beforeIndex > 0,
810
+ startCursor: edges[0]?.cursor,
811
+ endCursor: edges[edges.length - 1]?.cursor,
812
+ },
813
+ }
752
814
  }
753
815
 
754
- return null
816
+ return {
817
+ edges: [],
818
+ pageInfo: {
819
+ hasPreviousPage: false,
820
+ hasNextPage: false,
821
+ },
822
+ }
755
823
  }
756
824
 
757
825
  containsLength() {
@@ -768,25 +836,37 @@ export class CapiResponse {
768
836
  : null
769
837
  }
770
838
 
771
- async liveBlogPosts(): Promise<CapiResponse[] | []> {
772
- const contains = await this.contains()
839
+ private handleLiveBlogPosts(liveBlogPosts: LiveBlogPackage['contains']) {
840
+ this.context.addSurrogateKeys(
841
+ liveBlogPosts.map((article) => ({
842
+ prefix: 'contentPipelineArticle',
843
+ id: article.id,
844
+ }))
845
+ )
773
846
 
774
- if (contains && contains.length) {
775
- this.context.addSurrogateKeys(
776
- contains.map((article) => ({
777
- prefix: 'contentPipelineArticle',
778
- id: article.id(),
779
- }))
780
- )
847
+ return [...liveBlogPosts].reverse()
848
+ }
781
849
 
782
- const liveBlogPosts = contains.sort(
783
- (a, b) => b.publishedTimestamp() - a.publishedTimestamp()
784
- )
850
+ async liveBlogPosts(
851
+ args?: LiveBlogPackageLiveBlogPostsArgs
852
+ ): Promise<CapiResponse[] | []> {
853
+ const containsConnections = await this.containsConnection(
854
+ {
855
+ first: args?.count ?? undefined,
856
+ },
857
+ this.handleLiveBlogPosts.bind(this)
858
+ )
859
+ const contains = containsConnections.edges.map((edge) => edge.node)
785
860
 
786
- return liveBlogPosts
787
- }
861
+ return contains.sort(
862
+ (a, b) => b.publishedTimestamp() - a.publishedTimestamp()
863
+ )
864
+ }
788
865
 
789
- return []
866
+ async liveBlogPostsConnection(
867
+ args: ConnectionArguments
868
+ ): Promise<Connection<CapiResponse>> {
869
+ return this.containsConnection(args, this.handleLiveBlogPosts.bind(this))
790
870
  }
791
871
 
792
872
  isPinned() {
@@ -803,16 +883,28 @@ export class CapiResponse {
803
883
 
804
884
  async pinnedPost(): Promise<CapiResponse | null> {
805
885
  if ('pinnedPosts' in this.capiData) {
806
- const pinnedPosts = this.capiData.pinnedPosts || []
807
- const liveBlogPosts = await this.liveBlogPosts()
808
-
886
+ const pinnedPosts = this.capiData.pinnedPosts
809
887
  const pinnedPostId = pinnedPosts[0]
810
- const pinnedPostIndex = liveBlogPosts.findIndex(
811
- (post) => post.id() === pinnedPostId
812
- )
888
+ if (!pinnedPostId) {
889
+ return null
890
+ }
813
891
 
814
- if (pinnedPostIndex !== -1) {
815
- return liveBlogPosts.splice(pinnedPostIndex, 1)[0] ?? null
892
+ try {
893
+ return await this.context.dataSources.capi.getContent(
894
+ uuidFromUrl(pinnedPostId),
895
+ this
896
+ )
897
+ } catch (error) {
898
+ if (isError(error)) {
899
+ this.context.logger.warn({
900
+ event: 'RECOVERABLE_ERROR',
901
+ error: new OperationalError({
902
+ code: 'PACKAGE_PINNED_POST_ERROR',
903
+ message: `Failed to fetch the pinned post ${pinnedPostId} contained in the package ${this.id()}`,
904
+ cause: error,
905
+ }),
906
+ })
907
+ }
816
908
  }
817
909
  }
818
910
  return null
@@ -23,7 +23,7 @@ import { contentPackageSchema } from './content-package'
23
23
 
24
24
  export type Article = z.infer<typeof articleSchema>
25
25
  type Placeholder = z.infer<typeof placeholderSchema>
26
- type LiveBlogPackage = z.infer<typeof liveBlogPackageSchema>
26
+ export type LiveBlogPackage = z.infer<typeof liveBlogPackageSchema>
27
27
  type ContentPackage = z.infer<typeof contentPackageSchema>
28
28
  type Audio = z.infer<typeof audioSchema>
29
29
  type Video = z.infer<typeof videoSchema>
@@ -5,6 +5,9 @@ import {
5
5
  AudioResolvers,
6
6
  ContentPackageResolvers,
7
7
  ContentResolvers,
8
+ ContentConnectionResolvers,
9
+ ContentEdgeResolvers,
10
+ PageInfoResolvers,
8
11
  LiveBlogPackageResolvers,
9
12
  LiveBlogPostResolvers,
10
13
  VideoResolvers,
@@ -70,6 +73,23 @@ const resolvers = {
70
73
  ...contentResolvers,
71
74
  },
72
75
 
76
+ ContentConnection: {
77
+ edges: (parent) => parent.edges,
78
+ pageInfo: (parent) => parent.pageInfo,
79
+ },
80
+
81
+ ContentEdge: {
82
+ node: (parent) => parent.node,
83
+ cursor: (parent) => parent.cursor,
84
+ },
85
+
86
+ PageInfo: {
87
+ hasPreviousPage: (parent) => parent.hasPreviousPage,
88
+ hasNextPage: (parent) => parent.hasNextPage,
89
+ startCursor: (parent) => parent.startCursor ?? null,
90
+ endCursor: (parent) => parent.endCursor ?? null,
91
+ },
92
+
73
93
  Article: {
74
94
  ...contentResolvers,
75
95
  containedIn: (parent) => parent.containedIn(),
@@ -101,7 +121,14 @@ const resolvers = {
101
121
 
102
122
  LiveBlogPackage: {
103
123
  ...contentResolvers,
104
- liveBlogPosts: (parent) => parent.liveBlogPosts(),
124
+ liveBlogPosts: (parent, args) => parent.liveBlogPosts(args),
125
+ liveBlogPostsConnection: (parent, args) =>
126
+ parent.liveBlogPostsConnection({
127
+ first: args.first ?? undefined,
128
+ after: args.after ?? undefined,
129
+ last: args.last ?? undefined,
130
+ before: args.before ?? undefined,
131
+ }),
105
132
  pinnedPost: (parent) => parent.pinnedPost(),
106
133
  realtime: (parent) => parent.realtime(),
107
134
  },
@@ -161,6 +188,9 @@ const resolvers = {
161
188
  Placeholder: PlaceholderResolvers
162
189
  LiveBlogPost: LiveBlogPostResolvers
163
190
  Content: ContentResolvers
191
+ ContentConnection: ContentConnectionResolvers
192
+ ContentEdge: ContentEdgeResolvers
193
+ PageInfo: PageInfoResolvers
164
194
  LiveBlogPackage: LiveBlogPackageResolvers
165
195
  ContentPackage: ContentPackageResolvers
166
196
  Audio: AudioResolvers
@@ -0,0 +1,28 @@
1
+ // Types to aid the implementation of a Connection type, as specified in
2
+ // https://relay.dev/graphql/connections.htm
3
+
4
+ export type ConnectionCursor = string
5
+
6
+ export interface ConnectionPageInfo {
7
+ hasPreviousPage: boolean
8
+ hasNextPage: boolean
9
+ startCursor?: ConnectionCursor
10
+ endCursor?: ConnectionCursor
11
+ }
12
+
13
+ export interface ConnectionEdge<T> {
14
+ node: T
15
+ cursor: ConnectionCursor
16
+ }
17
+
18
+ export interface Connection<T> {
19
+ edges: ConnectionEdge<T>[]
20
+ pageInfo: ConnectionPageInfo
21
+ }
22
+
23
+ export interface ConnectionArguments {
24
+ first?: number
25
+ after?: ConnectionCursor
26
+ last?: number
27
+ before?: ConnectionCursor
28
+ }