diff --git a/cdk/app/lib/helper/KmsLambdaFunctions.ts b/cdk/app/lib/helper/KmsLambdaFunctions.ts index 8a3463e1..5cf68c54 100644 --- a/cdk/app/lib/helper/KmsLambdaFunctions.ts +++ b/cdk/app/lib/helper/KmsLambdaFunctions.ts @@ -234,6 +234,15 @@ export class LambdaFunctions { 'GET' ) + this.createApiLambda( + scope, + 'getHistoricalConceptsInScheme/handler.js', + 'get-historical-concepts-in-scheme', + 'getHistoricalConceptsInScheme', + '/concepts/historical/concept_scheme/{conceptScheme}', + 'GET' + ) + this.createApiLambda( scope, 'getConcepts/handler.js', @@ -279,6 +288,15 @@ export class LambdaFunctions { 'GET' ) + this.createApiLambda( + scope, + 'getHistoricalConceptVersions/handler.js', + 'get-historical-concept-versions', + 'getHistoricalConceptVersions', + '/concept_versions/historical', + 'GET' + ) + this.createApiLambda( scope, 'getFullPath/handler.js', diff --git a/serverless/src/getCapabilities/__tests__/handler.test.js b/serverless/src/getCapabilities/__tests__/handler.test.js index 3f11ddac..25a2650a 100644 --- a/serverless/src/getCapabilities/__tests__/handler.test.js +++ b/serverless/src/getCapabilities/__tests__/handler.test.js @@ -56,6 +56,8 @@ describe('getCapabilities', () => { expect(result.body).toContain(' { params: 'scheme=', action: 'GET' } + }, + { + ':@': { + name: 'get_historical_concept_versions', + href: '/concept_versions/historical', + params: 'None', + action: 'GET' + } + }, + { + ':@': { + name: 'get_historical_concepts_in_scheme', + href: '/concepts/historical/concept_scheme/{conceptScheme}', + params: 'version=', + action: 'GET' + } } ] diff --git a/serverless/src/getHistoricalConceptVersions/__tests__/handler.test.js b/serverless/src/getHistoricalConceptVersions/__tests__/handler.test.js new file mode 100644 index 00000000..c8344c23 --- /dev/null +++ b/serverless/src/getHistoricalConceptVersions/__tests__/handler.test.js @@ -0,0 +1,244 @@ +import { S3Client } from '@aws-sdk/client-s3' +import { + beforeEach, + describe, + expect, + vi +} from 'vitest' + +import { getApplicationConfig } from '@/shared/getConfig' +import { logAnalyticsData } from '@/shared/logAnalyticsData' + +import { getHistoricalConceptVersions } from '../handler' + +const { mockSend } = vi.hoisted(() => { + process.env.RDF_BUCKET_NAME = 'test-bucket' + + return { mockSend: vi.fn() } +}) + +vi.mock('@/shared/awsClients', () => ({ + getS3Client: () => { + const client = new S3Client({ region: 'us-east-1' }) + client.send = mockSend + + return client + } +})) + +vi.mock('@/shared/getConfig') +vi.mock('@/shared/logAnalyticsData') + +describe('getHistoricalConceptVersions', () => { + beforeEach(() => { + vi.resetAllMocks() + vi.spyOn(console, 'error').mockImplementation(() => {}) + + // Mock getApplicationConfig + getApplicationConfig.mockReturnValue({ + defaultResponseHeaders: { 'X-Test': 'test-header' } + }) + }) + + describe('when successful', () => { + test('should return 200 with the list of version directories that contain a CSV', async () => { + mockSend + // Top-level listing: two candidate version prefixes + .mockResolvedValueOnce({ + CommonPrefixes: [ + { Prefix: 'A/' }, + { Prefix: 'B/' } + ] + }) + // Per-version CSV check for "A" + .mockResolvedValueOnce({ + Contents: [{ Key: 'A/ScienceKeywords.csv' }] + }) + // Per-version CSV check for "B" + .mockResolvedValueOnce({ + Contents: [{ Key: 'B/ScienceKeywords.csv' }] + }) + + const event = {} + const context = {} + const response = await getHistoricalConceptVersions(event, context) + + expect(response.statusCode).toBe(200) + expect(response.headers['X-Test']).toBe('test-header') + expect(JSON.parse(response.body)).toEqual({ historicalVersions: ['A', 'B'] }) + }) + + test('should exclude the draft directory', async () => { + mockSend + .mockResolvedValueOnce({ + CommonPrefixes: [ + { Prefix: 'draft/' }, + { Prefix: 'A/' } + ] + }) + .mockResolvedValueOnce({ + Contents: [{ Key: 'A/ScienceKeywords.csv' }] + }) + + const response = await getHistoricalConceptVersions({}, {}) + + expect(JSON.parse(response.body)).toEqual({ historicalVersions: ['A'] }) + }) + + test('should exclude versions that have no CSV files, e.g. an rdf.xml-only or incomplete export', async () => { + mockSend + .mockResolvedValueOnce({ + CommonPrefixes: [ + { Prefix: 'A/' }, + { Prefix: 'B/' } + ] + }) + // "A" only has an rdf.xml export, no CSV + .mockResolvedValueOnce({ + Contents: [{ Key: 'A/rdf.xml' }] + }) + // "B" has a CSV + .mockResolvedValueOnce({ + Contents: [{ Key: 'B/ScienceKeywords.csv' }] + }) + + const response = await getHistoricalConceptVersions({}, {}) + + expect(JSON.parse(response.body)).toEqual({ historicalVersions: ['B'] }) + }) + + test('should exclude a version whose prefix has no objects at all', async () => { + mockSend + .mockResolvedValueOnce({ + CommonPrefixes: [{ Prefix: 'A/' }] + }) + .mockResolvedValueOnce({}) + + const response = await getHistoricalConceptVersions({}, {}) + + expect(JSON.parse(response.body)).toEqual({ historicalVersions: [] }) + }) + + test('should return an empty array when there are no common prefixes', async () => { + mockSend.mockResolvedValueOnce({}) + + const response = await getHistoricalConceptVersions({}, {}) + + expect(response.statusCode).toBe(200) + expect(JSON.parse(response.body)).toEqual({ historicalVersions: [] }) + // No candidate versions, so no per-version CSV checks should happen + expect(mockSend).toHaveBeenCalledTimes(1) + }) + + test('should paginate through multiple pages of top-level results', async () => { + mockSend + .mockResolvedValueOnce({ + CommonPrefixes: [{ Prefix: 'A/' }], + NextContinuationToken: 'page-2-token' + }) + .mockResolvedValueOnce({ + CommonPrefixes: [{ Prefix: 'B/' }] + }) + .mockResolvedValueOnce({ + Contents: [{ Key: 'A/ScienceKeywords.csv' }] + }) + .mockResolvedValueOnce({ + Contents: [{ Key: 'B/ScienceKeywords.csv' }] + }) + + const response = await getHistoricalConceptVersions({}, {}) + + expect(mockSend).toHaveBeenCalledTimes(4) + expect(JSON.parse(response.body)).toEqual({ historicalVersions: ['A', 'B'] }) + }) + + test('should paginate through multiple pages when checking a single version for CSVs', async () => { + mockSend + .mockResolvedValueOnce({ + CommonPrefixes: [{ Prefix: 'A/' }] + }) + .mockResolvedValueOnce({ + Contents: [{ Key: 'A/rdf.xml' }], + NextContinuationToken: 'version-page-2-token' + }) + .mockResolvedValueOnce({ + Contents: [{ Key: 'A/ScienceKeywords.csv' }] + }) + + const response = await getHistoricalConceptVersions({}, {}) + + expect(mockSend).toHaveBeenCalledTimes(3) + expect(JSON.parse(response.body)).toEqual({ historicalVersions: ['A'] }) + }) + + test('should call logAnalyticsData with the event and context', async () => { + mockSend.mockResolvedValueOnce({ CommonPrefixes: [] }) + + const event = { some: 'event' } + const context = { some: 'context' } + await getHistoricalConceptVersions(event, context) + + expect(logAnalyticsData).toHaveBeenCalledWith({ + event, + context + }) + }) + }) + + describe('when unsuccessful', () => { + test('should return 500 when the S3 request fails', async () => { + mockSend.mockRejectedValueOnce(new Error('The specified bucket does not exist')) + + const response = await getHistoricalConceptVersions({}, {}) + + expect(response.statusCode).toBe(500) + expect(response.headers['X-Test']).toBe('test-header') + expect(JSON.parse(response.body)).toEqual({ + message: 'Failed to fetch version directories' + }) + }) + + test('should log the error to the console', async () => { + const error = new Error('The specified bucket does not exist') + mockSend.mockRejectedValueOnce(error) + + await getHistoricalConceptVersions({}, {}) + + // eslint-disable-next-line no-console + expect(console.error).toHaveBeenCalledWith( + 'Failed to list S3 version directories:', + error.message + ) + }) + + test('should return 500 when checking a version for CSVs fails', async () => { + mockSend + .mockResolvedValueOnce({ + CommonPrefixes: [{ Prefix: 'A/' }] + }) + .mockRejectedValueOnce(new Error('Access denied')) + + const response = await getHistoricalConceptVersions({}, {}) + + expect(response.statusCode).toBe(500) + expect(JSON.parse(response.body)).toEqual({ + message: 'Failed to fetch version directories' + }) + }) + }) + + describe('when RDF_BUCKET_NAME is not set', () => { + test('should throw a clear error at module load instead of silently falling back to a default bucket', async () => { + const originalValue = process.env.RDF_BUCKET_NAME + delete process.env.RDF_BUCKET_NAME + + vi.resetModules() + + await expect(import('../handler')).rejects.toThrow( + 'Missing required environment variable: RDF_BUCKET_NAME' + ) + + process.env.RDF_BUCKET_NAME = originalValue + }) + }) +}) diff --git a/serverless/src/getHistoricalConceptVersions/handler.js b/serverless/src/getHistoricalConceptVersions/handler.js new file mode 100644 index 00000000..14fd9c17 --- /dev/null +++ b/serverless/src/getHistoricalConceptVersions/handler.js @@ -0,0 +1,140 @@ +import { paginateListObjectsV2 } from '@aws-sdk/client-s3' + +import { getS3Client } from '@/shared/awsClients' +import { getApplicationConfig } from '@/shared/getConfig' +import { logAnalyticsData } from '@/shared/logAnalyticsData' + +/** + * S3 bucket name to list version directories from. + * @type {string} + */ +const bucketName = process.env.RDF_BUCKET_NAME + +if (!bucketName) { + throw new Error('Missing required environment variable: RDF_BUCKET_NAME') +} + +/** + * S3 Client from shared configuration + * @type {S3Client} + */ +const s3Client = getS3Client() + +/** + * Lists candidate version "directory" names in the S3 bucket by using a + * delimiter so S3 groups keys into CommonPrefixes (its equivalent of + * folders), excluding the "draft/" prefix. + * + * @returns {Promise>} Array of candidate version/directory names + */ +const listCandidateVersions = async () => { + const candidateVersions = [] + + const paginator = paginateListObjectsV2( + { + client: s3Client, + pageSize: 1000 + }, + { + Bucket: bucketName, + Delimiter: '/' + } + ) + + // eslint-disable-next-line no-restricted-syntax + for await (const page of paginator) { + if (page.CommonPrefixes) { + const prefixes = page.CommonPrefixes + .map((p) => p.Prefix) + .filter(Boolean) + .map((prefix) => prefix.replace(/\/$/, '')) // Strip trailing slash + .filter((name) => name !== 'draft') // Exclude the draft "directory" + + candidateVersions.push(...prefixes) + } + } + + return candidateVersions +} + +/** + * Checks whether a given version "directory" contains at least one + * downloadable CSV file. A version may exist as a top-level prefix while + * only containing an rdf.xml export (or an incomplete export with no CSVs + * at all), so this is used to filter those out. + * + * @param {string} version - Version/directory name, e.g. "1.9.1" + * @returns {Promise} Whether the version contains at least one .csv key + */ +const versionHasCsv = async (version) => { + const paginator = paginateListObjectsV2( + { + client: s3Client, + pageSize: 1000 + }, + { + Bucket: bucketName, + Prefix: `${version}/` + } + ) + + // eslint-disable-next-line no-restricted-syntax + for await (const page of paginator) { + if (page.Contents?.some((object) => object.Key.endsWith('.csv'))) { + return true + } + } + + return false +} + +/** + * Lists top-level "directory" names in the S3 bucket, excluding the + * "draft/" prefix, and excluding any version that has no downloadable CSV + * files (e.g. rdf.xml-only or incomplete exports). + * + * @returns {Promise>} Array of version/directory names + */ +const listVersionDirectories = async () => { + const candidateVersions = await listCandidateVersions() + + const versionCsvChecks = await Promise.all( + candidateVersions.map(async (version) => ({ + version, + hasCsv: await versionHasCsv(version) + })) + ) + + return versionCsvChecks + .filter((result) => result.hasCsv) + .map((result) => result.version) +} + +export const getHistoricalConceptVersions = async (event, context) => { + const { defaultResponseHeaders } = getApplicationConfig() + + logAnalyticsData({ + event, + context + }) + + try { + const historicalVersions = await listVersionDirectories() + + return { + statusCode: 200, + headers: defaultResponseHeaders, + body: JSON.stringify({ historicalVersions }) + } + } catch (error) { + console.error('Failed to list S3 version directories:', error.message) + + return { + statusCode: 500, + headers: defaultResponseHeaders, + body: JSON.stringify({ message: 'Failed to fetch version directories' }) + } + } +} + +export default getHistoricalConceptVersions diff --git a/serverless/src/getHistoricalConceptsInScheme/__tests__/handler.test.js b/serverless/src/getHistoricalConceptsInScheme/__tests__/handler.test.js new file mode 100644 index 00000000..75902594 --- /dev/null +++ b/serverless/src/getHistoricalConceptsInScheme/__tests__/handler.test.js @@ -0,0 +1,289 @@ +import { + beforeEach, + describe, + expect, + vi +} from 'vitest' + +import { getApplicationConfig } from '@/shared/getConfig' +import { logAnalyticsData } from '@/shared/logAnalyticsData' + +import { getHistoricalConceptsInScheme } from '../handler' + +const { mockSend } = vi.hoisted(() => { + process.env.RDF_BUCKET_NAME = 'test-bucket' + + return { mockSend: vi.fn() } +}) + +vi.mock('@/shared/awsClients', () => ({ + getS3Client: () => ({ send: mockSend }) +})) + +vi.mock('@/shared/getConfig') +vi.mock('@/shared/logAnalyticsData') + +describe('getHistoricalConceptsInScheme', () => { + beforeEach(() => { + vi.resetAllMocks() + vi.spyOn(console, 'error').mockImplementation(() => {}) + + // Mock getApplicationConfig + getApplicationConfig.mockReturnValue({ + defaultResponseHeaders: { 'X-Test': 'test-header' } + }) + }) + + describe('when validation errors occur', () => { + test('should return 400 when conceptScheme path parameter is missing', async () => { + const event = { + pathParameters: {}, + queryStringParameters: { version: 'A' } + } + const response = await getHistoricalConceptsInScheme(event, {}) + + expect(response.statusCode).toBe(400) + expect(response.headers['X-Test']).toBe('test-header') + expect(JSON.parse(response.body)).toEqual({ error: 'scheme is required' }) + expect(mockSend).not.toHaveBeenCalled() + }) + + test('should return 400 when version query parameter is missing', async () => { + const event = { + pathParameters: { conceptScheme: 'ScienceKeywords' }, + queryStringParameters: {} + } + const response = await getHistoricalConceptsInScheme(event, {}) + + expect(response.statusCode).toBe(400) + expect(JSON.parse(response.body)).toEqual({ error: 'version is required' }) + expect(mockSend).not.toHaveBeenCalled() + }) + + test('should return 400 when pathParameters and queryStringParameters are absent entirely', async () => { + const response = await getHistoricalConceptsInScheme({}, {}) + + expect(response.statusCode).toBe(400) + expect(JSON.parse(response.body)).toEqual({ error: 'scheme is required' }) + }) + + test('should return 400 when conceptScheme contains invalid characters', async () => { + const event = { + pathParameters: { conceptScheme: 'instruments"; DROP TABLE--' }, + queryStringParameters: { version: 'A' } + } + const response = await getHistoricalConceptsInScheme(event, {}) + + expect(response.statusCode).toBe(400) + expect(JSON.parse(response.body)).toEqual({ error: 'scheme contains invalid characters' }) + expect(mockSend).not.toHaveBeenCalled() + }) + + test('should return 400 when version contains invalid characters', async () => { + const event = { + pathParameters: { conceptScheme: 'instruments' }, + queryStringParameters: { version: 'A\r\nX-Injected: true' } + } + const response = await getHistoricalConceptsInScheme(event, {}) + + expect(response.statusCode).toBe(400) + expect(JSON.parse(response.body)).toEqual({ error: 'version contains invalid characters' }) + expect(mockSend).not.toHaveBeenCalled() + }) + }) + + describe('when successful', () => { + test('should return 200 with the CSV content, matching the scheme case-insensitively', async () => { + // First call: ListObjectsV2 under the version prefix + mockSend.mockResolvedValueOnce({ + Contents: [ + { Key: 'A/ScienceKeywords.csv' }, + { Key: 'A/instruments.csv' } + ] + }) + + // Second call: GetObject for the matched key + mockSend.mockResolvedValueOnce({ + Body: { transformToString: () => Promise.resolve('id,label\n1,Foo\n2,Bar') } + }) + + const event = { + pathParameters: { conceptScheme: 'sciencekeywords' }, + queryStringParameters: { version: 'A' } + } + const response = await getHistoricalConceptsInScheme(event, {}) + + expect(response.statusCode).toBe(200) + expect(response.headers['Content-Type']).toBe('text/csv; charset=utf-8') + expect(response.headers['Content-Disposition']).toBe('attachment; filename="sciencekeywords.csv"') + expect(response.headers['X-Test']).toBe('test-header') + expect(response.body).toBe('id,label\n1,Foo\n2,Bar') + + expect(mockSend).toHaveBeenCalledTimes(2) + }) + + test('should paginate through multiple pages when listing keys', async () => { + mockSend + .mockResolvedValueOnce({ + Contents: [{ Key: 'A/other.csv' }], + NextContinuationToken: 'page-2-token' + }) + .mockResolvedValueOnce({ + Contents: [{ Key: 'A/instruments.csv' }] + }) + .mockResolvedValueOnce({ + Body: { transformToString: () => Promise.resolve('id,label\n1,Sensor') } + }) + + const event = { + pathParameters: { conceptScheme: 'instruments' }, + queryStringParameters: { version: 'A' } + } + const response = await getHistoricalConceptsInScheme(event, {}) + + expect(response.statusCode).toBe(200) + expect(response.body).toBe('id,label\n1,Sensor') + expect(mockSend).toHaveBeenCalledTimes(3) + }) + + test('should call logAnalyticsData with the event and context', async () => { + mockSend.mockResolvedValueOnce({ + Contents: [{ Key: 'A/instruments.csv' }] + }) + + mockSend.mockResolvedValueOnce({ + Body: { transformToString: () => Promise.resolve('id,label') } + }) + + const event = { + pathParameters: { conceptScheme: 'instruments' }, + queryStringParameters: { version: 'A' } + } + const context = { some: 'context' } + await getHistoricalConceptsInScheme(event, context) + + expect(logAnalyticsData).toHaveBeenCalledWith({ + event, + context + }) + }) + }) + + describe('when unsuccessful', () => { + test('should return 404 when no CSV matches the requested scheme', async () => { + mockSend.mockResolvedValueOnce({ + Contents: [{ Key: 'A/instruments.csv' }] + }) + + const event = { + pathParameters: { conceptScheme: 'doesnotexist' }, + queryStringParameters: { version: 'A' } + } + const response = await getHistoricalConceptsInScheme(event, {}) + + expect(response.statusCode).toBe(404) + expect(JSON.parse(response.body)).toEqual({ + error: 'No concept scheme doesnotexist found for version A' + }) + + // Only the list call should happen, never a GetObject + expect(mockSend).toHaveBeenCalledTimes(1) + }) + + test('should return 404 when the version prefix has no objects at all', async () => { + mockSend.mockResolvedValueOnce({}) + + const event = { + pathParameters: { conceptScheme: 'instruments' }, + queryStringParameters: { version: 'unknown-version' } + } + const response = await getHistoricalConceptsInScheme(event, {}) + + expect(response.statusCode).toBe(404) + }) + + test('should return 500 when listing objects fails', async () => { + mockSend.mockRejectedValueOnce(new Error('The specified bucket does not exist')) + + const event = { + pathParameters: { conceptScheme: 'instruments' }, + queryStringParameters: { version: 'A' } + } + const response = await getHistoricalConceptsInScheme(event, {}) + + expect(response.statusCode).toBe(500) + expect(JSON.parse(response.body)).toEqual({ error: 'Failed to fetch concept scheme CSV' }) + }) + + test('should return 500 when downloading the matched object fails', async () => { + mockSend.mockResolvedValueOnce({ + Contents: [{ Key: 'A/instruments.csv' }] + }) + + mockSend.mockRejectedValueOnce(new Error('Access denied')) + + const event = { + pathParameters: { conceptScheme: 'instruments' }, + queryStringParameters: { version: 'A' } + } + const response = await getHistoricalConceptsInScheme(event, {}) + + expect(response.statusCode).toBe(500) + expect(JSON.parse(response.body)).toEqual({ error: 'Failed to fetch concept scheme CSV' }) + }) + + test('should return 500 when the matched object has no Body', async () => { + mockSend.mockResolvedValueOnce({ + Contents: [{ Key: 'A/instruments.csv' }] + }) + + // GetObject resolves, but with no Body on the response + mockSend.mockResolvedValueOnce({}) + + const event = { + pathParameters: { conceptScheme: 'instruments' }, + queryStringParameters: { version: 'A' } + } + const response = await getHistoricalConceptsInScheme(event, {}) + + expect(response.statusCode).toBe(500) + expect(JSON.parse(response.body)).toEqual({ error: 'Failed to fetch concept scheme CSV' }) + + // eslint-disable-next-line no-console + expect(console.error).toHaveBeenCalledWith( + 'Failed to download CSV for scheme=instruments, version=A: No data returned from S3' + ) + }) + + test('should log the error to the console', async () => { + const error = new Error('Access denied') + mockSend.mockRejectedValueOnce(error) + + const event = { + pathParameters: { conceptScheme: 'instruments' }, + queryStringParameters: { version: 'A' } + } + await getHistoricalConceptsInScheme(event, {}) + + // eslint-disable-next-line no-console + expect(console.error).toHaveBeenCalledWith( + 'Failed to download CSV for scheme=instruments, version=A: Access denied' + ) + }) + }) + + describe('when RDF_BUCKET_NAME is not set', () => { + test('should throw a clear error at module load instead of silently falling back to a default bucket', async () => { + const originalValue = process.env.RDF_BUCKET_NAME + delete process.env.RDF_BUCKET_NAME + + vi.resetModules() + + await expect(import('../handler')).rejects.toThrow( + 'Missing required environment variable: RDF_BUCKET_NAME' + ) + + process.env.RDF_BUCKET_NAME = originalValue + }) + }) +}) diff --git a/serverless/src/getHistoricalConceptsInScheme/handler.js b/serverless/src/getHistoricalConceptsInScheme/handler.js new file mode 100644 index 00000000..db697233 --- /dev/null +++ b/serverless/src/getHistoricalConceptsInScheme/handler.js @@ -0,0 +1,190 @@ +import { GetObjectCommand, ListObjectsV2Command } from '@aws-sdk/client-s3' + +import { getS3Client } from '@/shared/awsClients' +import { getApplicationConfig } from '@/shared/getConfig' +import { logAnalyticsData } from '@/shared/logAnalyticsData' + +/** + * S3 bucket name to download the concept scheme CSV from + * @type {string} + */ +const bucketName = process.env.RDF_BUCKET_NAME + +if (!bucketName) { + throw new Error('Missing required environment variable: RDF_BUCKET_NAME') +} + +/** + * Allow-list pattern for the `conceptScheme` and `version` inputs. Both + * values are reflected into the S3 Prefix, the Content-Disposition header, + * and error messages, so they're restricted to characters seen in real + * version/scheme names (letters, digits, dot, hyphen, underscore) to rule + * out header injection or unexpectedly broad S3 prefix listings. + * @type {RegExp} + */ +const SAFE_IDENTIFIER_PATTERN = /^[A-Za-z0-9._-]+$/ + +/** + * S3 Client from shared configuration + * @type {S3Client} + */ +const s3Client = getS3Client() + +/** + * Lists all object keys directly under a version's S3 prefix. + * + * @param {string} versionPrefix - The "{version}/" prefix to list under. + * @returns {Promise>} Array of full S3 object keys. + */ +const listKeysUnderPrefix = async (versionPrefix) => { + const keys = [] + let continuationToken + + /* eslint-disable no-await-in-loop */ + do { + const command = new ListObjectsV2Command({ + Bucket: bucketName, + Prefix: versionPrefix, + ContinuationToken: continuationToken + }) + + const response = await s3Client.send(command) + + if (response.Contents) { + keys.push(...response.Contents.map((obj) => obj.Key).filter(Boolean)) + } + + continuationToken = response.NextContinuationToken + } while (continuationToken) + /* eslint-enable no-await-in-loop */ + + return keys +} + +/** + * Finds the actual S3 key for a scheme's CSV file under a version prefix, + * matching case-insensitively since scheme file names in S3 may be + * lowercase, CamelCase, or any other casing. + * + * @param {Array} keys - Object keys under the version prefix. + * @param {string} versionPrefix - The "{version}/" prefix the keys live under. + * @param {string} scheme - The lowercased scheme name to match against. + * @returns {string|undefined} The matching S3 key, if found. + */ +const findSchemeKey = (keys, versionPrefix, scheme) => keys.find((key) => { + const fileName = key.slice(versionPrefix.length) + + return fileName.toLowerCase() === `${scheme}.csv` +}) + +export const getHistoricalConceptsInScheme = async (event, context) => { + const { defaultResponseHeaders } = getApplicationConfig() + + logAnalyticsData({ + event, + context + }) + + const { pathParameters, queryStringParameters } = event + const { conceptScheme } = pathParameters || {} + const scheme = conceptScheme?.toLowerCase() + const { version } = queryStringParameters || {} + + if (!scheme) { + return { + statusCode: 400, + headers: { + ...defaultResponseHeaders, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ error: 'scheme is required' }) + } + } + + if (!version) { + return { + statusCode: 400, + headers: { + ...defaultResponseHeaders, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ error: 'version is required' }) + } + } + + if (!SAFE_IDENTIFIER_PATTERN.test(scheme)) { + return { + statusCode: 400, + headers: { + ...defaultResponseHeaders, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ error: 'scheme contains invalid characters' }) + } + } + + if (!SAFE_IDENTIFIER_PATTERN.test(version)) { + return { + statusCode: 400, + headers: { + ...defaultResponseHeaders, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ error: 'version contains invalid characters' }) + } + } + + const versionPrefix = `${version}/` + + try { + const keys = await listKeysUnderPrefix(versionPrefix) + const matchedKey = findSchemeKey(keys, versionPrefix, scheme) + + if (!matchedKey) { + return { + statusCode: 404, + headers: { + ...defaultResponseHeaders, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ error: `No concept scheme ${conceptScheme} found for version ${version}` }) + } + } + + const command = new GetObjectCommand({ + Bucket: bucketName, + Key: matchedKey + }) + + const response = await s3Client.send(command) + + if (!response.Body) { + throw new Error('No data returned from S3') + } + + const csvContent = await response.Body.transformToString() + + return { + statusCode: 200, + headers: { + ...defaultResponseHeaders, + 'Content-Type': 'text/csv; charset=utf-8', + 'Content-Disposition': `attachment; filename="${conceptScheme}.csv"` + }, + body: csvContent + } + } catch (error) { + console.error(`Failed to download CSV for scheme=${conceptScheme}, version=${version}: ${error.message}`) + + return { + statusCode: 500, + headers: { + ...defaultResponseHeaders, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ error: 'Failed to fetch concept scheme CSV' }) + } + } +} + +export default getHistoricalConceptsInScheme