diff --git a/modules/server/package.json b/modules/server/package.json index 8bc6edd0e..08c5469d2 100644 --- a/modules/server/package.json +++ b/modules/server/package.json @@ -19,7 +19,8 @@ "start:prod": "ts-node index.ts", "test": "jest", "test:watch": "jest --watch", - "watch": "npm run cleanDist && npm run build -- --watch" + "watch": "npm run cleanDist && npm run build -- --watch", + "typeCheck": "tsc --noEmit" }, "repository": { "type": "git", diff --git a/modules/server/src/network/errors.ts b/modules/server/src/network/errors.ts index e46f79a87..af1dbc248 100644 --- a/modules/server/src/network/errors.ts +++ b/modules/server/src/network/errors.ts @@ -4,3 +4,24 @@ export class NetworkAggregationError extends Error { this.name = 'NetworkAggregationError'; } } + +type ErrorWithMessage = { + message: string; +}; + +const isErrorWithMessage = (error: unknown): error is ErrorWithMessage => { + return ( + typeof error === 'object' && + error !== null && + 'message' in error && + typeof (error as Error).message === 'string' + ); +}; + +const getErrorMessage = (error: unknown): string => { + if (isErrorWithMessage(error)) { + return error.message; + } else { + return 'Unknown error'; + } +}; diff --git a/modules/server/src/network/gql.ts b/modules/server/src/network/gql.ts index 5e59756cc..32cf831c5 100644 --- a/modules/server/src/network/gql.ts +++ b/modules/server/src/network/gql.ts @@ -1,5 +1,6 @@ import axios, { AxiosRequestConfig } from 'axios'; import { ASTNode, print } from 'graphql'; +import { GQLFieldType } from './queries'; /** * Creates a graphql query string with variables for use in a POST request @@ -46,3 +47,8 @@ export const fetchGql = ({ return axios(axiosOptions); }; + +export const normalizeGqlField = (gqlField: GQLFieldType): { name: string; type: string } => { + const fieldType = gqlField.type.name; + return { name: gqlField.name, type: fieldType }; +}; diff --git a/modules/server/src/network/index.ts b/modules/server/src/network/index.ts index e2c3d536e..3be628bc8 100644 --- a/modules/server/src/network/index.ts +++ b/modules/server/src/network/index.ts @@ -18,15 +18,15 @@ export const createSchemaFromNetworkConfig = async ({ }: { networkConfigs: NetworkConfig[]; }) => { - const networkFields = await fetchAllNodeAggregations({ + const nodeConfig = await fetchAllNodeAggregations({ networkConfigs, }); - const networkFieldTypes = getAllFieldTypes(networkFields, SUPPORTED_AGGREGATIONS_LIST); + const networkFieldTypes = getAllFieldTypes(nodeConfig, SUPPORTED_AGGREGATIONS_LIST); const typeDefs = createTypeDefs(networkFieldTypes); - const resolvers = createResolvers(networkConfigs); + const resolvers = createResolvers(nodeConfig); const networkSchema = makeExecutableSchema({ typeDefs, resolvers }); diff --git a/modules/server/src/network/resolvers/aggregations.ts b/modules/server/src/network/resolvers/aggregations.ts index 8bf13d18a..e1a190a0f 100644 --- a/modules/server/src/network/resolvers/aggregations.ts +++ b/modules/server/src/network/resolvers/aggregations.ts @@ -6,7 +6,7 @@ import { fetchGql } from '../gql'; import { failure, isSuccess, Result, Success, success } from '../httpResponses'; import { Hits } from '../types/hits'; import { NetworkConfig } from '../types/setup'; -import { AllAggregations } from '../types/types'; +import { AllAggregations, NodeConfig } from '../types/types'; import { ASTtoString, RequestedFieldsMap } from '../util'; import { CONNECTION_STATUS, NetworkNode } from './networkNode'; @@ -58,7 +58,6 @@ type NetworkQuery = { /** * Converts info field object into string - * * @param requestedFields * * @example @@ -102,11 +101,24 @@ export const createNodeQueryString = ( return gqlString; }; -const createNetworkQuery = ( - documentName: string, +export const createNetworkQuery = ( + config: NodeConfig, requestedFields: RequestedFieldsMap, ): DocumentNode => { - const gqlString = createNodeQueryString(documentName, requestedFields); + const availableFields = config.aggregations; + const documentName = config.documentName; + + // ensure requested field is available to query on node + const fieldsToRequest = Object.keys(requestedFields).reduce((acc, requestedFieldKey) => { + const field = requestedFields[requestedFieldKey]; + if (availableFields.some((field) => field.name === requestedFieldKey)) { + return { ...acc, [requestedFieldKey]: field }; + } else { + return acc; + } + }, {}); + + const gqlString = createNodeQueryString(documentName, fieldsToRequest); /* * convert string to AST object to use as query @@ -119,6 +131,7 @@ const createNetworkQuery = ( return gqlQuery; } catch (err) { console.error('invalid gql', err); + throw Error('Query creation failed'); } }; @@ -132,37 +145,45 @@ type SuccessResponse = { [k: string]: { hits: Hits; aggregations: AllAggregation * @returns */ export const aggregationPipeline = async ( - configs: NetworkConfig[], + configs: NodeConfig[], requestedAggregationFields: RequestedFieldsMap, ) => { const nodeInfo: NetworkNode[] = []; - const totalAgg = new AggregationAccumulator(requestedAggregationFields); + const totalAgg = new AggregationAccumulator(); const aggregationResultPromises = configs.map(async (config) => { - const gqlQuery = createNetworkQuery(config.documentName, requestedAggregationFields); - const response = await fetchData({ url: config.graphqlUrl, gqlQuery }); - const nodeName = config.displayName; - - if (isSuccess(response)) { - const documentName = config.documentName; - const aggregationData = response.data[documentName]?.aggregations || {}; - const hitsData = response.data[documentName]?.hits || { total: 0 }; - - totalAgg.resolve(aggregationData); - nodeInfo.push({ - name: nodeName, - hits: hitsData.total, - status: CONNECTION_STATUS.OK, - errors: '', - }); - } else { + const nodeAvailableAggregations = config.aggregations; + + try { + const gqlQuery = createNetworkQuery(config, requestedAggregationFields); + const response = await fetchData({ url: config.graphqlUrl, gqlQuery }); + + if (isSuccess(response)) { + const documentName = config.documentName; + const aggregationData = response.data[documentName]?.aggregations || {}; + const hitsData = response.data[documentName]?.hits || { total: 0 }; + + totalAgg.resolve(aggregationData); + nodeInfo.push({ + name: nodeName, + hits: hitsData.total, + status: CONNECTION_STATUS.OK, + errors: '', + aggregations: nodeAvailableAggregations, + }); + } else { + throw Error(`Request failed for node: ${nodeName}`); + } + } catch (error) { + const message = getErrorMessage(error); nodeInfo.push({ name: nodeName, hits: 0, status: CONNECTION_STATUS.ERROR, - errors: response?.message || 'Error', + errors: message, + aggregations: nodeAvailableAggregations, }); } }); diff --git a/modules/server/src/network/resolvers/index.ts b/modules/server/src/network/resolvers/index.ts index 5d56515f5..97a8deca3 100644 --- a/modules/server/src/network/resolvers/index.ts +++ b/modules/server/src/network/resolvers/index.ts @@ -1,5 +1,7 @@ import { type GraphQLResolveInfo } from 'graphql'; +import { NetworkFields } from '../setup/fields'; import { NetworkConfig } from '../types/setup'; +import { NodeConfig } from '../types/types'; import { resolveInfoToMap } from '../util'; import { aggregationPipeline } from './aggregations'; import { NetworkNode } from './networkNode'; @@ -20,7 +22,7 @@ export type NetworkSearchRoot = { * @param networkFieldTypes * @returns */ -export const createResolvers = (configs: NetworkConfig[]) => { +export const createResolvers = (configs: NodeConfig[]) => { return { Query: { network: async ( diff --git a/modules/server/src/network/resolvers/networkNode.ts b/modules/server/src/network/resolvers/networkNode.ts index 84acb0f03..1c7b34db4 100644 --- a/modules/server/src/network/resolvers/networkNode.ts +++ b/modules/server/src/network/resolvers/networkNode.ts @@ -12,4 +12,5 @@ export type NetworkNode = { hits: number; status: keyof typeof CONNECTION_STATUS; errors: string; + aggregations: { name: string; type: string }[]; }; diff --git a/modules/server/src/network/setup/fields.ts b/modules/server/src/network/setup/fields.ts index 09d60e7a8..911245a95 100644 --- a/modules/server/src/network/setup/fields.ts +++ b/modules/server/src/network/setup/fields.ts @@ -2,6 +2,7 @@ import { SupportedAggregation } from '../common'; import { GQLFieldType } from '../queries'; import { NetworkFieldType, + NodeConfig, SupportedAggregations, SupportedNetworkFieldType, UnsupportedAggregations, @@ -23,7 +24,7 @@ const isSupportedType = ( * @returns { supportedAggregations: [], unsupportedAggregations: [] } */ export const getFieldTypes = ( - fields: GQLFieldType[], + fields: NodeConfig['aggregations'], supportedAggregationsList: SupportedAggregation[], ) => { const fieldTypes = fields.reduce( @@ -34,17 +35,15 @@ export const getFieldTypes = ( }, field, ) => { - const fieldType = field.type.name; - const fieldObject = { name: field.name, type: fieldType }; - if (isSupportedType(fieldObject, supportedAggregationsList)) { + if (isSupportedType(field, supportedAggregationsList)) { return { ...aggregations, - supportedAggregations: aggregations.supportedAggregations.concat(fieldObject), + supportedAggregations: aggregations.supportedAggregations.concat(field), }; } else { return { ...aggregations, - unsupportedAggregations: aggregations.unsupportedAggregations.concat(fieldObject), + unsupportedAggregations: aggregations.unsupportedAggregations.concat(field), }; } }, @@ -58,12 +57,12 @@ export const getFieldTypes = ( }; export const getAllFieldTypes = ( - networkFields: NetworkFields[], + nodeConfigs: NodeConfig[], supportedTypes: SupportedAggregation[], ) => { - const nodeFieldTypes = networkFields.map((networkField) => { + const nodeFieldTypes = nodeConfigs.map((config) => { const { supportedAggregations, unsupportedAggregations } = getFieldTypes( - networkField.fields, + config.aggregations, supportedTypes, ); diff --git a/modules/server/src/network/setup/query.ts b/modules/server/src/network/setup/query.ts index c16682d25..fc9472e0c 100644 --- a/modules/server/src/network/setup/query.ts +++ b/modules/server/src/network/setup/query.ts @@ -1,9 +1,9 @@ import { NetworkAggregationError } from '../errors'; -import { fetchGql } from '../gql'; +import { fetchGql, normalizeGqlField } from '../gql'; import { gqlAggregationTypeQuery, GQLTypeQueryResponse } from '../queries'; import { NetworkConfig } from '../types/setup'; +import { NodeConfig } from '../types/types'; import { fulfilledPromiseFilter } from '../util'; -import { NetworkFields } from './fields'; type NetworkQueryResult = PromiseFulfilledResult<{ config: NetworkConfig; @@ -67,7 +67,7 @@ export const fetchAllNodeAggregations = async ({ networkConfigs, }: { networkConfigs: NetworkConfig[]; -}): Promise => { +}): Promise => { // query remote connection types const networkQueryPromises = networkConfigs.map(async (config) => { const gqlResponse = await fetchNodeAggregations(config); @@ -76,15 +76,16 @@ export const fetchAllNodeAggregations = async ({ const networkQueries = await Promise.allSettled(networkQueryPromises); - const nodeAggregations = networkQueries + const nodeConfigs = networkQueries .filter(fulfilledPromiseFilter) .map((networkResult) => { const { config, gqlResponse } = networkResult.value; const fields = gqlResponse.__type.fields; - return { name: config.displayName, fields }; + const aggregations = fields.map(normalizeGqlField); + return { ...config, aggregations }; }); console.log('\nSuccessfully fetched node schemas\n'); - return nodeAggregations; + return nodeConfigs; }; diff --git a/modules/server/src/network/typeDefs/aggregations.ts b/modules/server/src/network/typeDefs/aggregations.ts index ac0e17e52..83a219cca 100644 --- a/modules/server/src/network/typeDefs/aggregations.ts +++ b/modules/server/src/network/typeDefs/aggregations.ts @@ -1,5 +1,5 @@ import { GraphQLInt, GraphQLList, GraphQLObjectType, GraphQLSchema, GraphQLString } from 'graphql'; -import { SupportedNetworkFieldType } from '../types'; +import { SupportedNetworkFieldType } from '../types/types'; import { singleToNetworkAggregationMap } from './networkAggregations'; /** @@ -8,7 +8,7 @@ import { singleToNetworkAggregationMap } from './networkAggregations'; * @example * { name: "donor_age", type: "NumericAggregations" } => { donor_age: { type: "NetworkNumericAggregations" } } */ -const convertToGQLObjectType = (networkFieldTypes) => { +const convertToGQLObjectType = (networkFieldTypes: SupportedNetworkFieldType[]) => { return networkFieldTypes.reduce((allFields, currentField) => { const field = { [currentField.name]: { type: singleToNetworkAggregationMap.get(currentField.type) }, @@ -39,6 +39,14 @@ export const createNetworkAggregationTypeDefs = ( fields: allFields, }); + const aggregationList = new GraphQLObjectType({ + name: 'aggregations', + fields: { + name: { type: GraphQLString }, + type: { type: GraphQLString }, + }, + }); + const remoteConnectionType = new GraphQLObjectType({ name: 'RemoteConnection', fields: { @@ -46,6 +54,7 @@ export const createNetworkAggregationTypeDefs = ( hits: { type: GraphQLInt }, status: { type: GraphQLString }, errors: { type: GraphQLString }, + aggregations: { type: new GraphQLList(aggregationList) }, }, }); diff --git a/modules/server/src/network/types/types.ts b/modules/server/src/network/types/types.ts index 3f144bd7e..a0a2571a9 100644 --- a/modules/server/src/network/types/types.ts +++ b/modules/server/src/network/types/types.ts @@ -2,6 +2,7 @@ import { ObjectValues } from '@/utils/types'; import { SupportedAggregation } from '../common'; import { CONNECTION_STATUS } from '../resolvers/networkNode'; import { Aggregations, Bucket, NumericAggregations } from './aggregations'; +import { NetworkConfig } from './setup'; // environment config export type NetworkAggregationConfigInput = { @@ -33,3 +34,5 @@ export type NetworkAggregation = { bucket_count: number; buckets: Bucket[]; }; + +export type NodeConfig = NetworkConfig & { aggregations: { name: string; type: string }[] };