Browse Source

add orion/atlas integration

Klaudiusz Dembler 4 years ago
parent
commit
a0c7d68711

+ 2 - 2
apollo.config.js

@@ -2,8 +2,8 @@ module.exports = {
   client: {
     service: {
       name: 'atlas-graphql',
-      localSchemaFile: 'src/schema.graphql',
+      localSchemaFile: 'src/api/schemas/extendedQueryNode.graphql',
     },
-    excludes: ['src/schema.graphql'],
+    excludes: ['src/api/schemas/extendedQueryNode.graphql', 'src/api/schemas/orion.graphql'],
   },
 }

+ 1 - 0
package.json

@@ -82,6 +82,7 @@
     "fluent-ffmpeg": "^2.1.2",
     "graphql": "^15.3.0",
     "graphql-tag": "^2.11.0",
+    "graphql-tools": "^6.2.4",
     "husky": "^4.2.5",
     "jest": "24.9.0",
     "lint-staged": "^10.2.7",

+ 2 - 2
src/App.tsx

@@ -1,12 +1,12 @@
 import React from 'react'
 import { ApolloProvider } from '@apollo/client'
 
-import { apolloClient } from '@/api'
+import { client } from '@/api'
 import { LayoutWithRouting } from '@/components'
 
 export default function App() {
   return (
-    <ApolloProvider client={apolloClient}>
+    <ApolloProvider client={client}>
       <LayoutWithRouting />
     </ApolloProvider>
   )

+ 0 - 42
src/api/client.ts

@@ -1,42 +0,0 @@
-import { ApolloClient, InMemoryCache } from '@apollo/client'
-import { parseISO } from 'date-fns'
-
-import '@/mocking/server'
-import { relayStylePagination } from '@apollo/client/utilities'
-
-const apolloClient = new ApolloClient({
-  uri: '/graphql',
-  cache: new InMemoryCache({
-    typePolicies: {
-      Query: {
-        fields: {
-          channelsConnection: relayStylePagination(),
-          videosConnection: relayStylePagination((args) => {
-            // make sure queries asking for a specific category are separated in cache
-            return args?.where?.categoryId_eq
-          }),
-        },
-      },
-      Video: {
-        fields: {
-          publishedOnJoystreamAt: {
-            merge(_, publishedOnJoystreamAt: string | Date): Date {
-              if (typeof publishedOnJoystreamAt !== 'string') {
-                // TODO: investigate further
-                // rarely, for some reason the object that arrives here is already a date object
-                // in this case parsing attempt will cause an error
-                return publishedOnJoystreamAt
-              }
-              return parseISO(publishedOnJoystreamAt)
-            },
-          },
-        },
-      },
-    },
-    possibleTypes: {
-      FreeTextSearchResultItemType: ['Video', 'Channel'],
-    },
-  }),
-})
-
-export default apolloClient

+ 37 - 0
src/api/client/cache.ts

@@ -0,0 +1,37 @@
+import { InMemoryCache } from '@apollo/client'
+import { relayStylePagination } from '@apollo/client/utilities'
+import { parseISO } from 'date-fns'
+
+const cache = new InMemoryCache({
+  typePolicies: {
+    Query: {
+      fields: {
+        channelsConnection: relayStylePagination(),
+        videosConnection: relayStylePagination((args) => {
+          // make sure queries asking for a specific category are separated in cache
+          return args?.where?.categoryId_eq
+        }),
+      },
+    },
+    Video: {
+      fields: {
+        publishedOnJoystreamAt: {
+          merge(_, publishedOnJoystreamAt: string | Date): Date {
+            if (typeof publishedOnJoystreamAt !== 'string') {
+              // TODO: investigate further
+              // rarely, for some reason the object that arrives here is already a date object
+              // in this case parsing attempt will cause an error
+              return publishedOnJoystreamAt
+            }
+            return parseISO(publishedOnJoystreamAt)
+          },
+        },
+      },
+    },
+  },
+  possibleTypes: {
+    FreeTextSearchResultItemType: ['Video', 'Channel'],
+  },
+})
+
+export default cache

+ 22 - 0
src/api/client/executors.ts

@@ -0,0 +1,22 @@
+import { print } from 'graphql'
+import { ORION_GRAPHQL_URL, QUERY_NODE_GRAPHQL_URL } from '@/config/urls'
+import { Executor } from '@graphql-tools/delegate'
+
+// TODO: Switch back to using Apollo HTTP links with `linkToExecutor`
+// That can be done once the following issues are resolved:
+// https://github.com/ardatan/graphql-tools/issues/2105
+// https://github.com/ardatan/graphql-tools/issues/2111
+const buildExecutor = (uri: string): Executor => async ({ document, variables }) => {
+  const query = print(document)
+  const fetchResult = await fetch(uri, {
+    method: 'POST',
+    headers: {
+      'Content-Type': 'application/json',
+    },
+    body: JSON.stringify({ query, variables }),
+  })
+  return fetchResult.json()
+}
+
+export const queryNodeExecutor = buildExecutor(QUERY_NODE_GRAPHQL_URL)
+export const orionExecutor = buildExecutor(ORION_GRAPHQL_URL)

+ 32 - 0
src/api/client/index.ts

@@ -0,0 +1,32 @@
+import { ApolloClient } from '@apollo/client'
+import { wrapSchema } from '@graphql-tools/wrap'
+import { mergeSchemas } from '@graphql-tools/merge'
+import { buildASTSchema } from 'graphql'
+import { SchemaLink } from '@apollo/client/link/schema'
+
+import extendedQueryNodeSchema from '../schemas/extendedQueryNode.graphql'
+import orionSchema from '../schemas/orion.graphql'
+
+import cache from './cache'
+import { orionExecutor, queryNodeExecutor } from './executors'
+import { queryNodeStitchingResolvers } from './resolvers'
+
+const executableQueryNodeSchema = wrapSchema({
+  schema: buildASTSchema(extendedQueryNodeSchema),
+  executor: queryNodeExecutor,
+})
+const executableOrionSchema = wrapSchema({
+  schema: buildASTSchema(orionSchema),
+  executor: orionExecutor,
+})
+
+const mergedSchema = mergeSchemas({
+  schemas: [executableQueryNodeSchema, executableOrionSchema],
+  resolvers: queryNodeStitchingResolvers(executableOrionSchema),
+})
+
+const link = new SchemaLink({ schema: mergedSchema })
+
+const apolloClient = new ApolloClient({ link, cache })
+
+export default apolloClient

+ 30 - 0
src/api/client/resolvers.ts

@@ -0,0 +1,30 @@
+import { GraphQLSchema } from 'graphql'
+import { delegateToSchema } from '@graphql-tools/delegate'
+import type { IResolvers } from '@graphql-tools/utils'
+import { TransformViewsField, VIEWS_FIELD_NAME } from './transformViews'
+
+export const queryNodeStitchingResolvers = (orionSchema: GraphQLSchema): IResolvers => ({
+  Video: {
+    // TODO: Resolve the views count in parallel to the videosConnection query
+    // this can be done by writing a resolver for the query itself in which two requests in the same fashion as below would be made
+    // then the results could be combined
+    views: async (parent, args, context, info) => {
+      try {
+        return await delegateToSchema({
+          schema: orionSchema,
+          operation: 'query',
+          fieldName: VIEWS_FIELD_NAME,
+          args: {
+            videoID: parent.id,
+          },
+          context,
+          info,
+          transforms: [TransformViewsField],
+        })
+      } catch (error) {
+        console.warn('Failed to resolve views field', { error })
+        return null
+      }
+    },
+  },
+})

+ 76 - 0
src/api/client/transformViews.ts

@@ -0,0 +1,76 @@
+import { Transform } from '@graphql-tools/utils'
+import { GraphQLError, SelectionSetNode } from 'graphql'
+
+class OrionError extends Error {
+  graphQLErrors: readonly GraphQLError[]
+
+  constructor(errors: readonly GraphQLError[]) {
+    super()
+    this.graphQLErrors = errors
+  }
+}
+
+const VIDEO_INFO_SELECTION_SET: SelectionSetNode = {
+  kind: 'SelectionSet',
+  selections: [
+    {
+      kind: 'Field',
+      name: {
+        kind: 'Name',
+        value: 'id',
+      },
+    },
+    {
+      kind: 'Field',
+      name: {
+        kind: 'Name',
+        value: 'views',
+      },
+    },
+  ],
+}
+
+export const VIEWS_FIELD_NAME = 'videoViews'
+
+// Transform a request to expect VideoViewsInfo return type instead of an Int
+export const TransformViewsField: Transform = {
+  transformRequest(request) {
+    request.document = {
+      ...request.document,
+      definitions: request.document.definitions.map((definition) => {
+        if (definition.kind === 'OperationDefinition') {
+          return {
+            ...definition,
+            selectionSet: {
+              ...definition.selectionSet,
+              selections: definition.selectionSet.selections.map((selection) => {
+                if (selection.kind === 'Field' && selection.name.value === VIEWS_FIELD_NAME) {
+                  return {
+                    ...selection,
+                    selectionSet: VIDEO_INFO_SELECTION_SET,
+                  }
+                }
+                return selection
+              }),
+            },
+          }
+        }
+        return definition
+      }),
+    }
+
+    return request
+  },
+  transformResult(result) {
+    if (result.errors) {
+      throw new OrionError(result.errors)
+    }
+
+    const views = result?.data?.[VIEWS_FIELD_NAME]?.views || 0
+    const data = {
+      videoViews: views,
+    }
+
+    return { data }
+  },
+}

+ 3 - 2
src/api/index.ts

@@ -1,3 +1,4 @@
-import apolloClient from '@/api/client'
+import '@/mocking/server'
 
-export { apolloClient }
+export { default as client } from './client'
+export * from './queries'

+ 22 - 0
src/api/queries/__generated__/AddVideoView.ts

@@ -0,0 +1,22 @@
+/* tslint:disable */
+/* eslint-disable */
+// @generated
+// This file was automatically generated and should not be edited.
+
+// ====================================================
+// GraphQL mutation operation: AddVideoView
+// ====================================================
+
+export interface AddVideoView_addVideoView {
+  __typename: "VideoViewsInfo";
+  id: string;
+  views: number;
+}
+
+export interface AddVideoView {
+  addVideoView: AddVideoView_addVideoView;
+}
+
+export interface AddVideoViewVariables {
+  id: string;
+}

+ 1 - 1
src/api/queries/__generated__/GetChannel.ts

@@ -45,7 +45,7 @@ export interface GetChannel_channel_videos {
   title: string;
   description: string;
   category: GetChannel_channel_videos_category;
-  views: number;
+  views: number | null;
   duration: number;
   thumbnailURL: string;
   publishedOnJoystreamAt: GQLDate;

+ 1 - 1
src/api/queries/__generated__/GetFeaturedVideos.ts

@@ -45,7 +45,7 @@ export interface GetFeaturedVideos_featured_videos {
   title: string;
   description: string;
   category: GetFeaturedVideos_featured_videos_category;
-  views: number;
+  views: number | null;
   duration: number;
   thumbnailURL: string;
   publishedOnJoystreamAt: GQLDate;

+ 1 - 1
src/api/queries/__generated__/GetNewestVideos.ts

@@ -45,7 +45,7 @@ export interface GetNewestVideos_videosConnection_edges_node {
   title: string;
   description: string;
   category: GetNewestVideos_videosConnection_edges_node_category;
-  views: number;
+  views: number | null;
   duration: number;
   thumbnailURL: string;
   publishedOnJoystreamAt: GQLDate;

+ 1 - 1
src/api/queries/__generated__/GetVideo.ts

@@ -45,7 +45,7 @@ export interface GetVideo_video {
   title: string;
   description: string;
   category: GetVideo_video_category;
-  views: number;
+  views: number | null;
   duration: number;
   thumbnailURL: string;
   publishedOnJoystreamAt: GQLDate;

+ 1 - 1
src/api/queries/__generated__/Search.ts

@@ -45,7 +45,7 @@ export interface Search_search_item_Video {
   title: string;
   description: string;
   category: Search_search_item_Video_category;
-  views: number;
+  views: number | null;
   duration: number;
   thumbnailURL: string;
   publishedOnJoystreamAt: GQLDate;

+ 1 - 1
src/api/queries/__generated__/VideoFields.ts

@@ -45,7 +45,7 @@ export interface VideoFields {
   title: string;
   description: string;
   category: VideoFields_category;
-  views: number;
+  views: number | null;
   duration: number;
   thumbnailURL: string;
   publishedOnJoystreamAt: GQLDate;

+ 9 - 0
src/api/queries/videos.ts

@@ -81,3 +81,12 @@ export const GET_VIDEO = gql`
   }
   ${videoFieldsFragment}
 `
+
+export const ADD_VIDEO_VIEW = gql`
+  mutation AddVideoView($id: ID!) {
+    addVideoView(videoID: $id) {
+      id
+      views
+    }
+  }
+`

+ 10 - 1
src/schema.graphql → src/api/schemas/extendedQueryNode.graphql

@@ -114,7 +114,7 @@ type Video {
 
   description: String!
 
-  views: Int!
+  views: Int
 
   # In seconds
   duration: Int!
@@ -204,6 +204,11 @@ type VideoConnection {
   totalCount: Int!
 }
 
+type VideoViewsInfo {
+  id: ID!
+  views: Int!
+}
+
 type Query {
   # Lookup a channel by its ID
   channel(id: ID!): Channel
@@ -234,3 +239,7 @@ type Query {
   # Free text search across videos and channels
   search(query_string: String!): [FreeTextSearchResult!]!
 }
+
+type Mutation {
+  addVideoView(videoID: ID!): VideoViewsInfo!
+}

+ 28 - 0
src/api/schemas/orion.graphql

@@ -0,0 +1,28 @@
+# -----------------------------------------------
+# !!! THIS FILE WAS GENERATED BY TYPE-GRAPHQL !!!
+# !!!   DO NOT MODIFY THIS FILE BY YOURSELF   !!!
+# -----------------------------------------------
+
+type Mutation {
+  """
+  Add a single view to the target video's count
+  """
+  addVideoView(videoID: ID!): VideoViewsInfo!
+}
+
+type Query {
+  """
+  Get views counts for a list of videos
+  """
+  batchedVideoViews(videoIDList: [ID!]!): [VideoViewsInfo]!
+
+  """
+  Get views count for a single video
+  """
+  videoViews(videoID: ID!): VideoViewsInfo
+}
+
+type VideoViewsInfo {
+  id: ID!
+  views: Int!
+}

+ 3 - 6
src/components/VideoBestMatch/VideoBestMatch.tsx

@@ -1,9 +1,8 @@
 import React from 'react'
 
 import { VideoFields } from '@/api/queries/__generated__/VideoFields'
-import { formatNumber } from '@/utils/number'
-import { formatDate } from '@/utils/time'
-import { Container, Content, InnerContainer, TitleContainer, Title, Poster } from './VideoBestMatch.style'
+import { formatVideoViewsAndDate } from '@/utils/video'
+import { Container, Content, InnerContainer, Poster, Title, TitleContainer } from './VideoBestMatch.style'
 
 type BestVideoMatchProps = {
   video: VideoFields
@@ -21,9 +20,7 @@ const BestVideoMatch: React.FC<BestVideoMatchProps> = ({
       <InnerContainer>
         <TitleContainer>
           <Title>{title}</Title>
-          <span>
-            {formatNumber(views)} views • {formatDate(publishedOnJoystreamAt)}
-          </span>
+          <span>{formatVideoViewsAndDate(views, publishedOnJoystreamAt, { fullViews: true })}</span>
         </TitleContainer>
       </InnerContainer>
     </Content>

+ 1 - 0
src/config/misc.ts

@@ -0,0 +1 @@
+export const MOCKED_SERVER_LOAD_DELAY = 500

+ 2 - 0
src/config/urls.ts

@@ -0,0 +1,2 @@
+export const QUERY_NODE_GRAPHQL_URL = '/query-node-graphql'
+export const ORION_GRAPHQL_URL = '/orion-graphql'

+ 7 - 0
src/mocking/server/data.ts

@@ -34,12 +34,19 @@ export const createMockData = (server: any) => {
 
   mockVideos.forEach((video, idx) => {
     const mediaIndex = idx % mockVideosMedia.length
+
     server.schema.create('Video', {
       ...video,
+      views: undefined,
       duration: mockVideosMedia[mediaIndex].duration,
       channel: channels[idx % channels.length],
       category: categories[idx % categories.length],
       media: videoMedias[mediaIndex],
     })
+
+    server.create('VideoViewsInfo', {
+      id: video.id,
+      views: video.views,
+    })
   })
 }

+ 29 - 4
src/mocking/server/index.ts

@@ -1,13 +1,24 @@
 import { createServer } from 'miragejs'
 import { createGraphQLHandler } from '@miragejs/graphql'
 
-import schema from '../../schema.graphql'
+import extendedQueryNodeSchema from '@/api/schemas/extendedQueryNode.graphql'
+import orionSchema from '@/api/schemas/orion.graphql'
+
 import { createMockData } from './data'
-import { channelsResolver, featuredVideosResolver, searchResolver, videosResolver } from './resolvers'
+import {
+  addVideoViewResolver,
+  channelsResolver,
+  featuredVideosResolver,
+  searchResolver,
+  videosResolver,
+  videoViewsResolver,
+} from './resolvers'
+import { ORION_GRAPHQL_URL, QUERY_NODE_GRAPHQL_URL } from '@/config/urls'
+import { MOCKED_SERVER_LOAD_DELAY } from '@/config/misc'
 
 createServer({
   routes() {
-    const graphQLHandler = createGraphQLHandler(schema, this.schema, {
+    const queryNodeHandler = createGraphQLHandler(extendedQueryNodeSchema, this.schema, {
       resolvers: {
         Query: {
           videosConnection: videosResolver,
@@ -25,7 +36,21 @@ createServer({
       },
     })
 
-    this.post('/graphql', graphQLHandler, { timing: 1500 }) // include load delay
+    const orionHandler = createGraphQLHandler(orionSchema, this.schema, {
+      resolvers: {
+        Query: {
+          videoViews: videoViewsResolver,
+        },
+        Mutation: {
+          addVideoView: addVideoViewResolver,
+        },
+      },
+    })
+
+    this.post(QUERY_NODE_GRAPHQL_URL, queryNodeHandler, { timing: MOCKED_SERVER_LOAD_DELAY })
+
+    this.post(ORION_GRAPHQL_URL, orionHandler, { timing: MOCKED_SERVER_LOAD_DELAY })
+    // this.passthrough(ORION_GRAPHQL_URL)
   },
 
   seeds(server) {

+ 17 - 1
src/mocking/server/resolvers.ts

@@ -13,7 +13,7 @@ import { ChannelFields } from '@/api/queries/__generated__/ChannelFields'
 type QueryResolver<ArgsType extends object = Record<string, unknown>, ReturnType = unknown> = (
   obj: unknown,
   args: ArgsType,
-  context: any,
+  context: { mirageSchema: any },
   info: unknown
 ) => ReturnType
 
@@ -100,3 +100,19 @@ export const searchResolver: QueryResolver<SearchVariables, Search_search[]> = (
   }, [] as Search_search[])
   return relevantItems
 }
+
+type VideoViewsArgs = {
+  videoID: string
+}
+
+export const videoViewsResolver: QueryResolver<VideoViewsArgs> = (obj, args, context, info) => {
+  return mirageGraphQLFieldResolver(obj, { id: args.videoID }, context, info)
+}
+
+export const addVideoViewResolver: QueryResolver<VideoViewsArgs> = (obj, args, context, info) => {
+  const videoInfo = context.mirageSchema.videoViewsInfos.find(args.videoID)
+  videoInfo.update({
+    views: videoInfo.views + 1,
+  })
+  return videoInfo
+}

+ 4 - 8
src/shared/components/VideoPreview/VideoPreview.tsx

@@ -11,9 +11,9 @@ import {
   StyledAvatar,
   TitleHeader,
 } from './VideoPreview.styles'
-import { formatDateAgo, formatDurationShort } from '@/utils/time'
-import { formatNumberShort } from '@/utils/number'
+import { formatDurationShort } from '@/utils/time'
 import VideoPreviewBase from './VideoPreviewBase'
+import { formatVideoViewsAndDate } from '@/utils/video'
 
 type VideoPreviewProps = {
   title: string
@@ -23,7 +23,7 @@ type VideoPreviewProps = {
   duration?: number
   // video watch progress in percent (0-100)
   progress?: number
-  views: number
+  views: number | null
   posterURL: string
 
   showChannel?: boolean
@@ -101,11 +101,7 @@ const VideoPreview: React.FC<VideoPreviewProps> = ({
     </ChannelName>
   )
 
-  const metaNode = (
-    <MetaText>
-      {formatDateAgo(createdAt)}・{formatNumberShort(views)} views
-    </MetaText>
-  )
+  const metaNode = <MetaText>{formatVideoViewsAndDate(views, createdAt)}</MetaText>
 
   return (
     <VideoPreviewBase

+ 12 - 0
src/utils/video.ts

@@ -0,0 +1,12 @@
+import { formatNumber, formatNumberShort } from '@/utils/number'
+import { formatDateAgo } from '@/utils/time'
+
+export const formatVideoViewsAndDate = (
+  views: number | null,
+  date: Date,
+  { fullViews } = { fullViews: false }
+): string => {
+  const formattedDate = formatDateAgo(date)
+  const formattedViews = views !== null && (fullViews ? formatNumber(views) : formatNumberShort(views))
+  return formattedViews ? `${formattedViews} views • ${formattedDate}` : formattedDate
+}

+ 34 - 9
src/views/VideoView/VideoView.tsx

@@ -1,4 +1,4 @@
-import React from 'react'
+import React, { useEffect } from 'react'
 import { RouteComponentProps, useParams, navigate } from '@reach/router'
 import {
   Container,
@@ -14,16 +14,43 @@ import {
 } from './VideoView.style'
 import { VideoGrid } from '@/components'
 import { VideoPlayer } from '@/shared/components'
-import { formatDateAgo } from '@/utils/time'
-import { formatNumber } from '@/utils/number'
-import { useQuery } from '@apollo/client'
-import { GET_VIDEO } from '@/api/queries'
+import { useMutation, useQuery } from '@apollo/client'
+import { ADD_VIDEO_VIEW, GET_VIDEO } from '@/api/queries'
 import { GetVideo, GetVideoVariables } from '@/api/queries/__generated__/GetVideo'
 import routes from '@/config/routes'
+import { formatVideoViewsAndDate } from '@/utils/video'
+import { AddVideoView, AddVideoViewVariables } from '@/api/queries/__generated__/AddVideoView'
 
 const VideoView: React.FC<RouteComponentProps> = () => {
   const { id } = useParams()
-  const { loading, data } = useQuery<GetVideo, GetVideoVariables>(GET_VIDEO, { variables: { id } })
+  const { loading, data } = useQuery<GetVideo, GetVideoVariables>(GET_VIDEO, {
+    variables: { id },
+  })
+  const [addVideoView] = useMutation<AddVideoView, AddVideoViewVariables>(ADD_VIDEO_VIEW)
+
+  const videoID = data?.video?.id
+
+  useEffect(() => {
+    if (!videoID) {
+      return
+    }
+    addVideoView({
+      variables: { id: videoID },
+      update: (cache, mutationResult) => {
+        cache.modify({
+          id: cache.identify({
+            __typename: 'Video',
+            id: videoID,
+          }),
+          fields: {
+            views: () => mutationResult.data?.addVideoView.views,
+          },
+        })
+      },
+    }).catch((error) => {
+      console.warn('Failed to increase video views', { error })
+    })
+  }, [addVideoView, videoID])
 
   if (loading || !data) {
     return <p>Loading</p>
@@ -48,9 +75,7 @@ const VideoView: React.FC<RouteComponentProps> = () => {
         <TitleActionsContainer>
           <Title>{title}</Title>
         </TitleActionsContainer>
-        <Meta>
-          {formatNumber(views)} views • {formatDateAgo(publishedOnJoystreamAt)}
-        </Meta>
+        <Meta>{formatVideoViewsAndDate(views, publishedOnJoystreamAt, { fullViews: true })}</Meta>
         <StyledChannelAvatar
           name={channel.handle}
           avatarUrl={channel.avatarPhotoURL}

File diff suppressed because it is too large
+ 502 - 64
yarn.lock


Some files were not shown because too many files changed in this diff