diff --git a/serverless-configs/aws-functions.yml b/serverless-configs/aws-functions.yml index 458fafe..a1748be 100644 --- a/serverless-configs/aws-functions.yml +++ b/serverless-configs/aws-functions.yml @@ -37,7 +37,7 @@ createConcept: - http: method: post cors: ${file(./serverless-configs/${self:provider.name}-cors-configuration.yml)} - path: concept/{conceptId} + path: concept createConcepts: handler: serverless/src/createConcepts/handler.default timeout: ${env:LAMBDA_TIMEOUT, '30'} @@ -53,7 +53,7 @@ updateConcept: - http: method: put cors: ${file(./serverless-configs/${self:provider.name}-cors-configuration.yml)} - path: concept/{conceptId} + path: concept deleteConcept: handler: serverless/src/deleteConcept/handler.default diff --git a/serverless/src/createConcept/__tests__/handler.test.js b/serverless/src/createConcept/__tests__/handler.test.js index 6b43680..bf26d31 100644 --- a/serverless/src/createConcept/__tests__/handler.test.js +++ b/serverless/src/createConcept/__tests__/handler.test.js @@ -6,90 +6,165 @@ import { } from 'vitest' import createConcept from '../handler' import conceptIdExists from '../../utils/conceptIdExists' +import getConceptId from '../../utils/getConceptId' import { getApplicationConfig } from '../../utils/getConfig' import { sparqlRequest } from '../../utils/sparqlRequest' // Mock the dependencies vi.mock('../../utils/conceptIdExists') +vi.mock('../../utils/getConceptId') vi.mock('../../utils/getConfig') vi.mock('../../utils/sparqlRequest') describe('createConcept', () => { - const mockEvent = { - body: '...', - pathParameters: { conceptId: '123' } - } - + const mockRdfXml = '...' + const mockEvent = { body: mockRdfXml } const mockDefaultHeaders = { 'Content-Type': 'application/json' } - beforeAll(() => { - vi.spyOn(console, 'error').mockImplementation(() => {}) - vi.spyOn(console, 'log').mockImplementation(() => {}) - }) + const mockConceptId = '123' + const mockConceptIRI = `https://gcmd.earthdata.nasa.gov/kms/concept/${mockConceptId}` beforeEach(() => { vi.resetAllMocks() getApplicationConfig.mockReturnValue({ defaultResponseHeaders: mockDefaultHeaders }) + getConceptId.mockReturnValue(mockConceptId) + vi.spyOn(console, 'log').mockImplementation(() => {}) + vi.spyOn(console, 'error').mockImplementation(() => {}) }) - test('should return 404 if concept already exists', async () => { - conceptIdExists.mockResolvedValue(true) + test('should handle missing body in event', async () => { + const eventWithoutBody = {} - const result = await createConcept(mockEvent) + const result = await createConcept(eventWithoutBody) expect(result).toEqual({ - statusCode: 404, - body: JSON.stringify({ message: 'Concept https://gcmd.earthdata.nasa.gov/kms/concept/123 already exists.' }), + statusCode: 400, + body: JSON.stringify({ + message: 'Error creating concept', + error: 'Missing RDF/XML data in request body' + }), headers: mockDefaultHeaders }) }) - test('should create concept and return 200 if concept does not exist', async () => { + test('should successfully create a concept', async () => { conceptIdExists.mockResolvedValue(false) sparqlRequest.mockResolvedValue({ ok: true }) const result = await createConcept(mockEvent) + expect(getConceptId).toHaveBeenCalledWith(mockRdfXml) + expect(conceptIdExists).toHaveBeenCalledWith(mockConceptIRI) expect(sparqlRequest).toHaveBeenCalledWith({ contentType: 'application/rdf+xml', accept: 'application/rdf+xml', path: '/statements', method: 'POST', - body: mockEvent.body + body: mockRdfXml + }) + + expect(result).toEqual({ + statusCode: 201, + body: JSON.stringify({ + message: 'Successfully created concept', + conceptId: mockConceptId + }), + headers: mockDefaultHeaders }) + }) + + test('should return 409 if concept already exists', async () => { + conceptIdExists.mockResolvedValue(true) + + const result = await createConcept(mockEvent) expect(result).toEqual({ - statusCode: 200, - body: 'Successfully loaded RDF XML into RDF4J', + statusCode: 409, + body: JSON.stringify({ message: `Concept ${mockConceptIRI} already exists.` }), headers: mockDefaultHeaders }) }) - test('should return 500 if sparqlRequest fails', async () => { + test('should handle getConceptId throwing an error', async () => { + getConceptId.mockImplementation(() => { + throw new Error('Invalid XML') + }) + + const result = await createConcept(mockEvent) + + expect(result).toEqual({ + statusCode: 400, + body: JSON.stringify({ + message: 'Error creating concept', + error: 'Invalid XML' + }), + headers: mockDefaultHeaders + }) + }) + + test('should handle missing concept ID', async () => { + getConceptId.mockReturnValue(null) + + const result = await createConcept(mockEvent) + + expect(result).toEqual({ + statusCode: 400, + body: JSON.stringify({ + message: 'Error creating concept', + error: 'Invalid or missing concept ID' + }), + headers: mockDefaultHeaders + }) + }) + + test('should handle sparqlRequest failure', async () => { conceptIdExists.mockResolvedValue(false) sparqlRequest.mockResolvedValue({ ok: false, status: 500, - text: () => Promise.resolve('Server error') + text: async () => 'Internal Server Error' + }) + + const result = await createConcept(mockEvent) + + expect(result).toEqual({ + statusCode: 400, + body: JSON.stringify({ + message: 'Error creating concept', + error: 'HTTP error! status: 500' + }), + headers: mockDefaultHeaders }) + expect(console.log).toHaveBeenCalledWith('Response text:', 'Internal Server Error') + }) + + test('should handle conceptIdExists throwing an error', async () => { + conceptIdExists.mockRejectedValue(new Error('Database error')) + const result = await createConcept(mockEvent) expect(result).toEqual({ - statusCode: 500, - body: 'Error loading RDF XML into RDF4J', + statusCode: 400, + body: JSON.stringify({ + message: 'Error creating concept', + error: 'Database error' + }), headers: mockDefaultHeaders }) }) - test('should return 500 if an error is thrown', async () => { + test('should handle sparqlRequest throwing an error', async () => { conceptIdExists.mockResolvedValue(false) sparqlRequest.mockRejectedValue(new Error('Network error')) const result = await createConcept(mockEvent) expect(result).toEqual({ - statusCode: 500, - body: 'Error loading RDF XML into RDF4J', + statusCode: 400, + body: JSON.stringify({ + message: 'Error creating concept', + error: 'Network error' + }), headers: mockDefaultHeaders }) }) diff --git a/serverless/src/createConcept/handler.js b/serverless/src/createConcept/handler.js index a17e198..fadded5 100644 --- a/serverless/src/createConcept/handler.js +++ b/serverless/src/createConcept/handler.js @@ -1,4 +1,5 @@ import conceptIdExists from '../utils/conceptIdExists' +import getConceptId from '../utils/getConceptId' import { getApplicationConfig } from '../utils/getConfig' import { sparqlRequest } from '../utils/sparqlRequest' @@ -12,8 +13,6 @@ import { sparqlRequest } from '../utils/sparqlRequest' * @function createConcept * @param {Object} event - The Lambda event object. * @param {string} event.body - The RDF/XML representation of the concept to be created. - * @param {Object} event.pathParameters - The path parameters from the API Gateway event. - * @param {string} event.pathParameters.conceptId - The ID of the concept to be created. * @returns {Promise} A promise that resolves to an object containing the statusCode, body, and headers. * * @example @@ -34,22 +33,29 @@ import { sparqlRequest } from '../utils/sparqlRequest' */ const createConcept = async (event) => { const { defaultResponseHeaders } = getApplicationConfig() - const { body: rdfXml } = event - const { conceptId } = event.pathParameters // Assuming the concept ID is passed as a path parameter + const { body: rdfXml } = event || {} // Use empty object as fallback - // Create the basic auth header - const conceptIRI = `https://gcmd.earthdata.nasa.gov/kms/concept/${conceptId}` + try { + if (!rdfXml) { + throw new Error('Missing RDF/XML data in request body') + } - const exists = await conceptIdExists(conceptIRI) - if (exists) { - return { - statusCode: 404, - body: JSON.stringify({ message: `Concept ${conceptIRI} already exists.` }), - headers: defaultResponseHeaders + const conceptId = getConceptId(rdfXml) + if (!conceptId) { + throw new Error('Invalid or missing concept ID') + } + + const conceptIRI = `https://gcmd.earthdata.nasa.gov/kms/concept/${conceptId}` + + const exists = await conceptIdExists(conceptIRI) + if (exists) { + return { + statusCode: 409, + body: JSON.stringify({ message: `Concept ${conceptIRI} already exists.` }), + headers: defaultResponseHeaders + } } - } - try { const response = await sparqlRequest({ contentType: 'application/rdf+xml', accept: 'application/rdf+xml', @@ -67,16 +73,22 @@ const createConcept = async (event) => { console.log('Successfully loaded RDF XML into RDF4J') return { - statusCode: 200, - body: 'Successfully loaded RDF XML into RDF4J', + statusCode: 201, // Changed from 200 to 201 Created + body: JSON.stringify({ + message: 'Successfully created concept', + conceptId + }), headers: defaultResponseHeaders } } catch (error) { - console.error('Error loading RDF XML into RDF4J:', error) + console.error('Error creating concept:', error) return { - statusCode: 500, - body: 'Error loading RDF XML into RDF4J', + statusCode: 400, // Changed from 500 to 400 for client errors + body: JSON.stringify({ + message: 'Error creating concept', + error: error.message + }), headers: defaultResponseHeaders } } diff --git a/serverless/src/deleteConcept/__tests__/handler.test.js b/serverless/src/deleteConcept/__tests__/handler.test.js index 3e6358c..e3b8fbb 100644 --- a/serverless/src/deleteConcept/__tests__/handler.test.js +++ b/serverless/src/deleteConcept/__tests__/handler.test.js @@ -1,169 +1,103 @@ import { describe, + test, expect, vi, - beforeEach, - afterEach + beforeEach } from 'vitest' import deleteConcept from '../handler' +import deleteTriples from '../../utils/deleteTriples' import { getApplicationConfig } from '../../utils/getConfig' -import { sparqlRequest } from '../../utils/sparqlRequest' // Mock the dependencies +vi.mock('../../utils/deleteTriples') vi.mock('../../utils/getConfig') -vi.mock('../../utils/sparqlRequest') describe('deleteConcept', () => { - const mockDefaultHeaders = { 'X-Custom-Header': 'value' } - const mockConceptId = '123' + const mockDefaultHeaders = { 'Content-Type': 'application/json' } const mockEvent = { - pathParameters: { conceptId: mockConceptId } + pathParameters: { conceptId: '123' } } - let consoleLogSpy - let consoleErrorSpy beforeEach(() => { vi.resetAllMocks() - getApplicationConfig.mockReturnValue({ defaultResponseHeaders: mockDefaultHeaders }) - - // Set up spies for console.log and console.error - consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - }) + vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.spyOn(console, 'log').mockImplementation(() => {}) - afterEach(() => { - // Restore the original console methods after each test - consoleLogSpy.mockRestore() - consoleErrorSpy.mockRestore() + getApplicationConfig.mockReturnValue({ defaultResponseHeaders: mockDefaultHeaders }) }) - test('should successfully delete a concept and return 200', async () => { - sparqlRequest.mockResolvedValue({ ok: true }) + test('should successfully delete a concept', async () => { + deleteTriples.mockResolvedValue({ deleteResponse: { ok: true } }) const result = await deleteConcept(mockEvent) - expect(sparqlRequest).toHaveBeenCalledWith({ - contentType: 'application/sparql-update', - accept: 'application/sparql-results+json', - path: '/statements', - method: 'POST', - body: expect.stringContaining(`https://gcmd.earthdata.nasa.gov/kms/concept/${mockConceptId}`) - }) - + expect(deleteTriples).toHaveBeenCalledWith('https://gcmd.earthdata.nasa.gov/kms/concept/123') expect(result).toEqual({ statusCode: 200, - body: JSON.stringify({ message: `Successfully deleted concept: ${mockConceptId}` }), + body: JSON.stringify({ message: 'Successfully deleted concept: 123' }), headers: mockDefaultHeaders }) - - expect(consoleLogSpy).toHaveBeenCalledWith(`Successfully deleted concept: ${mockConceptId}`) }) - test('should handle SPARQL endpoint errors and return 500', async () => { - const errorMessage = 'SPARQL endpoint error' - sparqlRequest.mockResolvedValue({ - ok: false, - status: 400, - text: () => Promise.resolve(errorMessage) - }) + test('should return 500 if deleteTriples fails', async () => { + const mockError = new Error('Delete failed') + deleteTriples.mockRejectedValue(mockError) const result = await deleteConcept(mockEvent) - expect(sparqlRequest).toHaveBeenCalled() expect(result).toEqual({ statusCode: 500, body: JSON.stringify({ message: 'Error deleting concept', - error: 'HTTP error! status: 400' + error: 'Delete failed' }), headers: mockDefaultHeaders }) - - expect(consoleLogSpy).toHaveBeenCalledWith('Response text:', errorMessage) - expect(consoleErrorSpy).toHaveBeenCalledWith('Error deleting concept:', expect.any(Error)) }) - test('should handle unexpected errors and return 500', async () => { - const error = new Error('Unexpected error') - sparqlRequest.mockRejectedValue(error) + test('should return 500 if deleteTriples returns non-ok response', async () => { + deleteTriples.mockResolvedValue({ + deleteResponse: { + ok: false, + status: 400, + text: () => Promise.resolve('Bad Request') + } + }) const result = await deleteConcept(mockEvent) - expect(sparqlRequest).toHaveBeenCalled() expect(result).toEqual({ statusCode: 500, body: JSON.stringify({ message: 'Error deleting concept', - error: 'Unexpected error' + error: 'HTTP error! status: 400' }), headers: mockDefaultHeaders }) - - expect(consoleErrorSpy).toHaveBeenCalledWith('Error deleting concept:', error) }) - test('should construct the correct SPARQL query', async () => { - sparqlRequest.mockResolvedValue({ ok: true }) - - await deleteConcept(mockEvent) - - const expectedQuery = ` - PREFIX skos: - DELETE { - ?s ?p ?o . - } - WHERE { - ?s ?p ?o . - FILTER(?s = ) - } - ` - - expect(sparqlRequest).toHaveBeenCalledWith({ - accept: 'application/sparql-results+json', - body: expectedQuery, - contentType: 'application/sparql-update', - method: 'POST', - path: '/statements' - }) - }) - - test('should handle missing conceptId in path parameters', async () => { + test('should handle missing conceptId', async () => { const eventWithoutConceptId = { pathParameters: {} } const result = await deleteConcept(eventWithoutConceptId) - expect(result).toEqual({ + expect(result).toMatchObject({ statusCode: 500, - body: expect.stringContaining('Error deleting concept'), headers: mockDefaultHeaders }) - expect(consoleErrorSpy).toHaveBeenCalled() - }) - - test('should use the correct content type and accept headers', async () => { - sparqlRequest.mockResolvedValue({ ok: true }) - - await deleteConcept(mockEvent) - - expect(sparqlRequest).toHaveBeenCalledWith( - expect.objectContaining({ - contentType: 'application/sparql-update', - accept: 'application/sparql-results+json' - }) - ) + const body = JSON.parse(result.body) + expect(body).toHaveProperty('message', 'Error deleting concept') + expect(body).toHaveProperty('error') + expect(typeof body.error).toBe('string') }) - test('should use the correct path and method for the SPARQL request', async () => { - sparqlRequest.mockResolvedValue({ ok: true }) + test('should use correct conceptIRI format', async () => { + deleteTriples.mockResolvedValue({ deleteResponse: { ok: true } }) await deleteConcept(mockEvent) - expect(sparqlRequest).toHaveBeenCalledWith( - expect.objectContaining({ - path: '/statements', - method: 'POST' - }) - ) + expect(deleteTriples).toHaveBeenCalledWith('https://gcmd.earthdata.nasa.gov/kms/concept/123') }) }) diff --git a/serverless/src/deleteConcept/handler.js b/serverless/src/deleteConcept/handler.js index 26cf19b..1230445 100644 --- a/serverless/src/deleteConcept/handler.js +++ b/serverless/src/deleteConcept/handler.js @@ -1,5 +1,5 @@ +import deleteTriples from '../utils/deleteTriples' import { getApplicationConfig } from '../utils/getConfig' -import { sparqlRequest } from '../utils/sparqlRequest' /** * Deletes a SKOS Concept from the RDF store based on its rdf:about identifier. @@ -37,26 +37,8 @@ const deleteConcept = async (event) => { // Construct the full IRI const conceptIRI = `https://gcmd.earthdata.nasa.gov/kms/concept/${conceptId}` - // Construct the SPARQL DELETE query - const deleteQuery = ` - PREFIX skos: - DELETE { - ?s ?p ?o . - } - WHERE { - ?s ?p ?o . - FILTER(?s = <${conceptIRI}>) - } - ` - try { - const response = await sparqlRequest({ - contentType: 'application/sparql-update', - accept: 'application/sparql-results+json', - path: '/statements', - method: 'POST', - body: deleteQuery - }) + const { deleteResponse: response } = await deleteTriples(conceptIRI) if (!response.ok) { const responseText = await response.text() @@ -64,8 +46,7 @@ const deleteConcept = async (event) => { throw new Error(`HTTP error! status: ${response.status}`) } - console.log(`Successfully deleted concept: ${conceptId}`) - + // Return success response return { statusCode: 200, body: JSON.stringify({ message: `Successfully deleted concept: ${conceptId}` }), diff --git a/serverless/src/getConcept/handler.js b/serverless/src/getConcept/handler.js index 3cac638..7937e5b 100644 --- a/serverless/src/getConcept/handler.js +++ b/serverless/src/getConcept/handler.js @@ -47,6 +47,7 @@ const getConcept = async (event) => { }) const conceptIRI = `https://gcmd.earthdata.nasa.gov/kms/concept/${conceptId}` + const concept = await getSkosConcept(conceptIRI) const rdfJson = { 'rdf:RDF': { '@xmlns:rdf': 'http://www.w3.org/1999/02/22-rdf-syntax-ns#', @@ -54,7 +55,7 @@ const getConcept = async (event) => { '@xmlns:gcmd': 'https://gcmd.earthdata.nasa.gov/kms#', '@xmlns:kms': 'https://gcmd.earthdata.nasa.gov/kms#', 'gcmd:gcmd': await getGcmdMetadata({ conceptIRI }), - 'skos:Concept': [await getSkosConcept(conceptIRI)] + 'skos:Concept': [concept] } } diff --git a/serverless/src/updateConcept/__tests__/handler.test.js b/serverless/src/updateConcept/__tests__/handler.test.js index 47d26b4..1cfbc80 100644 --- a/serverless/src/updateConcept/__tests__/handler.test.js +++ b/serverless/src/updateConcept/__tests__/handler.test.js @@ -2,24 +2,35 @@ import { describe, expect, vi, - beforeEach + beforeEach, + test } from 'vitest' import updateConcept from '../handler' import conceptIdExists from '../../utils/conceptIdExists' +import deleteTriples from '../../utils/deleteTriples' +import rollback from '../../utils/rollback' +import getConceptId from '../../utils/getConceptId' import { getApplicationConfig } from '../../utils/getConfig' import { sparqlRequest } from '../../utils/sparqlRequest' // Mock the dependencies vi.mock('../../utils/conceptIdExists') +vi.mock('../../utils/deleteTriples') +vi.mock('../../utils/rollback') +vi.mock('../../utils/getConceptId') vi.mock('../../utils/getConfig') vi.mock('../../utils/sparqlRequest') describe('updateConcept', () => { const mockDefaultHeaders = { 'Content-Type': 'application/json' } - const mockEvent = { - body: '...', - pathParameters: { conceptId: '123' } - } + const mockRdfXml = '...' + const mockEvent = { body: mockRdfXml } + const mockConceptId = '123' + const mockDeletedTriples = [{ + s: { value: 'subject' }, + p: { value: 'predicate' }, + o: { value: 'object' } + }] beforeEach(() => { vi.spyOn(console, 'log').mockImplementation(() => {}) @@ -27,6 +38,13 @@ describe('updateConcept', () => { vi.resetAllMocks() getApplicationConfig.mockReturnValue({ defaultResponseHeaders: mockDefaultHeaders }) + getConceptId.mockReturnValue(mockConceptId) + deleteTriples.mockResolvedValue({ + deletedTriples: mockDeletedTriples, + deleteResponse: { ok: true } + }) + + rollback.mockResolvedValue() }) test('should return 404 if concept does not exist', async () => { @@ -34,6 +52,7 @@ describe('updateConcept', () => { const result = await updateConcept(mockEvent) + expect(getConceptId).toHaveBeenCalledWith(mockRdfXml) expect(result).toEqual({ statusCode: 404, body: JSON.stringify({ message: 'Concept https://gcmd.earthdata.nasa.gov/kms/concept/123 not found' }), @@ -41,18 +60,20 @@ describe('updateConcept', () => { }) }) - test('should update concept and return 200 if concept exists', async () => { + test('should update concept and return 200 if concept exists and update succeeds', async () => { conceptIdExists.mockResolvedValue(true) sparqlRequest.mockResolvedValue({ ok: true }) const result = await updateConcept(mockEvent) + expect(getConceptId).toHaveBeenCalledWith(mockRdfXml) + expect(deleteTriples).toHaveBeenCalledWith('https://gcmd.earthdata.nasa.gov/kms/concept/123') expect(sparqlRequest).toHaveBeenCalledWith({ contentType: 'application/rdf+xml', accept: 'application/rdf+xml', path: '/statements', method: 'POST', - body: mockEvent.body + body: mockRdfXml }) expect(result).toEqual({ @@ -62,51 +83,174 @@ describe('updateConcept', () => { }) }) - test('should return 500 if sparqlRequest fails', async () => { + test('should rollback and return 500 if delete succeeds but insert fails', async () => { conceptIdExists.mockResolvedValue(true) sparqlRequest.mockResolvedValue({ ok: false, - status: 500, - text: () => Promise.resolve('Server error') + status: 500 }) const result = await updateConcept(mockEvent) + expect(getConceptId).toHaveBeenCalledWith(mockRdfXml) + expect(deleteTriples).toHaveBeenCalledWith('https://gcmd.earthdata.nasa.gov/kms/concept/123') + expect(sparqlRequest).toHaveBeenCalledWith({ + contentType: 'application/rdf+xml', + accept: 'application/rdf+xml', + path: '/statements', + method: 'POST', + body: mockRdfXml + }) + + expect(rollback).toHaveBeenCalledWith(mockDeletedTriples) + expect(result).toEqual({ statusCode: 500, body: JSON.stringify({ message: 'Error updating concept', - error: 'HTTP error! status: 500' + error: 'HTTP error! insert status: 500' }), headers: mockDefaultHeaders }) }) - test('should return 500 if an error is thrown', async () => { + test('should return 500 if delete operation fails', async () => { conceptIdExists.mockResolvedValue(true) - sparqlRequest.mockRejectedValue(new Error('Network error')) + deleteTriples.mockResolvedValue({ + deletedTriples: mockDeletedTriples, + deleteResponse: { + ok: false, + status: 500 + } + }) const result = await updateConcept(mockEvent) + expect(getConceptId).toHaveBeenCalledWith(mockRdfXml) + expect(deleteTriples).toHaveBeenCalledWith('https://gcmd.earthdata.nasa.gov/kms/concept/123') + expect(sparqlRequest).not.toHaveBeenCalled() + expect(rollback).not.toHaveBeenCalled() + expect(result).toEqual({ statusCode: 500, body: JSON.stringify({ message: 'Error updating concept', - error: 'Network error' + error: 'HTTP error! delete status: 500' + }), + headers: mockDefaultHeaders + }) + }) + + test('should return 500 if rollback fails', async () => { + conceptIdExists.mockResolvedValue(true) + sparqlRequest.mockResolvedValue({ + ok: false, + status: 500 + }) + + rollback.mockRejectedValue(new Error('Rollback failed')) + + const result = await updateConcept(mockEvent) + + expect(getConceptId).toHaveBeenCalledWith(mockRdfXml) + expect(deleteTriples).toHaveBeenCalledWith('https://gcmd.earthdata.nasa.gov/kms/concept/123') + expect(sparqlRequest).toHaveBeenCalled() + expect(rollback).toHaveBeenCalledWith(mockDeletedTriples) + + expect(result).toEqual({ + statusCode: 500, + body: JSON.stringify({ + message: 'Error updating concept', + error: 'Rollback failed' }), headers: mockDefaultHeaders }) }) - test('should handle missing conceptId in path parameters', async () => { - const eventWithoutConceptId = { - body: '...', - pathParameters: {} - } + test('should handle missing body in event', async () => { + const eventWithoutBody = {} - const result = await updateConcept(eventWithoutConceptId) + const result = await updateConcept(eventWithoutBody) - expect(result.statusCode).toBe(404) - expect(JSON.parse(result.body).message).toContain('not found') + expect(getConceptId).not.toHaveBeenCalled() + expect(result).toEqual({ + statusCode: 500, + body: JSON.stringify({ + message: 'Error updating concept', + error: 'Missing RDF/XML data in request body' + }), + headers: mockDefaultHeaders + }) + }) + + test('should handle getConceptId returning null', async () => { + getConceptId.mockReturnValue(null) + + const result = await updateConcept(mockEvent) + + expect(getConceptId).toHaveBeenCalledWith(mockRdfXml) + expect(result).toEqual({ + statusCode: 500, + body: JSON.stringify({ + message: 'Error updating concept', + error: 'Invalid or missing concept ID' + }), + headers: mockDefaultHeaders + }) + }) + + test('should handle getConceptId throwing an error', async () => { + getConceptId.mockImplementation(() => { + throw new Error('Invalid XML') + }) + + const result = await updateConcept(mockEvent) + + expect(getConceptId).toHaveBeenCalledWith(mockRdfXml) + expect(result).toEqual({ + statusCode: 500, + body: JSON.stringify({ + message: 'Error updating concept', + error: 'Invalid XML' + }), + headers: mockDefaultHeaders + }) + }) + + test('should handle conceptIdExists throwing an error', async () => { + conceptIdExists.mockRejectedValue(new Error('Database error')) + + const result = await updateConcept(mockEvent) + + expect(getConceptId).toHaveBeenCalledWith(mockRdfXml) + expect(conceptIdExists).toHaveBeenCalledWith('https://gcmd.earthdata.nasa.gov/kms/concept/123') + expect(result).toEqual({ + statusCode: 500, + body: JSON.stringify({ + message: 'Error updating concept', + error: 'Database error' + }), + headers: mockDefaultHeaders + }) + }) + + test('should handle sparqlRequest throwing an error', async () => { + conceptIdExists.mockResolvedValue(true) + sparqlRequest.mockRejectedValue(new Error('Network error')) + + const result = await updateConcept(mockEvent) + + expect(getConceptId).toHaveBeenCalledWith(mockRdfXml) + expect(deleteTriples).toHaveBeenCalledWith('https://gcmd.earthdata.nasa.gov/kms/concept/123') + expect(sparqlRequest).toHaveBeenCalled() + expect(rollback).toHaveBeenCalledWith(mockDeletedTriples) + expect(result).toEqual({ + statusCode: 500, + body: JSON.stringify({ + message: 'Error updating concept', + error: 'Network error' + }), + headers: mockDefaultHeaders + }) }) }) diff --git a/serverless/src/updateConcept/handler.js b/serverless/src/updateConcept/handler.js index 3ade7c7..c5a3aef 100644 --- a/serverless/src/updateConcept/handler.js +++ b/serverless/src/updateConcept/handler.js @@ -1,6 +1,9 @@ -import conceptIdExists from '../utils/conceptIdExists' import { getApplicationConfig } from '../utils/getConfig' import { sparqlRequest } from '../utils/sparqlRequest' +import conceptIdExists from '../utils/conceptIdExists' +import deleteTriples from '../utils/deleteTriples' +import rollback from '../utils/rollback' +import getConceptId from '../utils/getConceptId' /** * Updates an existing SKOS Concept in the RDF store. @@ -12,8 +15,6 @@ import { sparqlRequest } from '../utils/sparqlRequest' * @function updateConcept * @param {Object} event - The Lambda event object. * @param {string} event.body - The RDF/XML representation of the updated concept. - * @param {Object} event.pathParameters - The path parameters from the API Gateway event. - * @param {string} event.pathParameters.conceptId - The ID of the concept to update. * @returns {Promise} A promise that resolves to an object containing the statusCode, body, and headers. * * @example @@ -32,15 +33,23 @@ import { sparqlRequest } from '../utils/sparqlRequest' * // headers: { ... } * // } */ + const updateConcept = async (event) => { const { defaultResponseHeaders } = getApplicationConfig() - const { body: rdfXml } = event - const { conceptId } = event.pathParameters // Assuming the concept ID is passed as a path parameter - - // Construct the full IRI - const conceptIRI = `https://gcmd.earthdata.nasa.gov/kms/concept/${conceptId}` + const { body: rdfXml } = event || {} // Use empty object as fallback try { + if (!rdfXml) { + throw new Error('Missing RDF/XML data in request body') + } + + const conceptId = getConceptId(rdfXml) + if (!conceptId) { + throw new Error('Invalid or missing concept ID') + } + + const conceptIRI = `https://gcmd.earthdata.nasa.gov/kms/concept/${conceptId}` + const exists = await conceptIdExists(conceptIRI) if (!exists) { return { @@ -50,27 +59,50 @@ const updateConcept = async (event) => { } } - // If the concept exists, proceed with the update - const response = await sparqlRequest({ - contentType: 'application/rdf+xml', - accept: 'application/rdf+xml', - path: '/statements', - method: 'POST', - body: rdfXml - }) - - if (!response.ok) { - const responseText = await response.text() - console.log('Response text:', responseText) - throw new Error(`HTTP error! status: ${response.status}`) + // Delete existing triples and get the deleted data + const { deletedTriples, deleteResponse } = await deleteTriples(conceptIRI) + + if (!deleteResponse.ok) { + throw new Error(`HTTP error! delete status: ${deleteResponse.status}`) } - console.log(`Successfully updated concept: ${conceptId}`) + console.log(`Successfully deleted concept: ${conceptId}`) - return { - statusCode: 200, - body: JSON.stringify({ message: `Successfully updated concept: ${conceptId}` }), - headers: defaultResponseHeaders + // Try to insert the new data + try { + const insertResponse = await sparqlRequest({ + contentType: 'application/rdf+xml', + accept: 'application/rdf+xml', + path: '/statements', + method: 'POST', + body: rdfXml + }) + + if (!insertResponse.ok) { + throw new Error(`HTTP error! insert status: ${insertResponse.status}`) + } + + console.log(`Successfully updated concept: ${conceptId}`) + + return { + statusCode: 200, + body: JSON.stringify({ message: `Successfully updated concept: ${conceptId}` }), + headers: defaultResponseHeaders + } + } catch (insertError) { + console.error('Error inserting new data, rolling back:', insertError) + + // Rollback: reinsert the deleted triples + await rollback(deletedTriples) + + return { + statusCode: 500, + body: JSON.stringify({ + message: 'Error updating concept', + error: insertError.message + }), + headers: defaultResponseHeaders + } } } catch (error) { console.error('Error updating concept:', error) diff --git a/serverless/src/utils/__tests__/deleteTriples.test.js b/serverless/src/utils/__tests__/deleteTriples.test.js new file mode 100644 index 0000000..31f5abc --- /dev/null +++ b/serverless/src/utils/__tests__/deleteTriples.test.js @@ -0,0 +1,153 @@ +import { + describe, + test, + expect, + vi, + beforeEach +} from 'vitest' +import deleteTriples from '../deleteTriples' +import { sparqlRequest } from '../sparqlRequest' + +// Mock the sparqlRequest function +vi.mock('../sparqlRequest') + +describe('deleteTriples', () => { + const mockConceptIRI = 'https://example.com/concept/123' + const mockDeletedTriples = [ + { + s: { value: mockConceptIRI }, + p: { value: 'predicate1' }, + o: { value: 'object1' } + }, + { + s: { value: mockConceptIRI }, + p: { value: 'predicate2' }, + o: { value: 'object2' } + } + ] + + beforeEach(() => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.resetAllMocks() + }) + + test('should successfully delete triples', async () => { + sparqlRequest.mockResolvedValueOnce({ + ok: true, + json: async () => ({ results: { bindings: mockDeletedTriples } }) + }) + + sparqlRequest.mockResolvedValueOnce({ ok: true }) + + const result = await deleteTriples(mockConceptIRI) + + expect(sparqlRequest).toHaveBeenCalledTimes(2) + expect(sparqlRequest).toHaveBeenNthCalledWith(1, expect.objectContaining({ + contentType: 'application/sparql-query', + accept: 'application/sparql-results+json', + method: 'POST', + body: expect.stringContaining(`FILTER(?s = <${mockConceptIRI}>)`) + })) + + expect(sparqlRequest).toHaveBeenNthCalledWith(2, expect.objectContaining({ + contentType: 'application/sparql-update', + accept: 'application/sparql-results+json', + path: '/statements', + method: 'POST', + body: expect.stringContaining(`FILTER(?s = <${mockConceptIRI}>)`) + })) + + expect(result).toEqual({ + deletedTriples: mockDeletedTriples, + deleteResponse: { ok: true } + }) + }) + + test('should throw error if select query fails', async () => { + sparqlRequest.mockResolvedValueOnce({ + ok: false, + status: 400 + }) + + await expect(deleteTriples(mockConceptIRI)).rejects.toThrow('HTTP error! select status: 400') + expect(sparqlRequest).toHaveBeenCalledTimes(1) + }) + + test('should throw error if delete query fails', async () => { + sparqlRequest.mockResolvedValueOnce({ + ok: true, + json: async () => ({ results: { bindings: mockDeletedTriples } }) + }) + + sparqlRequest.mockResolvedValueOnce({ + ok: false, + status: 500 + }) + + await expect(deleteTriples(mockConceptIRI)).rejects.toThrow('HTTP error! delete status: 500') + expect(sparqlRequest).toHaveBeenCalledTimes(2) + }) + + test('should handle empty result from select query', async () => { + sparqlRequest.mockResolvedValueOnce({ + ok: true, + json: async () => ({ results: { bindings: [] } }) + }) + + sparqlRequest.mockResolvedValueOnce({ ok: true }) + + const result = await deleteTriples(mockConceptIRI) + + expect(result).toEqual({ + deletedTriples: [], + deleteResponse: { ok: true } + }) + }) + + test('should propagate unexpected errors', async () => { + const mockError = new Error('Unexpected error') + sparqlRequest.mockRejectedValueOnce(mockError) + + await expect(deleteTriples(mockConceptIRI)).rejects.toThrow('Unexpected error') + }) + + test('should use correct SPARQL queries', async () => { + sparqlRequest.mockResolvedValueOnce({ + ok: true, + json: async () => ({ results: { bindings: mockDeletedTriples } }) + }) + + sparqlRequest.mockResolvedValueOnce({ ok: true }) + + await deleteTriples(mockConceptIRI) + + const selectCall = sparqlRequest.mock.calls[0][0] + const deleteCall = sparqlRequest.mock.calls[1][0] + + expect(selectCall.body).toContain('SELECT ?s ?p ?o') + expect(selectCall.body).toContain(`FILTER(?s = <${mockConceptIRI}>)`) + + expect(deleteCall.body).toContain('DELETE {') + expect(deleteCall.body).toContain(`FILTER(?s = <${mockConceptIRI}>)`) + }) + + test('should handle large number of triples', async () => { + const largeNumberOfTriples = Array(1000).fill().map((_, i) => ({ + s: { value: mockConceptIRI }, + p: { value: `predicate${i}` }, + o: { value: `object${i}` } + })) + + sparqlRequest.mockResolvedValueOnce({ + ok: true, + json: async () => ({ results: { bindings: largeNumberOfTriples } }) + }) + + sparqlRequest.mockResolvedValueOnce({ ok: true }) + + const result = await deleteTriples(mockConceptIRI) + + expect(result.deletedTriples).toHaveLength(1000) + expect(sparqlRequest).toHaveBeenCalledTimes(2) + }) +}) diff --git a/serverless/src/utils/__tests__/getConceptId.test.js b/serverless/src/utils/__tests__/getConceptId.test.js new file mode 100644 index 0000000..03fbadd --- /dev/null +++ b/serverless/src/utils/__tests__/getConceptId.test.js @@ -0,0 +1,115 @@ +import { describe, expect } from 'vitest' +import getConceptId from '../getConceptId' + +describe('getConceptId', () => { + test('should extract concept ID from valid RDF/XML', () => { + const validXml = ` + + + Test Concept + + + ` + expect(getConceptId(validXml)).toBe('123') + }) + + test('should return null for empty concept ID', () => { + const emptyIdXml = ` + + + Test Concept + + + ` + expect(getConceptId(emptyIdXml)).toBe(null) + }) + + test('should throw error for missing skos:Concept element', () => { + const noConceptXml = ` + + + Test Collection + + + ` + expect(() => getConceptId(noConceptXml)).toThrow('Invalid XML: skos:Concept element not found') + }) + + test('should throw error for missing rdf:about attribute', () => { + const noAboutXml = ` + + + Test Concept + + + ` + expect(() => getConceptId(noAboutXml)).toThrow('rdf:about attribute not found in skos:Concept element') + }) + + test('should handle concept ID with special characters', () => { + const specialCharsXml = ` + + + Test Concept + + + ` + expect(getConceptId(specialCharsXml)).toBe('test_123-456') + }) + + test('should throw error for invalid XML', () => { + const invalidXml = ` + + + Incomplete XML + ` + expect(() => getConceptId(invalidXml)).toThrow('Error extracting concept ID:') + }) + + test('should throw error for multiple skos:Concept elements', () => { + const multiConceptXml = ` + + + First Concept + + + Second Concept + + + ` + expect(() => getConceptId(multiConceptXml)).toThrow('Multiple skos:Concept elements found. Only one concept is allowed.') + }) + + test('should handle concept ID with query parameters', () => { + const queryParamXml = ` + + + Test Concept + + + ` + expect(getConceptId(queryParamXml)).toBe('789?version=1.0') + }) + + test('should handle concept ID with fragment identifier', () => { + const fragmentXml = ` + + + Test Concept + + + ` + expect(getConceptId(fragmentXml)).toBe('101#section1') + }) + + test('should handle concept ID with encoded characters', () => { + const encodedXml = ` + + + Test Concept + + + ` + expect(getConceptId(encodedXml)).toBe('test%20concept') + }) +}) diff --git a/serverless/src/utils/__tests__/rollback.test.js b/serverless/src/utils/__tests__/rollback.test.js new file mode 100644 index 0000000..4bd9d6f --- /dev/null +++ b/serverless/src/utils/__tests__/rollback.test.js @@ -0,0 +1,101 @@ +import { + describe, + expect, + vi, + beforeEach +} from 'vitest' +import rollback from '../rollback' +import { sparqlRequest } from '../sparqlRequest' + +vi.mock('../sparqlRequest', () => ({ + sparqlRequest: vi.fn() +})) + +describe('rollback', () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + const mockDeletedTriples = [ + { + s: { value: 'http://example.com/subject1' }, + p: { value: 'http://example.com/predicate1' }, + o: { + type: 'uri', + value: 'http://example.com/object1' + } + }, + { + s: { value: 'http://example.com/subject2' }, + p: { value: 'http://example.com/predicate2' }, + o: { + type: 'literal', + value: 'Literal value' + } + } + ] + + beforeEach(() => { + vi.clearAllMocks() + }) + + test('should successfully rollback when sparqlRequest is successful', async () => { + sparqlRequest.mockResolvedValue({ ok: true }) + + await expect(rollback(mockDeletedTriples)).resolves.not.toThrow() + + expect(sparqlRequest).toHaveBeenCalledTimes(1) + expect(sparqlRequest).toHaveBeenCalledWith(expect.objectContaining({ + contentType: 'application/sparql-update', + accept: 'application/sparql-results+json', + path: '/statements', + method: 'POST', + body: expect.stringContaining('INSERT DATA') + })) + }) + + test('should throw an error when sparqlRequest fails', async () => { + sparqlRequest.mockResolvedValue({ + ok: false, + status: 500 + }) + + await expect(rollback(mockDeletedTriples)).rejects.toThrow('Rollback failed! status: 500') + + expect(sparqlRequest).toHaveBeenCalledTimes(1) + }) + + test('should construct correct SPARQL query from deletedTriples', async () => { + sparqlRequest.mockResolvedValue({ ok: true }) + + await rollback(mockDeletedTriples) + + const expectedQueryParts = [ + 'INSERT DATA {', + ' .', + ' "Literal value" .', + '}' + ] + + expect(sparqlRequest).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.stringContaining(expectedQueryParts[0]) + }) + ) + + expectedQueryParts.forEach((part) => { + expect(sparqlRequest.mock.calls[0][0].body).toContain(part) + }) + }) + + test('should handle empty deletedTriples array', async () => { + sparqlRequest.mockResolvedValue({ ok: true }) + + await expect(rollback([])).resolves.not.toThrow() + + expect(sparqlRequest).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.stringContaining('INSERT DATA {') + }) + ) + }) +}) diff --git a/serverless/src/utils/deleteTriples.js b/serverless/src/utils/deleteTriples.js new file mode 100644 index 0000000..0930deb --- /dev/null +++ b/serverless/src/utils/deleteTriples.js @@ -0,0 +1,62 @@ +import { sparqlRequest } from './sparqlRequest' + +async function deleteTriples(conceptIRI) { + const selectQuery = ` + SELECT ?s ?p ?o + WHERE { + ?s ?p ?o . + FILTER(?s = <${conceptIRI}>) + } + ` + + const deleteQuery = ` + PREFIX skos: + DELETE { + ?s ?p ?o . + } + WHERE { + ?s ?p ?o . + FILTER(?s = <${conceptIRI}>) + } + ` + + try { + // First, select all triples + const selectResponse = await sparqlRequest({ + contentType: 'application/sparql-query', + accept: 'application/sparql-results+json', + method: 'POST', + body: selectQuery + }) + + if (!selectResponse.ok) { + throw new Error(`HTTP error! select status: ${selectResponse.status}`) + } + + const selectData = await selectResponse.json() + const deletedTriples = selectData.results.bindings + + // Then, delete the triples + const deleteResponse = await sparqlRequest({ + contentType: 'application/sparql-update', + accept: 'application/sparql-results+json', + path: '/statements', + method: 'POST', + body: deleteQuery + }) + + if (!deleteResponse.ok) { + throw new Error(`HTTP error! delete status: ${deleteResponse.status}`) + } + + return { + deletedTriples, + deleteResponse + } + } catch (error) { + console.error('Error deleting concept:', error) + throw error + } +} + +export default deleteTriples diff --git a/serverless/src/utils/getConceptId.js b/serverless/src/utils/getConceptId.js new file mode 100644 index 0000000..97bc49a --- /dev/null +++ b/serverless/src/utils/getConceptId.js @@ -0,0 +1,45 @@ +import { XMLParser } from 'fast-xml-parser' + +/** + * Extracts the concept ID from the RDF/XML data. + * + * @param {string} rdfXml - The RDF/XML representation of the concept. + * @returns {string|null} The extracted concept ID or null if not found. + * @throws {Error} If the XML is invalid, doesn't contain a skos:Concept element, or contains multiple concepts. + */ +const getConceptId = (rdfXml) => { + const parser = new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: '@_', + allowBooleanAttributes: true, + isArray: (name) => name === 'skos:Concept' + }) + + try { + const result = parser.parse(rdfXml) + const concepts = result['rdf:RDF']['skos:Concept'] + + if (!concepts || concepts.length === 0) { + throw new Error('Invalid XML: skos:Concept element not found') + } + + if (concepts.length > 1) { + throw new Error('Multiple skos:Concept elements found. Only one concept is allowed.') + } + + const concept = concepts[0] + const aboutAttr = concept['@_rdf:about'] + if (!aboutAttr) { + throw new Error('rdf:about attribute not found in skos:Concept element') + } + + // Extract the concept ID using split and pop + const conceptId = aboutAttr.split('/').pop() + + return conceptId || null + } catch (error) { + throw new Error(`Error extracting concept ID: ${error.message}`) + } +} + +export default getConceptId diff --git a/serverless/src/utils/rollback.js b/serverless/src/utils/rollback.js new file mode 100644 index 0000000..3488d32 --- /dev/null +++ b/serverless/src/utils/rollback.js @@ -0,0 +1,49 @@ +import { sparqlRequest } from './sparqlRequest' + +/** + * Performs a rollback operation by reinserting deleted triples into the RDF store. + * + * This function is used as part of a transaction-like process in updating RDF concepts. + * If an update operation fails after deleting existing triples, this rollback function + * is called to reinsert the deleted triples, effectively undoing the deletion. + * + * @async + * @param {Array} deletedTriples - An array of triple objects, each containing s (subject), + * p (predicate), and o (object) properties with their respective values. + * @throws {Error} Throws an error if the rollback operation fails. + * + * The function constructs a SPARQL INSERT DATA query from the deleted triples and + * sends it to the RDF store using the sparqlRequest utility. If the request is not + * successful (i.e., non-OK response), it throws an error. Any error during the process + * is logged and re-thrown for handling by the caller. + */ +const rollback = async (deletedTriples) => { + const rollbackQuery = ` + INSERT DATA { + ${deletedTriples.map((triple) => `<${triple.s.value}> <${triple.p.value}> ${ + triple.o.type === 'uri' ? `<${triple.o.value}>` : `"${triple.o.value}"` + } .`).join('\n')} + } + ` + + try { + const rollbackResponse = await sparqlRequest({ + contentType: 'application/sparql-update', + accept: 'application/sparql-results+json', + path: '/statements', + method: 'POST', + body: rollbackQuery + }) + + if (!rollbackResponse.ok) { + throw new Error(`Rollback failed! status: ${rollbackResponse.status}`) + } + + console.log('Rollback successful') + } catch (rollbackError) { + console.error('Rollback failed:', rollbackError) + throw rollbackError + } +} + +export default rollback