From 2082f3a30c33b93c32a296aa1ce6c6f3a3559c9b Mon Sep 17 00:00:00 2001 From: Deep Mistry Date: Wed, 13 Mar 2024 12:38:23 -0400 Subject: [PATCH 1/7] GQL-31: Added support for creating association --- src/cmr/concepts/association.js | 77 ++++++++++++++++++++++++++ src/datasources/association.js | 47 ++++++++++++++++ src/graphql/handler.js | 6 ++ src/resolvers/association.js | 29 ++++++++++ src/resolvers/index.js | 2 + src/types/association.graphql | 21 +++++++ src/types/index.js | 2 + src/utils/cmrAssociation.js | 78 ++++++++++++++++++++++++++ src/utils/cmrVariableAssociation.js | 83 ++++++++++++++++++++++++++++ src/utils/umm/associationKeyMap.json | 12 ++++ 10 files changed, 357 insertions(+) create mode 100644 src/cmr/concepts/association.js create mode 100644 src/datasources/association.js create mode 100644 src/resolvers/association.js create mode 100644 src/types/association.graphql create mode 100644 src/utils/cmrAssociation.js create mode 100644 src/utils/cmrVariableAssociation.js create mode 100644 src/utils/umm/associationKeyMap.json diff --git a/src/cmr/concepts/association.js b/src/cmr/concepts/association.js new file mode 100644 index 000000000..82cc55092 --- /dev/null +++ b/src/cmr/concepts/association.js @@ -0,0 +1,77 @@ +import { snakeCase } from 'lodash' +import { pickIgnoringCase } from '../../utils/pickIgnoringCase' +import Concept from './concept' +import { parseError } from '../../utils/parseError' +import { cmrVariableAssociation } from '../../utils/cmrVariableAssociation' + +export default class Association extends Concept { + /** + * Parse and return the body of an ingest operation + * @param {Object} ingestResponse HTTP response from the CMR endpoint + */ + parseCreateBody(ingestResponse) { + const { data } = ingestResponse + + return data[0] + } + + /** + * Parses the response from an create + * @param {Object} requestInfo Parsed data pertaining to the create operation + */ + async parseCreate(requestInfo) { + try { + const { + ingestKeys + } = requestInfo + + const result = await this.getResponse() + + const data = this.parseCreateBody(result) + + ingestKeys.forEach((key) => { + const cmrKey = snakeCase(key) + + const { [cmrKey]: keyValue } = data + + this.setIngestValue(key, keyValue) + }) + } catch (e) { + parseError(e, { reThrowError: true }) + } + } + + /** + * Create the provided object into the CMR + * @param {Object} data Parameters provided by the query + * @param {Array} requestedKeys Keys requested by the query + * @param {Object} providedHeaders Headers requested by the query + */ + createVariable(data, requestedKeys, providedHeaders) { + // Default headers + const defaultHeaders = { + 'Content-Type': 'application/vnd.nasa.cmr.umm+json' + } + + this.logKeyRequest(requestedKeys, 'association') + + // Merge default headers into the provided headers and then pick out only permitted values + const permittedHeaders = pickIgnoringCase({ + ...defaultHeaders, + ...providedHeaders + }, [ + 'Accept', + 'Authorization', + 'Client-Id', + 'Content-Type', + 'CMR-Request-Id' + ]) + + this.response = cmrVariableAssociation({ + conceptType: this.getConceptType(), + data, + nonIndexedKeys: this.getNonIndexedKeys(), + headers: permittedHeaders + }) + } +} diff --git a/src/datasources/association.js b/src/datasources/association.js new file mode 100644 index 000000000..e9c69e92d --- /dev/null +++ b/src/datasources/association.js @@ -0,0 +1,47 @@ +import Association from '../cmr/concepts/association' +import { parseRequestedFields } from '../utils/parseRequestedFields' +import associationKeyMap from '../utils/umm/associationKeyMap.json' + +export const createAssociation = async (args, context, parsedInfo) => { + const { headers } = context + const { conceptType } = args + + const requestInfo = parseRequestedFields(parsedInfo, associationKeyMap, 'Association') + + const { + ingestKeys + } = requestInfo + + const association = new Association(`${conceptType.toLowerCase()}s`, headers, requestInfo, args) + + // Contact CMR + association.create(args, ingestKeys, headers) + + // Parse the response from CMR + await association.parseCreate(requestInfo) + + // Return a formatted JSON response + return association.getFormattedIngestResponse() +} + +export const createVariableAssociation = async (args, context, parsedInfo) => { + const { headers } = context + const { conceptType } = args + + const requestInfo = parseRequestedFields(parsedInfo, associationKeyMap, 'Association') + + const { + ingestKeys + } = requestInfo + + const association = new Association(`${conceptType.toLowerCase()}s`, headers, requestInfo, args) + + // Contact CMR + association.createVariable(args, ingestKeys, headers) + + // Parse the response from CMR + await association.parseIngest(requestInfo) + + // Return a formatted JSON response + return association.getFormattedIngestResponse() +} diff --git a/src/graphql/handler.js b/src/graphql/handler.js index e49c87d87..bea6b6ae0 100644 --- a/src/graphql/handler.js +++ b/src/graphql/handler.js @@ -12,6 +12,10 @@ import resolvers from '../resolvers' import typeDefs from '../types' import aclSource from '../datasources/acl' +import { + createAssociation as associationSourceCreate, + createVariableAssociation as variableAssociationSourceCreate +} from '../datasources/association' import collectionDraftProposalSource from '../datasources/collectionDraftProposal' import collectionDraftSource from '../datasources/collectionDraft' import collectionVariableDraftsSource from '../datasources/collectionVariableDrafts' @@ -157,6 +161,7 @@ export default startServerAndCreateLambdaHandler( ...context, dataSources: { aclSource, + associationSourceCreate, collectionDraftProposalSource, collectionDraftSource, collectionSourceDelete, @@ -183,6 +188,7 @@ export default startServerAndCreateLambdaHandler( toolDraftSource, toolSourceDelete, toolSourceFetch, + variableAssociationSourceCreate, variableDraftSource, variableSourceDelete, variableSourceFetch diff --git a/src/resolvers/association.js b/src/resolvers/association.js new file mode 100644 index 000000000..9e2d358da --- /dev/null +++ b/src/resolvers/association.js @@ -0,0 +1,29 @@ +import { parseResolveInfo } from 'graphql-parse-resolve-info' + +export default { + Mutation: { + createAssociation: async (source, args, context, info) => { + const { dataSources } = context + + const { conceptType } = args + + if (conceptType === 'Variable') { + const result = await dataSources.variableAssociationSourceCreate( + args, + context, + parseResolveInfo(info) + ) + + return result + } + + const result = await dataSources.associationSourceCreate( + args, + context, + parseResolveInfo(info) + ) + + return result + } + } +} diff --git a/src/resolvers/index.js b/src/resolvers/index.js index 010e54648..e9cc76f54 100644 --- a/src/resolvers/index.js +++ b/src/resolvers/index.js @@ -1,6 +1,7 @@ import { mergeResolvers } from '@graphql-tools/merge' import aclResolver from './acl' +import associationResolver from './association' import collectionDraftProposalResolver from './collectionDraftProposal' import collectionDraftResolver from './collectionDraft' import collectionResolver from './collection' @@ -20,6 +21,7 @@ import variableResolver from './variable' const resolvers = [ aclResolver, + associationResolver, collectionDraftProposalResolver, collectionDraftResolver, collectionResolver, diff --git a/src/types/association.graphql b/src/types/association.graphql new file mode 100644 index 000000000..058631d33 --- /dev/null +++ b/src/types/association.graphql @@ -0,0 +1,21 @@ +type AssociationMutationResponse { + toolAssociation: JSON + serviceAssociation: JSON + variableAssociation: JSON + associatedItem: JSON +} + +type Mutation{ + createAssociation ( + "The concept id of the associated record" + conceptId: String! + "The list of collection conceptIds for association" + collectionConceptIds: [JSON]! + "The concept type of the association" + conceptType: String! + "Native Id of the published variable record" + nativeId: String + "The metadata for the published variable record" + metadata: JSON + ): AssociationMutationResponse +} \ No newline at end of file diff --git a/src/types/index.js b/src/types/index.js index 511a97892..edf1a89b8 100644 --- a/src/types/index.js +++ b/src/types/index.js @@ -1,5 +1,6 @@ import { mergeTypeDefs } from '@graphql-tools/merge' import acl from './acl.graphql' +import association from './association.graphql' import collection from './collection.graphql' import collectionDraft from './collectionDraft.graphql' import collectionDraftProposal from './collectionDraftProposal.graphql' @@ -21,6 +22,7 @@ import orderOption from './orderOption.graphql' export default mergeTypeDefs( [ acl, + association, collection, collectionDraft, collectionDraftProposal, diff --git a/src/utils/cmrAssociation.js b/src/utils/cmrAssociation.js new file mode 100644 index 000000000..5b7fa2142 --- /dev/null +++ b/src/utils/cmrAssociation.js @@ -0,0 +1,78 @@ +import axios from 'axios' +import { pickIgnoringCase } from './pickIgnoringCase' +import { downcaseKeys } from './downcaseKeys' + +/** + * Make a request to CMR and return the promise + * @param {Object} params + * @param {Object} params.headers Headers to send to CMR + * @param {Array} params.nonIndexedKeys Parameter names that should not be indexed before sending to CMR + * @param {Object} params.options Additional Options (format) + * @param {Object} params.params Parameters to send to CMR + * @param {String} params.conceptType Concept type to search + */ +export const cmrAssociation = ({ + conceptType, + data, + headers +}) => { + // Default headers + const defaultHeaders = {} + + // Merge default headers into the provided headers and then pick out only permitted values + const permittedHeaders = pickIgnoringCase({ + ...defaultHeaders, + ...headers + }, [ + 'Accept', + 'Authorization', + 'Client-Id', + 'CMR-Request-Id', + 'CMR-Search-After' + ]) + + const { + 'client-id': clientId, + 'cmr-request-id': requestId + } = downcaseKeys(permittedHeaders) + + const { conceptId, collectionConceptIds } = data + + const requestConfiguration = { + data: collectionConceptIds, + headers: permittedHeaders, + method: 'POST', + url: `${process.env.cmrRootUrl}/search/${conceptType}/${conceptId}/associations` + } + + // Interceptors require an instance of axios + const instance = axios.create() + const { interceptors } = instance + const { + request: requestInterceptor, + response: responseInterceptor + } = interceptors + + // Intercept the request to inject timing information + requestInterceptor.use((config) => { + // eslint-disable-next-line no-param-reassign + config.headers['request-startTime'] = process.hrtime() + + return config + }) + + responseInterceptor.use((response) => { + // Determine total time to complete this request + const start = response.config.headers['request-startTime'] + const end = process.hrtime(start) + const milliseconds = Math.round((end[0] * 1000) + (end[1] / 1000000)) + + response.headers['request-duration'] = milliseconds + + console.log(`Request ${requestId} from ${clientId} to association [concept: ${conceptType}] completed external request in [observed: ${milliseconds} ms]`) + + return response + }) + + return instance.request(requestConfiguration) +} diff --git a/src/utils/cmrVariableAssociation.js b/src/utils/cmrVariableAssociation.js new file mode 100644 index 000000000..d3fc04958 --- /dev/null +++ b/src/utils/cmrVariableAssociation.js @@ -0,0 +1,83 @@ +import axios from 'axios' +import { pickIgnoringCase } from './pickIgnoringCase' +import { downcaseKeys } from './downcaseKeys' + +/** + * Make a request to CMR and return the promise + * @param {Object} params + * @param {Object} params.headers Headers to send to CMR + * @param {Array} params.nonIndexedKeys Parameter names that should not be indexed before sending to CMR + * @param {Object} params.options Additional Options (format) + * @param {Object} params.params Parameters to send to CMR + * @param {String} params.conceptType Concept type to search + */ +export const cmrVariableAssociation = ({ + conceptType, + data, + headers +}) => { + // Default headers + const defaultHeaders = {} + + // Merge default headers into the provided headers and then pick out only permitted values + const permittedHeaders = pickIgnoringCase({ + ...defaultHeaders, + ...headers + }, [ + 'Accept', + 'Authorization', + 'Client-Id', + 'CMR-Request-Id', + 'Content-Type', + 'CMR-Search-After' + ]) + + const { + 'client-id': clientId, + 'cmr-request-id': requestId + } = downcaseKeys(permittedHeaders) + + const { + collectionConceptIds, nativeId, metadata + } = data + + const { concept_id: collectionConcept } = collectionConceptIds[0] + + const requestConfiguration = { + data: metadata, + headers: permittedHeaders, + method: 'PUT', + url: `${process.env.cmrRootUrl}/ingest/collections/${collectionConcept}/variables/${nativeId}. ` + } + + // Interceptors require an instance of axios + const instance = axios.create() + const { interceptors } = instance + const { + request: requestInterceptor, + response: responseInterceptor + } = interceptors + + // Intercept the request to inject timing information + requestInterceptor.use((config) => { + // eslint-disable-next-line no-param-reassign + config.headers['request-startTime'] = process.hrtime() + + return config + }) + + responseInterceptor.use((response) => { + // Determine total time to complete this request + const start = response.config.headers['request-startTime'] + const end = process.hrtime(start) + const milliseconds = Math.round((end[0] * 1000) + (end[1] / 1000000)) + + response.headers['request-duration'] = milliseconds + + console.log(`Request ${requestId} from ${clientId} to association [concept: ${conceptType}] completed external request in [observed: ${milliseconds} ms]`) + + return response + }) + + return instance.request(requestConfiguration) +} diff --git a/src/utils/umm/associationKeyMap.json b/src/utils/umm/associationKeyMap.json new file mode 100644 index 000000000..886fbb65d --- /dev/null +++ b/src/utils/umm/associationKeyMap.json @@ -0,0 +1,12 @@ +{ + "sharedKeys": [ + "toolAssociation", + "associatedItem", + "serviceAssociation" + ], + "ummKeyMappings": { + "toolAssociation": "meta.tool-association", + "serviceAssociation": "meta.service-association", + "associatedItem": "meta.associated-item" + } +} From f24f87a3c64582048464d16eb51a30b970383d4e Mon Sep 17 00:00:00 2001 From: Deep Mistry Date: Wed, 13 Mar 2024 13:53:51 -0400 Subject: [PATCH 2/7] GQL-31: Added Delete Mutation for Association --- src/cmr/concepts/association.js | 76 ++++++++++++++++++++++++-- src/datasources/association.js | 24 ++++++++- src/graphql/handler.js | 4 +- src/resolvers/association.js | 24 ++++++++- src/types/association.graphql | 21 +++++++- src/utils/cmrAssociation.js | 3 +- src/utils/cmrDeleteAssociation.js | 79 ++++++++++++++++++++++++++++ src/utils/umm/associationKeyMap.json | 11 ++-- 8 files changed, 226 insertions(+), 16 deletions(-) create mode 100644 src/utils/cmrDeleteAssociation.js diff --git a/src/cmr/concepts/association.js b/src/cmr/concepts/association.js index 82cc55092..6f4e2c63a 100644 --- a/src/cmr/concepts/association.js +++ b/src/cmr/concepts/association.js @@ -3,13 +3,15 @@ import { pickIgnoringCase } from '../../utils/pickIgnoringCase' import Concept from './concept' import { parseError } from '../../utils/parseError' import { cmrVariableAssociation } from '../../utils/cmrVariableAssociation' +import { cmrAssociation } from '../../utils/cmrAssociation' +import { cmrDeleteAssociation } from '../../utils/cmrDeleteAssociation' export default class Association extends Concept { /** * Parse and return the body of an ingest operation * @param {Object} ingestResponse HTTP response from the CMR endpoint */ - parseCreateBody(ingestResponse) { + parseAssociationBody(ingestResponse) { const { data } = ingestResponse return data[0] @@ -19,7 +21,7 @@ export default class Association extends Concept { * Parses the response from an create * @param {Object} requestInfo Parsed data pertaining to the create operation */ - async parseCreate(requestInfo) { + async parseAssociationResponse(requestInfo) { try { const { ingestKeys @@ -27,7 +29,7 @@ export default class Association extends Concept { const result = await this.getResponse() - const data = this.parseCreateBody(result) + const data = this.parseAssociationBody(result) ingestKeys.forEach((key) => { const cmrKey = snakeCase(key) @@ -41,6 +43,40 @@ export default class Association extends Concept { } } + /** + * Create the provided object into the CMR + * @param {Object} data Parameters provided by the query + * @param {Array} requestedKeys Keys requested by the query + * @param {Object} providedHeaders Headers requested by the query + */ + create(data, requestedKeys, providedHeaders) { + // Default headers + const defaultHeaders = { + 'Content-Type': 'application/vnd.nasa.cmr.umm+json' + } + + this.logKeyRequest(requestedKeys, 'association') + + // Merge default headers into the provided headers and then pick out only permitted values + const permittedHeaders = pickIgnoringCase({ + ...defaultHeaders, + ...providedHeaders + }, [ + 'Accept', + 'Authorization', + 'Client-Id', + 'Content-Type', + 'CMR-Request-Id' + ]) + + this.response = cmrAssociation({ + conceptType: this.getConceptType(), + data, + nonIndexedKeys: this.getNonIndexedKeys(), + headers: permittedHeaders + }) + } + /** * Create the provided object into the CMR * @param {Object} data Parameters provided by the query @@ -74,4 +110,38 @@ export default class Association extends Concept { headers: permittedHeaders }) } + + /** + * Delete the provided object into the CMR + * @param {Object} data Parameters provided by the query + * @param {Array} requestedKeys Keys requested by the query + * @param {Object} providedHeaders Headers requested by the query + */ + deleteAssociation(data, requestedKeys, providedHeaders) { + // Default headers + const defaultHeaders = { + 'Content-Type': 'application/vnd.nasa.cmr.umm+json' + } + + this.logKeyRequest(requestedKeys, 'association') + + // Merge default headers into the provided headers and then pick out only permitted values + const permittedHeaders = pickIgnoringCase({ + ...defaultHeaders, + ...providedHeaders + }, [ + 'Accept', + 'Authorization', + 'Client-Id', + 'Content-Type', + 'CMR-Request-Id' + ]) + + this.response = cmrDeleteAssociation({ + conceptType: this.getConceptType(), + data, + nonIndexedKeys: this.getNonIndexedKeys(), + headers: permittedHeaders + }) + } } diff --git a/src/datasources/association.js b/src/datasources/association.js index e9c69e92d..5c3a6b272 100644 --- a/src/datasources/association.js +++ b/src/datasources/association.js @@ -18,7 +18,7 @@ export const createAssociation = async (args, context, parsedInfo) => { association.create(args, ingestKeys, headers) // Parse the response from CMR - await association.parseCreate(requestInfo) + await association.parseAssociationResponse(requestInfo) // Return a formatted JSON response return association.getFormattedIngestResponse() @@ -45,3 +45,25 @@ export const createVariableAssociation = async (args, context, parsedInfo) => { // Return a formatted JSON response return association.getFormattedIngestResponse() } + +export const deleteAssociation = async (args, context, parsedInfo) => { + const { headers } = context + const { conceptType } = args + + const requestInfo = parseRequestedFields(parsedInfo, associationKeyMap, 'Association') + + const { + ingestKeys + } = requestInfo + + const association = new Association(`${conceptType.toLowerCase()}s`, headers, requestInfo, args) + + // Contact CMR + association.deleteAssociation(args, ingestKeys, headers) + + // Parse the response from CMR + await association.parseAssociationResponse(requestInfo) + + // Return a formatted JSON response + return association.getFormattedIngestResponse() +} diff --git a/src/graphql/handler.js b/src/graphql/handler.js index bea6b6ae0..b2f3143fc 100644 --- a/src/graphql/handler.js +++ b/src/graphql/handler.js @@ -14,7 +14,8 @@ import typeDefs from '../types' import aclSource from '../datasources/acl' import { createAssociation as associationSourceCreate, - createVariableAssociation as variableAssociationSourceCreate + createVariableAssociation as variableAssociationSourceCreate, + deleteAssociation as associationSourceDelete } from '../datasources/association' import collectionDraftProposalSource from '../datasources/collectionDraftProposal' import collectionDraftSource from '../datasources/collectionDraft' @@ -162,6 +163,7 @@ export default startServerAndCreateLambdaHandler( dataSources: { aclSource, associationSourceCreate, + associationSourceDelete, collectionDraftProposalSource, collectionDraftSource, collectionSourceDelete, diff --git a/src/resolvers/association.js b/src/resolvers/association.js index 9e2d358da..56d773e49 100644 --- a/src/resolvers/association.js +++ b/src/resolvers/association.js @@ -5,7 +5,17 @@ export default { createAssociation: async (source, args, context, info) => { const { dataSources } = context - const { conceptType } = args + const { conceptType, nativeId, metadata } = args + + // Checks if nativeId and metadata are present when creating a Variable Association + if (conceptType === 'Variable' && (!nativeId || !metadata)) { + throw new Error('nativeId and metadata required. When creating a Variable Association, nativeId and metadata are required') + } + + // Checks if nativeId or metadata are present when creating a Tool or Service Association + if (conceptType !== 'Variable' && (nativeId || metadata)) { + throw new Error('nativeId or metadata are invalid fields. When creating a Tool or Service Association, nativeId and metadata are not valid field') + } if (conceptType === 'Variable') { const result = await dataSources.variableAssociationSourceCreate( @@ -23,6 +33,18 @@ export default { parseResolveInfo(info) ) + return result + }, + + deleteAssociation: async (source, args, context, info) => { + const { dataSources } = context + + const result = await dataSources.associationSourceDelete( + args, + context, + parseResolveInfo(info) + ) + return result } } diff --git a/src/types/association.graphql b/src/types/association.graphql index 058631d33..22849b779 100644 --- a/src/types/association.graphql +++ b/src/types/association.graphql @@ -1,21 +1,38 @@ +"ConceptType must be one of the enum values." +enum ConceptType { + Service + Tool + Variable +} + type AssociationMutationResponse { toolAssociation: JSON serviceAssociation: JSON variableAssociation: JSON associatedItem: JSON + warnings: [String] } -type Mutation{ +type Mutation { createAssociation ( "The concept id of the associated record" conceptId: String! "The list of collection conceptIds for association" collectionConceptIds: [JSON]! "The concept type of the association" - conceptType: String! + conceptType: ConceptType! "Native Id of the published variable record" nativeId: String "The metadata for the published variable record" metadata: JSON ): AssociationMutationResponse + + deleteAssociation ( + "The concept id of the associated record" + conceptId: String! + "The list of collection conceptIds for association" + collectionConceptIds: [JSON]! + "The concept type of the association" + conceptType: ConceptType! + ): AssociationMutationResponse } \ No newline at end of file diff --git a/src/utils/cmrAssociation.js b/src/utils/cmrAssociation.js index 5b7fa2142..9a76d3a48 100644 --- a/src/utils/cmrAssociation.js +++ b/src/utils/cmrAssociation.js @@ -1,4 +1,5 @@ import axios from 'axios' +import snakecaseKeys from 'snakecase-keys' import { pickIgnoringCase } from './pickIgnoringCase' import { downcaseKeys } from './downcaseKeys' @@ -39,7 +40,7 @@ export const cmrAssociation = ({ const { conceptId, collectionConceptIds } = data const requestConfiguration = { - data: collectionConceptIds, + data: snakecaseKeys(collectionConceptIds), headers: permittedHeaders, method: 'POST', url: `${process.env.cmrRootUrl}/search/${conceptType}/${conceptId}/associations` diff --git a/src/utils/cmrDeleteAssociation.js b/src/utils/cmrDeleteAssociation.js new file mode 100644 index 000000000..aed8e72f9 --- /dev/null +++ b/src/utils/cmrDeleteAssociation.js @@ -0,0 +1,79 @@ +import axios from 'axios' +import snakecaseKeys from 'snakecase-keys' +import { pickIgnoringCase } from './pickIgnoringCase' +import { downcaseKeys } from './downcaseKeys' + +/** + * Make a request to CMR and return the promise + * @param {Object} params + * @param {Object} params.headers Headers to send to CMR + * @param {Array} params.nonIndexedKeys Parameter names that should not be indexed before sending to CMR + * @param {Object} params.options Additional Options (format) + * @param {Object} params.params Parameters to send to CMR + * @param {String} params.conceptType Concept type to search + */ +export const cmrDeleteAssociation = ({ + conceptType, + data, + headers +}) => { + // Default headers + const defaultHeaders = {} + + // Merge default headers into the provided headers and then pick out only permitted values + const permittedHeaders = pickIgnoringCase({ + ...defaultHeaders, + ...headers + }, [ + 'Accept', + 'Authorization', + 'Client-Id', + 'CMR-Request-Id', + 'CMR-Search-After' + ]) + + const { + 'client-id': clientId, + 'cmr-request-id': requestId + } = downcaseKeys(permittedHeaders) + + const { conceptId, collectionConceptIds } = data + + const requestConfiguration = { + data: snakecaseKeys(collectionConceptIds), + headers: permittedHeaders, + method: 'DELETE', + url: `${process.env.cmrRootUrl}/search/${conceptType}/${conceptId}/associations` + } + + // Interceptors require an instance of axios + const instance = axios.create() + const { interceptors } = instance + const { + request: requestInterceptor, + response: responseInterceptor + } = interceptors + + // Intercept the request to inject timing information + requestInterceptor.use((config) => { + // eslint-disable-next-line no-param-reassign + config.headers['request-startTime'] = process.hrtime() + + return config + }) + + responseInterceptor.use((response) => { + // Determine total time to complete this request + const start = response.config.headers['request-startTime'] + const end = process.hrtime(start) + const milliseconds = Math.round((end[0] * 1000) + (end[1] / 1000000)) + + response.headers['request-duration'] = milliseconds + + console.log(`Request ${requestId} from ${clientId} to association [concept: ${conceptType}] completed external request in [observed: ${milliseconds} ms]`) + + return response + }) + + return instance.request(requestConfiguration) +} diff --git a/src/utils/umm/associationKeyMap.json b/src/utils/umm/associationKeyMap.json index 886fbb65d..e55687f33 100644 --- a/src/utils/umm/associationKeyMap.json +++ b/src/utils/umm/associationKeyMap.json @@ -1,12 +1,9 @@ { - "sharedKeys": [ + "jsonKeys": [ "toolAssociation", "associatedItem", "serviceAssociation" ], - "ummKeyMappings": { - "toolAssociation": "meta.tool-association", - "serviceAssociation": "meta.service-association", - "associatedItem": "meta.associated-item" - } -} + "sharedKeys": [], + "ummKeyMappings": {} +} \ No newline at end of file From 102242682f8f5de92c107a32d18bab0aeb3d5ffc Mon Sep 17 00:00:00 2001 From: Deep Mistry Date: Wed, 13 Mar 2024 14:04:18 -0400 Subject: [PATCH 3/7] GQL-31: Code cleanup --- src/cmr/concepts/association.js | 4 ++-- src/types/association.graphql | 2 +- src/utils/cmrDeleteAssociation.js | 2 +- src/utils/cmrVariableAssociation.js | 2 +- src/utils/umm/associationKeyMap.json | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/cmr/concepts/association.js b/src/cmr/concepts/association.js index 6f4e2c63a..a3cab6b21 100644 --- a/src/cmr/concepts/association.js +++ b/src/cmr/concepts/association.js @@ -8,7 +8,7 @@ import { cmrDeleteAssociation } from '../../utils/cmrDeleteAssociation' export default class Association extends Concept { /** - * Parse and return the body of an ingest operation + * Parse and return the body of an create association operation * @param {Object} ingestResponse HTTP response from the CMR endpoint */ parseAssociationBody(ingestResponse) { @@ -18,7 +18,7 @@ export default class Association extends Concept { } /** - * Parses the response from an create + * Parses the response from an create association * @param {Object} requestInfo Parsed data pertaining to the create operation */ async parseAssociationResponse(requestInfo) { diff --git a/src/types/association.graphql b/src/types/association.graphql index 22849b779..480124d0c 100644 --- a/src/types/association.graphql +++ b/src/types/association.graphql @@ -35,4 +35,4 @@ type Mutation { "The concept type of the association" conceptType: ConceptType! ): AssociationMutationResponse -} \ No newline at end of file +} diff --git a/src/utils/cmrDeleteAssociation.js b/src/utils/cmrDeleteAssociation.js index aed8e72f9..9094862e4 100644 --- a/src/utils/cmrDeleteAssociation.js +++ b/src/utils/cmrDeleteAssociation.js @@ -70,7 +70,7 @@ export const cmrDeleteAssociation = ({ response.headers['request-duration'] = milliseconds - console.log(`Request ${requestId} from ${clientId} to association [concept: ${conceptType}] completed external request in [observed: ${milliseconds} ms]`) + console.log(`Request ${requestId} from ${clientId} to delete association [concept: ${conceptType}] completed external request in [observed: ${milliseconds} ms]`) return response }) diff --git a/src/utils/cmrVariableAssociation.js b/src/utils/cmrVariableAssociation.js index d3fc04958..059362b02 100644 --- a/src/utils/cmrVariableAssociation.js +++ b/src/utils/cmrVariableAssociation.js @@ -74,7 +74,7 @@ export const cmrVariableAssociation = ({ response.headers['request-duration'] = milliseconds - console.log(`Request ${requestId} from ${clientId} to association [concept: ${conceptType}] completed external request in [observed: ${milliseconds} ms]`) + console.log(`Request ${requestId} from ${clientId} to variable association [concept: ${conceptType}] completed external request in [observed: ${milliseconds} ms]`) return response }) diff --git a/src/utils/umm/associationKeyMap.json b/src/utils/umm/associationKeyMap.json index e55687f33..77702e35e 100644 --- a/src/utils/umm/associationKeyMap.json +++ b/src/utils/umm/associationKeyMap.json @@ -6,4 +6,4 @@ ], "sharedKeys": [], "ummKeyMappings": {} -} \ No newline at end of file +} From 69d5a093e1d77c19a7588f93d1cdcacb12bed2d1 Mon Sep 17 00:00:00 2001 From: Deep Mistry Date: Thu, 14 Mar 2024 12:13:43 -0400 Subject: [PATCH 4/7] GQL-31: Adding tests for associations --- src/datasources/__tests__/association.test.js | 222 ++++++++++++ .../__tests__/__mocks__/mockServer.js | 8 + src/resolvers/__tests__/association.test.js | 338 ++++++++++++++++++ src/utils/cmrVariableAssociation.js | 3 +- 4 files changed, 570 insertions(+), 1 deletion(-) create mode 100644 src/datasources/__tests__/association.test.js create mode 100644 src/resolvers/__tests__/association.test.js diff --git a/src/datasources/__tests__/association.test.js b/src/datasources/__tests__/association.test.js new file mode 100644 index 000000000..a98f01627 --- /dev/null +++ b/src/datasources/__tests__/association.test.js @@ -0,0 +1,222 @@ +import nock from 'nock' + +let requestInfo + +import { + createAssociation as associationSourceCreate, + deleteAssociation as associationSourceDelete +} from '../association' + +describe('association#create', () => { + const OLD_ENV = process.env + + beforeEach(() => { + jest.resetAllMocks() + + jest.restoreAllMocks() + + process.env = { ...OLD_ENV } + + process.env.cmrRootUrl = 'http://example-cmr.com' + + // Default requestInfo + requestInfo = { + name: 'createAssociation', + alias: 'createAssociation', + args: { + conceptType: 'Tool', + conceptId: 'T12000000', + collectionConceptIds: [ + { + conceptId: 'C12000000' + } + ] + }, + fieldsByTypeName: { + AssociationMutationResponse: { + toolAssociation: { + name: 'toolAssociation', + alias: 'toolAssociation', + args: {}, + fieldsByTypeName: {} + } + } + } + } + }) + + afterEach(() => { + process.env = OLD_ENV + }) + + test('return the parsed association results', async () => { + nock(/example-cmr/) + .defaultReplyHeaders({ + 'CMR-Request-Id': 'abcd-1234-efgh-5678' + }) + .post(/search\/tools\/T12000000\/associations/, JSON.stringify([{ concept_id: 'C12000000' }])) + .reply(201, [{ + tool_association: { + concept_id: 'TLA12000000-CMR', + revision_id: 1 + } + }]) + + const response = await associationSourceCreate({ + conceptType: 'Tool', + conceptId: 'T12000000', + collectionConceptIds: [ + { + conceptId: 'C12000000' + } + ] + + }, { + headers: { + 'Client-Id': 'eed-test-graphql', + 'CMR-Request-Id': 'abcd-1234-efgh-5678' + } + }, requestInfo) + + expect(response).toEqual({ + toolAssociation: { + concept_id: 'TLA12000000-CMR', + revision_id: 1 + } + }) + }) + + test('catches errors received from create CMR', async () => { + nock(/example-cmr/) + .post(/search\/tools\/T12000000\/associations/) + .reply(500, { + errors: ['HTTP Error'] + }, { + 'cmr-request-id': 'abcd-1234-efgh-5678' + }) + + await expect( + associationSourceCreate({ + conceptType: 'Tool', + conceptId: 'T12000000', + collectionConceptIds: [ + { + conceptId: 'C12000000' + } + ] + + }, { + headers: { + 'Client-Id': 'eed-test-graphql', + 'CMR-Request-Id': 'abcd-1234-efgh-5678' + } + }, requestInfo) + ).rejects.toThrow(Error) + }) +}) + +describe('association#delete', () => { + const OLD_ENV = process.env + + beforeEach(() => { + jest.resetAllMocks() + + jest.restoreAllMocks() + + process.env = { ...OLD_ENV } + + process.env.cmrRootUrl = 'http://example-cmr.com' + + // Default requestInfo + requestInfo = { + name: 'deleteAssociation', + alias: 'deleteAssociation', + args: { + conceptType: 'Tool', + conceptId: 'T12000000', + collectionConceptIds: [ + { + conceptId: 'C12000000' + } + ] + }, + fieldsByTypeName: { + AssociationMutationResponse: { + toolAssociation: { + name: 'toolAssociation', + alias: 'toolAssociation', + args: {}, + fieldsByTypeName: {} + } + } + } + } + }) + + afterEach(() => { + process.env = OLD_ENV + }) + + test('returns the parsed association results', async () => { + nock(/example-cmr/) + .defaultReplyHeaders({ + 'CMR-Request-Id': 'abcd-1234-efgh-5678' + }) + .delete(/search\/tools\/T12000000\/associations/) + .reply(201, [{ + tool_association: { + concept_id: 'TLA12000000-CMR', + revision_id: 1 + } + }]) + + const response = await associationSourceDelete({ + conceptType: 'Tool', + conceptId: 'T12000000', + collectionConceptIds: [ + { + conceptId: 'C12000000' + } + ] + }, { + headers: { + 'Client-Id': 'eed-test-graphql', + 'CMR-Request-Id': 'abcd-1234-efgh-5678' + } + }, requestInfo) + + expect(response).toEqual({ + toolAssociation: { + concept_id: 'TLA12000000-CMR', + revision_id: 1 + } + }) + }) + + test('catches errors received from associationDelete', async () => { + nock(/example-cmr/) + .delete(/search\/tools\/T12000000\/associations/) + .reply(500, { + errors: ['HTTP Error'] + }, { + 'cmr-request-id': 'abcd-1234-efgh-5678' + }) + + await expect( + associationSourceDelete({ + conceptType: 'Tool', + conceptId: 'T12000000', + collectionConceptIds: [ + { + conceptId: 'C12000000' + } + ] + }, { + headers: { + 'Client-Id': 'eed-test-graphql', + 'CMR-Request-Id': 'abcd-1234-efgh-5678' + } + }, requestInfo) + ).rejects.toThrow(Error) + }) +}) diff --git a/src/resolvers/__tests__/__mocks__/mockServer.js b/src/resolvers/__tests__/__mocks__/mockServer.js index 0cb2351cc..ba6c12cee 100644 --- a/src/resolvers/__tests__/__mocks__/mockServer.js +++ b/src/resolvers/__tests__/__mocks__/mockServer.js @@ -4,6 +4,11 @@ import resolvers from '../..' import typeDefs from '../../../types' import aclSource from '../../../datasources/acl' +import { + createAssociation as associationSourceCreate, + createVariableAssociation as variableAssociationSourceCreate, + deleteAssociation as associationSourceDelete +} from '../../../datasources/association' import collectionDraftProposalSource from '../../../datasources/collectionDraftProposal' import collectionDraftSource from '../../../datasources/collectionDraft' import collectionVariableDraftsSource from '../../../datasources/collectionVariableDrafts' @@ -56,6 +61,8 @@ export const server = new ApolloServer({ export const buildContextValue = (extraContext) => ({ dataSources: { aclSource, + associationSourceCreate, + associationSourceDelete, collectionDraftProposalSource, collectionDraftSource, collectionSourceDelete, @@ -82,6 +89,7 @@ export const buildContextValue = (extraContext) => ({ toolDraftSource, toolSourceDelete, toolSourceFetch, + variableAssociationSourceCreate, variableDraftSource, variableSourceDelete, variableSourceFetch diff --git a/src/resolvers/__tests__/association.test.js b/src/resolvers/__tests__/association.test.js new file mode 100644 index 000000000..e5216deda --- /dev/null +++ b/src/resolvers/__tests__/association.test.js @@ -0,0 +1,338 @@ +import nock from 'nock' + +import { buildContextValue, server } from './__mocks__/mockServer' + +const contextValue = buildContextValue() + +describe('Association', () => { + const OLD_ENV = process.env + + beforeEach(() => { + process.env = { ...OLD_ENV } + + process.env.cmrRootUrl = 'http://example-cmr.com' + }) + + afterEach(() => { + process.env = OLD_ENV + }) + + describe('Mutation', () => { + describe('createAssociation', () => { + test('returns the cmr results', async () => { + nock(/example-cmr/, { + reqheaders: { + accept: 'application/json, text/plain, */*', + 'content-type': 'application/json', + 'client-id': 'eed-test-graphql', + 'cmr-request-id': 'abcd-1234-efgh-5678' + } + }) + .defaultReplyHeaders({ + 'CMR-Took': 7, + 'CMR-Request-Id': 'abcd-1234-efgh-5678' + }) + .post(/search\/tools\/T12000000\/associations/) + .reply(201, [{ + tool_association: { + concept_id: 'TLA12000000-CMR', + revision_id: 1 + }, + associated_item: { concept_id: 'C12000000' } + }]) + + const response = await server.executeOperation({ + variables: { + conceptId: 'T12000000', + collectionConceptIds: [ + { + conceptId: 'C12000000' + } + ], + conceptType: 'Tool' + }, + query: `mutation CreateAssociation( + $conceptId: String! + $collectionConceptIds: [JSON]! + $conceptType: ConceptType! + ) { + createAssociation( + conceptId: $conceptId + collectionConceptIds: $collectionConceptIds + conceptType: $conceptType + ) { + associatedItem + toolAssociation + } + }` + }, { + contextValue + }) + const { data } = response.body.singleResult + expect(data).toEqual({ + createAssociation: { + associatedItem: { concept_id: 'C12000000' }, + toolAssociation: { + concept_id: 'TLA12000000-CMR', + revision_id: 1 + } + } + }) + }) + + test('returns an error when Metadata or NativeId are provided', async () => { + nock(/example-cmr/, { + reqheaders: { + accept: 'application/json, text/plain, */*', + 'content-type': 'application/json', + 'client-id': 'eed-test-graphql', + 'cmr-request-id': 'abcd-1234-efgh-5678' + } + }) + .defaultReplyHeaders({ + 'CMR-Took': 7, + 'CMR-Request-Id': 'abcd-1234-efgh-5678' + }) + .post(/search\/tools\/T12000000\/associations/) + .reply(201, [{ + tool_association: { + concept_id: 'TLA12000000-CMR', + revision_id: 1 + }, + associated_item: { concept_id: 'C12000000' } + }]) + + const response = await server.executeOperation({ + variables: { + conceptId: 'T12000000', + collectionConceptIds: [ + { + conceptId: 'C12000000' + } + ], + conceptType: 'Tool', + nativeId: 'Variable-1', + metadata: {} + }, + query: `mutation CreateAssociation( + $conceptId: String! + $collectionConceptIds: [JSON]! + $conceptType: ConceptType! + $nativeId: String + $metadata: JSON + ) { + createAssociation( + conceptId: $conceptId + collectionConceptIds: $collectionConceptIds + conceptType: $conceptType + nativeId: $nativeId + metadata: $metadata + ) { + associatedItem + toolAssociation + } + }` + }, { + contextValue + }) + + const { errors } = response.body.singleResult + const { message } = errors[0] + + expect(message).toEqual('nativeId or metadata are invalid fields. When creating a Tool or Service Association, nativeId and metadata are not valid field') + }) + }) + + describe('createVariableAssociation', () => { + test('returns the cmr results', async () => { + nock(/example-cmr/, { + reqheaders: { + accept: 'application/json, text/plain, */*', + 'content-type': 'application/vnd.nasa.cmr.umm+json', + 'client-id': 'eed-test-graphql', + 'cmr-request-id': 'abcd-1234-efgh-5678' + } + }) + .defaultReplyHeaders({ + 'CMR-Took': 7, + 'CMR-Request-Id': 'abcd-1234-efgh-5678' + }) + .put(/ingest\/collections\/C12000000\/variables\/variable-1/) + .reply(200, { + 'variable-association': { + 'concept-id': 'VA1200000007-CMR', + 'revision-id': 1 + } + }) + + const response = await server.executeOperation({ + variables: { + conceptId: 'V120000000', + collectionConceptIds: [ + { + conceptId: 'C12000000' + } + ], + conceptType: 'Variable', + nativeId: 'variable-1', + metadata: { + Name: 'Test Variable', + LongName: 'mock long name' + } + }, + query: `mutation CreateAssociation( + $conceptId: String! + $collectionConceptIds: [JSON]! + $conceptType: ConceptType! + $nativeId: String + $metadata: JSON + ) { + createAssociation( + conceptId: $conceptId + collectionConceptIds: $collectionConceptIds + conceptType: $conceptType + nativeId: $nativeId + metadata: $metadata + ) { + variableAssociation + } + }` + }, { + contextValue + }) + + const { data } = response.body.singleResult + + expect(data).toEqual({ + createAssociation: { + variableAssociation: { + 'concept-id': 'VA1200000007-CMR', + 'revision-id': 1 + } + } + }) + }) + + test('returns an error when Metadata or NativeId not provided', async () => { + nock(/example-cmr/, { + reqheaders: { + accept: 'application/json, text/plain, */*', + 'content-type': 'application/vnd.nasa.cmr.umm+json', + 'client-id': 'eed-test-graphql', + 'cmr-request-id': 'abcd-1234-efgh-5678' + } + }) + .defaultReplyHeaders({ + 'CMR-Took': 7, + 'CMR-Request-Id': 'abcd-1234-efgh-5678' + }) + .put(/ingest\/collections\/C12000000\/variables\/variable-1/) + .reply(200, { + 'variable-association': { + 'concept-id': 'VA1200000007-CMR', + 'revision-id': 1 + } + }) + + const response = await server.executeOperation({ + variables: { + conceptId: 'V120000000', + collectionConceptIds: [ + { + conceptId: 'C12000000' + } + ], + conceptType: 'Variable' + }, + query: `mutation CreateAssociation( + $conceptId: String! + $collectionConceptIds: [JSON]! + $conceptType: ConceptType! + $nativeId: String + $metadata: JSON + ) { + createAssociation( + conceptId: $conceptId + collectionConceptIds: $collectionConceptIds + conceptType: $conceptType + nativeId: $nativeId + metadata: $metadata + ) { + variableAssociation + } + }` + }, { + contextValue + }) + + const { errors } = response.body.singleResult + const { message } = errors[0] + + expect(message).toEqual('nativeId and metadata required. When creating a Variable Association, nativeId and metadata are required') + }) + }) + + describe('deleteAssociation', () => { + test('returns the cmr results', async () => { + nock(/example-cmr/, { + reqheaders: { + accept: 'application/json, text/plain, */*', + 'content-type': 'application/json', + 'client-id': 'eed-test-graphql', + 'cmr-request-id': 'abcd-1234-efgh-5678' + } + }) + .defaultReplyHeaders({ + 'CMR-Took': 7, + 'CMR-Request-Id': 'abcd-1234-efgh-5678' + }) + .delete(/search\/tools\/T12000000\/associations/) + .reply(201, [{ + tool_association: { + concept_id: 'TLA12000000-CMR', + revision_id: 1 + }, + associated_item: { concept_id: 'C12000000' } + }]) + + const response = await server.executeOperation({ + variables: { + conceptId: 'T12000000', + collectionConceptIds: [ + { + conceptId: 'C12000000' + } + ], + conceptType: 'Tool' + }, + query: `mutation DeleteAssociation( + $conceptId: String! + $collectionConceptIds: [JSON]! + $conceptType: ConceptType! + ) { + deleteAssociation( + conceptId: $conceptId + collectionConceptIds: $collectionConceptIds + conceptType: $conceptType + ) { + associatedItem + toolAssociation + } + }` + }, { + contextValue + }) + const { data } = response.body.singleResult + expect(data).toEqual({ + deleteAssociation: { + associatedItem: { concept_id: 'C12000000' }, + toolAssociation: { + concept_id: 'TLA12000000-CMR', + revision_id: 1 + } + } + }) + }) + }) + }) +}) diff --git a/src/utils/cmrVariableAssociation.js b/src/utils/cmrVariableAssociation.js index 059362b02..7b93de7e0 100644 --- a/src/utils/cmrVariableAssociation.js +++ b/src/utils/cmrVariableAssociation.js @@ -1,4 +1,5 @@ import axios from 'axios' +import snakecaseKeys from 'snakecase-keys' import { pickIgnoringCase } from './pickIgnoringCase' import { downcaseKeys } from './downcaseKeys' @@ -41,7 +42,7 @@ export const cmrVariableAssociation = ({ collectionConceptIds, nativeId, metadata } = data - const { concept_id: collectionConcept } = collectionConceptIds[0] + const { concept_id: collectionConcept } = snakecaseKeys(collectionConceptIds[0]) const requestConfiguration = { data: metadata, From 8a9dd0b5eef205aa595ba74afb8d7220a32bc577 Mon Sep 17 00:00:00 2001 From: Deep Mistry Date: Thu, 14 Mar 2024 13:43:44 -0400 Subject: [PATCH 5/7] GQL-31: Updating the new association information to the README --- README.md | 183 +++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 141 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index a36bf1763..bbb83bcec 100644 --- a/README.md +++ b/README.md @@ -1027,64 +1027,163 @@ variables: "ummVersion": "1.2.0" } + +#### Associations +For all supported arguments and columns, see [the schema](src/types/association.graphql). + +The association support associate a Tool, Variable or Service record to a collation record. +##### Example Query + +##### Associating a Tool to a Collection + Mutation CreateAssociation( + $conceptId: String! + $collectionConceptIds: [JSON]! + $conceptType: ConceptType! + ){ + createAssociation( + conceptId: $conceptId + collectionConceptIds: $collectionConceptIds + conceptType: $conceptType + ) { + associatedItem + toolAssociation + } + } + +variables: + + { + "conceptId": "TL12000000-EXAMPLE", + "conceptType": "Tool", + "collectionConceptIds": [ + { + "conceptId": "C12000000-EXAMPLE" + } + ] + } + +##### Associating a Variable to a Collection + Note for Variable association, nativeId and metadata are required params. + + Mutation CreateAssociation( + $conceptId: String! + $collectionConceptIds: [JSON]! + $conceptType: ConceptType! + $nativeId: String + $metadata: JSON + ){ + createAssociation( + conceptId: $conceptId + collectionConceptIds: $collectionConceptIds + conceptType: $conceptType + nativeId: $nativeId + metadata: $metadata + ) { + associatedItem + variableAssociation + } + } + +variables: + + { + "conceptId": "V12000000-EXAMPLE", + "conceptType": "Variable", + "collectionConceptIds": [ + { + "conceptId": "C12000000-EXAMPLE" + } + ], + "nativeId": "Variable native id", + "metadata": {} + } + +##### Disassociation a Tool to a Collection (Delete) + Mutation DeleteAssociation( + $conceptId: String! + $collectionConceptIds: [JSON]! + $conceptType: ConceptType! + ){ + deleteAssociation( + conceptId: $conceptId + collectionConceptIds: $collectionConceptIds + conceptType: $conceptType + ) { + associatedItem + toolAssociation + } + } + +variables: + + { + "conceptId": "TL12000000-EXAMPLE", + "conceptType": "Tool", + "collectionConceptIds": [ + { + "conceptId": "C12000000-EXAMPLE" + } + ] + } + ##### Acls For all supported arguments and columns, see [the schema](src/types/acl.graphql). ##### Example Query -query Acls($params: AclsInput) { - acls(params: $params) { - items { - acl + query Acls($params: AclsInput) { + acls(params: $params) { + items { + acl + } + } } - } -} -variables: + variables: -{ - "params": { - "includeFullAcl": true, - "pageNum": 1, - "pageSize": 20, - "permittedUser": "typical", - "target": "PROVIDER_CONTEXT" - } -} + { + "params": { + "includeFullAcl": true, + "pageNum": 1, + "pageSize": 20, + "permittedUser": "typical", + "target": "PROVIDER_CONTEXT" + } + } ##### Example Response -{ - "data": { - "acls": { - "items": [ - { - "acl": { - "group_permissions": [ - { - "group_id": "AG1200000003-MMT_2", - "permissions": [ - "read" - ] - }, - { - "group_id": "AG1200000001-CMR", - "permissions": [ - "read" - ] + { + "data": { + "acls": { + "items": [ + { + "acl": { + "group_permissions": [ + { + "group_id": "AG1200000003-MMT_2", + "permissions": [ + "read" + ] + }, + { + "group_id": "AG1200000001-CMR", + "permissions": [ + "read" + ] + } + ], + "provider_identity": { + "target": "PROVIDER_CONTEXT", + "provider_id": "MMT_2" + } } - ], - "provider_identity": { - "target": "PROVIDER_CONTEXT", - "provider_id": "MMT_2" } - } + ] } - ] + } } - } -} #### Local graph database: From d840c9a554a1d2c2e96c1fa33135e618e3405e24 Mon Sep 17 00:00:00 2001 From: Deep Mistry Date: Mon, 18 Mar 2024 14:42:08 -0400 Subject: [PATCH 6/7] GQL-31: Fixing minor bug with variable assiociation --- src/utils/cmrVariableAssociation.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/cmrVariableAssociation.js b/src/utils/cmrVariableAssociation.js index 7b93de7e0..ee9994b4f 100644 --- a/src/utils/cmrVariableAssociation.js +++ b/src/utils/cmrVariableAssociation.js @@ -48,7 +48,7 @@ export const cmrVariableAssociation = ({ data: metadata, headers: permittedHeaders, method: 'PUT', - url: `${process.env.cmrRootUrl}/ingest/collections/${collectionConcept}/variables/${nativeId}. ` + url: `${process.env.cmrRootUrl}/ingest/collections/${collectionConcept}/variables/${nativeId}` } // Interceptors require an instance of axios From a443d0a612eba55fb793470521ed445b967eeb16 Mon Sep 17 00:00:00 2001 From: Deep Mistry Date: Thu, 21 Mar 2024 14:11:10 -0400 Subject: [PATCH 7/7] GQL-31: Addressed PR comments --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index bbb83bcec..f290a1206 100644 --- a/README.md +++ b/README.md @@ -1031,7 +1031,7 @@ variables: #### Associations For all supported arguments and columns, see [the schema](src/types/association.graphql). -The association support associate a Tool, Variable or Service record to a collation record. +The association supports associating a Tool, Variable, or Service record to a collection. ##### Example Query ##### Associating a Tool to a Collection