From 2089efd7609318c96132fdb02b2aead1bfa0ed0f Mon Sep 17 00:00:00 2001 From: Ting Zuge Date: Thu, 23 Jan 2025 10:09:11 -0700 Subject: [PATCH] feat(directory-service): adding resource API endpoints --- .../configuration/configuration.spec.ts | 1 + .../directory-service/src/directory/events.ts | 38 +- .../src/directory/job/resolve.ts | 2 +- .../directory-service/src/directory/mapper.ts | 15 +- .../src/directory/model/resource.spec.ts | 35 +- .../src/directory/model/resource.ts | 8 +- .../src/directory/repository/directory.ts | 12 +- .../src/directory/router/directory.spec.ts | 1 + .../src/directory/router/resource.spec.ts | 428 +++++++++++++++++- .../src/directory/router/resource.swagger.yml | 164 +++++++ .../src/directory/router/resource.ts | 182 +++++++- .../src/directory/types/resource.ts | 7 + apps/directory-service/src/main.ts | 4 +- .../directory-service/src/mongo/repository.ts | 60 ++- 14 files changed, 895 insertions(+), 62 deletions(-) diff --git a/apps/directory-service/src/directory/configuration/configuration.spec.ts b/apps/directory-service/src/directory/configuration/configuration.spec.ts index 104226f83e..51fa594c79 100644 --- a/apps/directory-service/src/directory/configuration/configuration.spec.ts +++ b/apps/directory-service/src/directory/configuration/configuration.spec.ts @@ -29,6 +29,7 @@ describe('configuration', () => { getTaggedResources: jest.fn(), applyTag: jest.fn(), removeTag: jest.fn(), + getResources: jest.fn(), saveResource: jest.fn(), deleteResource: jest.fn(), }; diff --git a/apps/directory-service/src/directory/events.ts b/apps/directory-service/src/directory/events.ts index fc10a11a9f..685d790273 100644 --- a/apps/directory-service/src/directory/events.ts +++ b/apps/directory-service/src/directory/events.ts @@ -1,6 +1,5 @@ -import type { DomainEvent, DomainEventDefinition, User, Stream } from '@abgov/adsp-service-sdk'; -import type { mapTag } from './mapper'; -import { Resource } from './types'; +import type { DomainEvent, DomainEventDefinition, User, Stream, AdspId } from '@abgov/adsp-service-sdk'; +import type { mapResource, mapTag } from './mapper'; const ENTRY_UPDATED = 'entry-updated'; const ENTRY_DELETED = 'entry-deleted'; @@ -9,6 +8,7 @@ const UNTAGGED_RESOURCE = 'untagged-resource'; const RESOURCE_RESOLUTION_FAILED = 'resource-resolution-failed'; type Tag = ReturnType; +type Resource = ReturnType; export const EntryUpdatedDefinition: DomainEventDefinition = { name: ENTRY_UPDATED, @@ -207,10 +207,16 @@ export const entryDeleted = ( }, }); -export const taggedResource = (resource: Resource, tag: Tag, updatedBy: User, isNewResource: boolean): DomainEvent => ({ +export const taggedResource = ( + tenantId: AdspId, + resource: Resource, + tag: Tag, + updatedBy: User, + isNewResource: boolean +): DomainEvent => ({ name: TAGGED_RESOURCE, timestamp: new Date(), - tenantId: resource.tenantId, + tenantId, correlationId: tag.value, context: { tag: tag.value, @@ -218,9 +224,7 @@ export const taggedResource = (resource: Resource, tag: Tag, updatedBy: User, is }, payload: { resource: { - urn: resource.urn.toString(), - name: resource.name, - description: resource.description, + ...resource, isNew: isNewResource, }, tag, @@ -231,21 +235,17 @@ export const taggedResource = (resource: Resource, tag: Tag, updatedBy: User, is }, }); -export const untaggedResource = (resource: Resource, tag: Tag, updatedBy: User): DomainEvent => ({ +export const untaggedResource = (tenantId: AdspId, resource: Resource, tag: Tag, updatedBy: User): DomainEvent => ({ name: UNTAGGED_RESOURCE, timestamp: new Date(), - tenantId: resource.tenantId, + tenantId, correlationId: tag.value, context: { tag: tag.value, resources: resource.urn.toString(), }, payload: { - resource: { - urn: resource.urn.toString(), - name: resource.name, - description: resource.description, - }, + resource, tag, updatedBy: { id: updatedBy.id, @@ -254,18 +254,18 @@ export const untaggedResource = (resource: Resource, tag: Tag, updatedBy: User): }, }); -export const resourceResolutionFailed = (resource: Resource, type: string, error: string): DomainEvent => ({ +export const resourceResolutionFailed = (tenantId: AdspId, urn: AdspId, type: string, error: string): DomainEvent => ({ name: RESOURCE_RESOLUTION_FAILED, timestamp: new Date(), - tenantId: resource.tenantId, + tenantId, correlationId: type, context: { - resources: resource.urn.toString(), + resources: urn.toString(), type, }, payload: { resource: { - urn: resource.urn.toString(), + urn: urn.toString(), }, type, error, diff --git a/apps/directory-service/src/directory/job/resolve.ts b/apps/directory-service/src/directory/job/resolve.ts index 61a1c803be..29b754c6ea 100644 --- a/apps/directory-service/src/directory/job/resolve.ts +++ b/apps/directory-service/src/directory/job/resolve.ts @@ -61,7 +61,7 @@ export function createResolveJob({ logger, tokenProvider, configurationService, context: 'ResolveJob', tenant: tenantId.toString(), }); - eventService.send(resourceResolutionFailed(resource, type?.type, `${err}`)); + eventService.send(resourceResolutionFailed(tenantId, urn, type?.type, `${err}`)); } } }; diff --git a/apps/directory-service/src/directory/mapper.ts b/apps/directory-service/src/directory/mapper.ts index e220712113..20ba899a0e 100644 --- a/apps/directory-service/src/directory/mapper.ts +++ b/apps/directory-service/src/directory/mapper.ts @@ -8,6 +8,9 @@ export function mapTag(apiId: AdspId, tag: Tag) { label: tag.label, value: tag.value, _links: { + self: { + href: `${apiId}:/tags/${tag.value}`, + }, resources: { href: `${apiId}:/tags/${tag.value}/resources`, }, @@ -16,7 +19,7 @@ export function mapTag(apiId: AdspId, tag: Tag) { : null; } -export function mapResource(resource: Resource) { +export function mapResource(apiId: AdspId, resource: Resource) { return resource ? { urn: resource.urn.toString(), @@ -24,7 +27,15 @@ export function mapResource(resource: Resource) { description: resource.description, type: resource.type, _links: { - represents: { href: resource.urn.toString() }, + self: { + href: `${apiId}:/resources/${encodeURIComponent(resource.urn.toString())}`, + }, + represents: { + href: resource.urn.toString(), + }, + tags: { + href: `${apiId}:/resources/${resource.urn}/tags`, + }, }, _embedded: resource.data && { represents: resource.data, diff --git a/apps/directory-service/src/directory/model/resource.spec.ts b/apps/directory-service/src/directory/model/resource.spec.ts index c8a74f4e1b..f7b9227ab0 100644 --- a/apps/directory-service/src/directory/model/resource.spec.ts +++ b/apps/directory-service/src/directory/model/resource.spec.ts @@ -32,6 +32,7 @@ describe('ResourceType', () => { getTaggedResources: jest.fn(), applyTag: jest.fn(), removeTag: jest.fn(), + getResources: jest.fn(), saveResource: jest.fn(), deleteResource: jest.fn(), }; @@ -41,6 +42,7 @@ describe('ResourceType', () => { axiosMock.isAxiosError.mockClear(); directoryMock.getResourceUrl.mockClear(); repositoryMock.deleteResource.mockClear(); + repositoryMock.saveResource.mockClear(); }); it('can be created', () => { @@ -82,7 +84,7 @@ describe('ResourceType', () => { }); describe('resolve', () => { - it('can resolve resource', async () => { + it('can resolve resource and sync', async () => { const type = new ResourceType(loggerMock, directoryMock, repositoryMock, { type: 'test', matcher: '^\\/tests', @@ -99,7 +101,7 @@ describe('ResourceType', () => { axiosMock.get.mockResolvedValueOnce({ data: { testName: name, testDescription: description } }); repositoryMock.saveResource.mockImplementationOnce((resource) => Promise.resolve(resource)); - const result = await type.resolve('token', { tenantId, urn }); + const result = await type.resolve('token', { tenantId, urn }, true); expect(result.tenantId).toBe(tenantId); expect(result.urn).toBe(urn); expect(result.name).toBe(name); @@ -109,6 +111,31 @@ describe('ResourceType', () => { ); }); + it('can resolve resource and not sync', async () => { + const type = new ResourceType(loggerMock, directoryMock, repositoryMock, { + type: 'test', + matcher: '^\\/tests', + namePath: 'testName', + descriptionPath: 'testDescription', + }); + + const resourceUrl = new URL('https://test-service.com/test/v1/tests/123'); + directoryMock.getResourceUrl.mockResolvedValueOnce(resourceUrl); + + const urn = adspId`urn:ads:platform:test-service:v1:/tests/123`; + const name = 'Test 123'; + const description = 'This is a description.'; + axiosMock.get.mockResolvedValueOnce({ data: { testName: name, testDescription: description } }); + repositoryMock.saveResource.mockImplementationOnce((resource) => Promise.resolve(resource)); + + const result = await type.resolve('token', { tenantId, urn }); + expect(result.tenantId).toBe(tenantId); + expect(result.urn).toBe(urn); + expect(result.name).toBe(name); + expect(result.description).toBe(description); + expect(repositoryMock.saveResource).not.toHaveBeenCalled(); + }); + it('can default description getter', async () => { const type = new ResourceType(loggerMock, directoryMock, repositoryMock, { type: 'test', @@ -189,7 +216,7 @@ describe('ResourceType', () => { await expect(type.resolve('token', null)).rejects.toThrow(Error); }); - it('can delete not found resource', async () => { + it('can delete not found resource if sync true', async () => { const type = new ResourceType(loggerMock, directoryMock, repositoryMock, { type: 'test', matcher: '^\\/tests', @@ -214,7 +241,7 @@ describe('ResourceType', () => { expect(repositoryMock.deleteResource).toHaveBeenCalledWith(expect.objectContaining({ tenantId, urn })); }); - it('can not delete on not found resource', async () => { + it('can not delete on not found resource if sync false', async () => { const type = new ResourceType(loggerMock, directoryMock, repositoryMock, { type: 'test', matcher: '^\\/tests', diff --git a/apps/directory-service/src/directory/model/resource.ts b/apps/directory-service/src/directory/model/resource.ts index f46d80fbbe..735dd1ce9c 100644 --- a/apps/directory-service/src/directory/model/resource.ts +++ b/apps/directory-service/src/directory/model/resource.ts @@ -35,7 +35,7 @@ export class ResourceType { return this.matcher.test(urn.resource); } - public async resolve(token: string, resource: Resource, deleteNotFound = false): Promise { + public async resolve(token: string, resource: Resource, sync = false): Promise { if (!this.matches(resource?.urn)) { throw new InvalidOperationError(`Resource type '${this.type}' not matched to resource: ${resource.urn}`); } @@ -64,12 +64,14 @@ export class ResourceType { const name = this.nameGetter(data) || resource.name; const description = this.descriptionGetter(data) || resource.description; if (name !== resource.name || description !== resource.description) { - resource = await this.repository.saveResource({ ...resource, name, description, type: this.type }); + resource = sync + ? await this.repository.saveResource({ ...resource, name, description, type: this.type }) + : { ...resource, name, description }; } return { ...resource, data }; } catch (err) { - if (axios.isAxiosError(err) && err.response?.status === 404 && deleteNotFound) { + if (axios.isAxiosError(err) && err.response?.status === 404 && sync) { // If there's a 404, then the resource doesn't exist. This can happen if the resource was deleted before being resolved. // Delete the missing resource for consistency. await this.repository.deleteResource(resource); diff --git a/apps/directory-service/src/directory/repository/directory.ts b/apps/directory-service/src/directory/repository/directory.ts index fe0a9ef626..61c2b73590 100644 --- a/apps/directory-service/src/directory/repository/directory.ts +++ b/apps/directory-service/src/directory/repository/directory.ts @@ -1,7 +1,7 @@ import { AdspId } from '@abgov/adsp-service-sdk'; import { Results } from '@core-services/core-common'; import { DirectoryEntity } from '../model'; -import { Directory, Criteria, Tag, Resource, TagCriteria } from '../types'; +import { Directory, Criteria, Tag, Resource, TagCriteria, ResourceCriteria } from '../types'; export interface DirectoryRepository { find(top: number, after: string, criteria: Criteria): Promise>; @@ -11,13 +11,21 @@ export interface DirectoryRepository { save(type: DirectoryEntity): Promise; getTags(top: number, after: string, criteria: TagCriteria): Promise>; - getTaggedResources(tenantId: AdspId, tag: string, top: number, after: string): Promise>; + getTaggedResources( + tenantId: AdspId, + tag: string, + top: number, + after: string, + criteria: ResourceCriteria + ): Promise>; applyTag( tag: Tag, resource: Resource ): Promise<{ tag: Tag; resource: Resource; tagged: boolean; isNewResource: boolean }>; removeTag(tag: Tag, resource: Resource): Promise<{ tag?: Tag; resource?: Resource; untagged: boolean }>; + + getResources(top: number, after: string, criteria: ResourceCriteria); saveResource(resource: Resource & { type?: string }): Promise; deleteResource(resource: Resource): Promise; } diff --git a/apps/directory-service/src/directory/router/directory.spec.ts b/apps/directory-service/src/directory/router/directory.spec.ts index 5345a5e060..0bc72ce4d6 100644 --- a/apps/directory-service/src/directory/router/directory.spec.ts +++ b/apps/directory-service/src/directory/router/directory.spec.ts @@ -66,6 +66,7 @@ describe('router', () => { getTaggedResources: jest.fn(), applyTag: jest.fn(), removeTag: jest.fn(), + getResources: jest.fn(), saveResource: jest.fn(), deleteResource: jest.fn(), }; diff --git a/apps/directory-service/src/directory/router/resource.spec.ts b/apps/directory-service/src/directory/router/resource.spec.ts index a02106889f..e7719b8853 100644 --- a/apps/directory-service/src/directory/router/resource.spec.ts +++ b/apps/directory-service/src/directory/router/resource.spec.ts @@ -1,9 +1,18 @@ import { AdspId, adspId, UnauthorizedUserError } from '@abgov/adsp-service-sdk'; -import { InvalidOperationError } from '@core-services/core-common'; +import { InvalidOperationError, NotFoundError } from '@core-services/core-common'; import { Logger } from 'winston'; import { Request, Response } from 'express'; import { ServiceRoles } from '../roles'; -import { createResourceRouter, getTaggedResources, getTags, tagOperation } from './resource'; +import { + createResourceRouter, + getResource, + getResources, + getResourceTags, + getTag, + getTaggedResources, + getTags, + tagOperation, +} from './resource'; describe('resource', () => { const tenantId = adspId`urn:ads:platform:tenant-service:v2:/tenants/test`; @@ -35,6 +44,7 @@ describe('resource', () => { getTaggedResources: jest.fn(), applyTag: jest.fn(), removeTag: jest.fn(), + getResources: jest.fn(), saveResource: jest.fn(), deleteResource: jest.fn(), }; @@ -46,6 +56,7 @@ describe('resource', () => { repositoryMock.applyTag.mockClear(); repositoryMock.removeTag.mockClear(); directoryMock.getResourceUrl.mockClear(); + repositoryMock.getResources.mockClear(); }); it('can create router', () => { @@ -147,6 +158,80 @@ describe('resource', () => { }); }); + describe('getTag', () => { + it('can create handler', () => { + const handler = getTag(apiId, repositoryMock); + expect(handler).toBeTruthy(); + }); + + it('can get tag', async () => { + const req = { + tenant: { id: tenantId }, + user: { tenantId, id: 'tester', name: 'Tester', roles: [ServiceRoles.ResourceBrowser] }, + params: { tag: 'test-label' }, + }; + const res = { send: jest.fn() }; + const next = jest.fn(); + + const page = {}; + const results = [ + { + label: 'Test label', + value: 'test-label', + }, + ]; + repositoryMock.getTags.mockResolvedValueOnce({ results, page }); + + const handler = getTag(apiId, repositoryMock); + await handler(req as unknown as Request, res as unknown as Response, next); + expect(repositoryMock.getTags).toHaveBeenCalledWith( + 1, + null, + expect.objectContaining({ tenantIdEquals: tenantId, valueEquals: req.params.tag }) + ); + expect(res.send).toHaveBeenCalledWith(expect.objectContaining(results[0])); + }); + + it('can call next with unauthorized', async () => { + const req = { + tenant: { id: tenantId }, + user: { tenantId, id: 'tester', name: 'Tester', roles: [] }, + params: { tag: 'test-label' }, + }; + const res = { send: jest.fn() }; + const next = jest.fn(); + + const handler = getTag(apiId, repositoryMock); + await handler(req as unknown as Request, res as unknown as Response, next); + expect(res.send).not.toHaveBeenCalled(); + expect(next).toBeCalledWith(expect.any(UnauthorizedUserError)); + }); + + it('can call next with not found', async () => { + const req = { + tenant: { id: tenantId }, + user: { tenantId, id: 'tester', name: 'Tester', roles: [ServiceRoles.ResourceBrowser] }, + params: { tag: 'test-label' }, + }; + const res = { send: jest.fn() }; + const next = jest.fn(); + + const page = {}; + const results = []; + repositoryMock.getTags.mockResolvedValueOnce({ results, page }); + + const handler = getTag(apiId, repositoryMock); + await handler(req as unknown as Request, res as unknown as Response, next); + expect(repositoryMock.getTags).toHaveBeenCalledWith( + 1, + null, + expect.objectContaining({ tenantIdEquals: tenantId, valueEquals: req.params.tag }) + ); + expect(next).toHaveBeenCalledWith(expect.any(NotFoundError)); + expect(res.send).not.toHaveBeenCalled(); + }); + }); + describe('tagOperation', () => { it('can create handler', () => { const handler = tagOperation(apiId, loggerMock, directoryMock, eventServiceMock, repositoryMock); @@ -339,6 +424,8 @@ describe('resource', () => { const res = { send: jest.fn() }; const next = jest.fn(); + directoryMock.getResourceUrl.mockResolvedValueOnce(new URL('http://file-service/file/v1/files/123')); + const handler = tagOperation(apiId, loggerMock, directoryMock, eventServiceMock, repositoryMock); await handler(req as unknown as Request, res as unknown as Response, next); @@ -449,7 +536,7 @@ describe('resource', () => { value: 'test-tag', }, resource: { - urn: 'urn:ads:platform:file-service:v1', + urn: 'urn:ads:platform:file-service:v1:/files/123', }, }, }; @@ -494,7 +581,7 @@ describe('resource', () => { describe('getTaggedResources', () => { it('can create handler', () => { - const handler = getTaggedResources(repositoryMock); + const handler = getTaggedResources(apiId, repositoryMock); expect(handler).toBeTruthy(); }); @@ -516,9 +603,9 @@ describe('resource', () => { const page = {}; repositoryMock.getTaggedResources.mockResolvedValueOnce({ results, page }); - const handler = getTaggedResources(repositoryMock); + const handler = getTaggedResources(apiId, repositoryMock); await handler(req as unknown as Request, res as unknown as Response, next); - expect(repositoryMock.getTaggedResources).toHaveBeenCalledWith(tenantId, 'test-tag', 10, undefined); + expect(repositoryMock.getTaggedResources).toHaveBeenCalledWith(tenantId, 'test-tag', 10, undefined, null); expect(res.send).toHaveBeenCalledWith( expect.objectContaining({ results: expect.arrayContaining([ @@ -547,9 +634,46 @@ describe('resource', () => { const page = {}; repositoryMock.getTaggedResources.mockResolvedValueOnce({ results, page }); - const handler = getTaggedResources(repositoryMock); + const handler = getTaggedResources(apiId, repositoryMock); + await handler(req as unknown as Request, res as unknown as Response, next); + expect(repositoryMock.getTaggedResources).toHaveBeenCalledWith(tenantId, 'test-tag', 15, '123', null); + expect(res.send).toHaveBeenCalledWith( + expect.objectContaining({ + results: expect.arrayContaining([ + expect.objectContaining({ urn: 'urn:ads:platform:file-service:v1:/files/123' }), + ]), + page, + }) + ); + }); + + it('can get tagged resources with criteria', async () => { + const req = { + tenant: { id: tenantId }, + user: { tenantId, id: 'tester', name: 'Tester', roles: [ServiceRoles.ResourceBrowser] }, + params: { tag: 'test-tag' }, + query: { top: '15', after: '123', criteria: JSON.stringify({ typeEquals: 'test' }) }, + }; + const res = { send: jest.fn() }; + const next = jest.fn(); + + const results = [ + { + urn: adspId`urn:ads:platform:file-service:v1:/files/123`, + }, + ]; + const page = {}; + repositoryMock.getTaggedResources.mockResolvedValueOnce({ results, page }); + + const handler = getTaggedResources(apiId, repositoryMock); await handler(req as unknown as Request, res as unknown as Response, next); - expect(repositoryMock.getTaggedResources).toHaveBeenCalledWith(tenantId, 'test-tag', 15, '123'); + expect(repositoryMock.getTaggedResources).toHaveBeenCalledWith( + tenantId, + 'test-tag', + 15, + '123', + expect.objectContaining({ typeEquals: 'test' }) + ); expect(res.send).toHaveBeenCalledWith( expect.objectContaining({ results: expect.arrayContaining([ @@ -570,7 +694,7 @@ describe('resource', () => { const res = { send: jest.fn() }; const next = jest.fn(); - const handler = getTaggedResources(repositoryMock); + const handler = getTaggedResources(apiId, repositoryMock); await handler(req as unknown as Request, res as unknown as Response, next); expect(res.send).not.toHaveBeenCalled(); expect(next).toHaveBeenCalledWith(expect.any(UnauthorizedUserError)); @@ -615,9 +739,9 @@ describe('resource', () => { }); req.getServiceConfiguration.mockResolvedValueOnce({ getResourceType }); - const handler = getTaggedResources(repositoryMock); + const handler = getTaggedResources(apiId, repositoryMock); await handler(req as unknown as Request, res as unknown as Response, next); - expect(repositoryMock.getTaggedResources).toHaveBeenCalledWith(tenantId, 'test-tag', 10, undefined); + expect(repositoryMock.getTaggedResources).toHaveBeenCalledWith(tenantId, 'test-tag', 10, undefined, null); expect(res.send).toHaveBeenCalledWith( expect.objectContaining({ results: expect.arrayContaining([ @@ -631,4 +755,286 @@ describe('resource', () => { ); }); }); + + describe('getResources', () => { + it('can create handler', () => { + const handler = getResources(apiId, repositoryMock); + expect(handler).toBeTruthy(); + }); + + it('can get resources', async () => { + const req = { + tenant: { id: tenantId }, + user: { tenantId, id: 'tester', name: 'Tester', roles: [ServiceRoles.ResourceBrowser] }, + query: {}, + }; + const res = { send: jest.fn() }; + const next = jest.fn(); + + const page = {}; + const results = [ + { + urn: adspId`urn:ads:platform:file-service:v1:/files/123`, + }, + ]; + repositoryMock.getResources.mockResolvedValueOnce({ results, page }); + + const handler = getResources(apiId, repositoryMock); + await handler(req as unknown as Request, res as unknown as Response, next); + expect(repositoryMock.getResources).toHaveBeenCalledWith( + 10, + undefined, + expect.objectContaining({ tenantIdEquals: tenantId }) + ); + expect(res.send).toHaveBeenCalledWith( + expect.objectContaining({ + page, + results: expect.arrayContaining([expect.objectContaining({ urn: results[0].urn.toString() })]), + }) + ); + }); + + it('can get resources with query params', async () => { + const req = { + tenant: { id: tenantId }, + user: { tenantId, id: 'tester', name: 'Tester', roles: [ServiceRoles.ResourceBrowser] }, + query: { top: '42', after: '123' }, + }; + const res = { send: jest.fn() }; + const next = jest.fn(); + + const page = {}; + const results = [ + { + urn: adspId`urn:ads:platform:file-service:v1:/files/123`, + }, + ]; + repositoryMock.getResources.mockResolvedValueOnce({ results, page }); + + const handler = getResources(apiId, repositoryMock); + await handler(req as unknown as Request, res as unknown as Response, next); + expect(repositoryMock.getResources).toHaveBeenCalledWith( + 42, + '123', + expect.objectContaining({ tenantIdEquals: tenantId }) + ); + expect(res.send).toHaveBeenCalledWith( + expect.objectContaining({ + page, + results: expect.arrayContaining([expect.objectContaining({ urn: results[0].urn.toString() })]), + }) + ); + }); + + it('can get resources with criteria', async () => { + const req = { + tenant: { id: tenantId }, + user: { tenantId, id: 'tester', name: 'Tester', roles: [ServiceRoles.ResourceBrowser] }, + query: { top: '42', after: '123', criteria: JSON.stringify({ typeEquals: 'test' }) }, + }; + const res = { send: jest.fn() }; + const next = jest.fn(); + + const page = {}; + const results = [ + { + urn: adspId`urn:ads:platform:file-service:v1:/files/123`, + }, + ]; + repositoryMock.getResources.mockResolvedValueOnce({ results, page }); + + const handler = getResources(apiId, repositoryMock); + await handler(req as unknown as Request, res as unknown as Response, next); + expect(repositoryMock.getResources).toHaveBeenCalledWith( + 42, + '123', + expect.objectContaining({ tenantIdEquals: tenantId, typeEquals: 'test' }) + ); + expect(res.send).toHaveBeenCalledWith( + expect.objectContaining({ + page, + results: expect.arrayContaining([expect.objectContaining({ urn: results[0].urn.toString() })]), + }) + ); + }); + + it('can call next with unauthorized', async () => { + const req = { + tenant: { id: tenantId }, + user: { tenantId, id: 'tester', name: 'Tester', roles: [] }, + query: {}, + }; + const res = { send: jest.fn() }; + const next = jest.fn(); + + const handler = getResources(apiId, repositoryMock); + await handler(req as unknown as Request, res as unknown as Response, next); + expect(res.send).not.toHaveBeenCalled(); + expect(next).toBeCalledWith(expect.any(UnauthorizedUserError)); + }); + }); + + describe('getResource', () => { + it('can create handler', () => { + const handler = getResource(apiId, repositoryMock); + expect(handler).toBeTruthy(); + }); + + it('can get resource', async () => { + const req = { + tenant: { id: tenantId }, + user: { tenantId, id: 'tester', name: 'Tester', roles: [ServiceRoles.ResourceBrowser] }, + params: { resource: 'urn:ads:platform:file-service:v1:/files/123' }, + }; + const res = { send: jest.fn() }; + const next = jest.fn(); + + const page = {}; + const results = [ + { + urn: adspId`urn:ads:platform:file-service:v1:/files/123`, + }, + ]; + repositoryMock.getResources.mockResolvedValueOnce({ results, page }); + + const handler = getResource(apiId, repositoryMock); + await handler(req as unknown as Request, res as unknown as Response, next); + expect(repositoryMock.getResources).toHaveBeenCalledWith( + 1, + null, + expect.objectContaining({ tenantIdEquals: tenantId, urnEquals: expect.any(AdspId) }) + ); + expect(res.send).toHaveBeenCalledWith(expect.objectContaining({ urn: results[0].urn.toString() })); + }); + + it('can call next with unauthorized', async () => { + const req = { + tenant: { id: tenantId }, + user: { tenantId, id: 'tester', name: 'Tester', roles: [] }, + params: { resource: 'urn:ads:platform:file-service:v1:/files/123' }, + }; + const res = { send: jest.fn() }; + const next = jest.fn(); + + const handler = getResource(apiId, repositoryMock); + await handler(req as unknown as Request, res as unknown as Response, next); + expect(res.send).not.toHaveBeenCalled(); + expect(next).toBeCalledWith(expect.any(UnauthorizedUserError)); + }); + + it('can call next with not found', async () => { + const req = { + tenant: { id: tenantId }, + user: { tenantId, id: 'tester', name: 'Tester', roles: [ServiceRoles.ResourceBrowser] }, + params: { resource: 'urn:ads:platform:file-service:v1:/files/123' }, + }; + const res = { send: jest.fn() }; + const next = jest.fn(); + + const page = {}; + const results = []; + repositoryMock.getResources.mockResolvedValueOnce({ results, page }); + + const handler = getResource(apiId, repositoryMock); + await handler(req as unknown as Request, res as unknown as Response, next); + expect(repositoryMock.getResources).toHaveBeenCalledWith( + 1, + null, + expect.objectContaining({ tenantIdEquals: tenantId, urnEquals: expect.any(AdspId) }) + ); + expect(next).toHaveBeenCalledWith(expect.any(NotFoundError)); + expect(res.send).not.toHaveBeenCalled(); + }); + }); + + describe('getResourceTags', () => { + it('can create handler', () => { + const handler = getResourceTags(apiId, repositoryMock); + expect(handler).toBeTruthy(); + }); + + it('can get tags', async () => { + const req = { + tenant: { id: tenantId }, + user: { tenantId, id: 'tester', name: 'Tester', roles: [ServiceRoles.ResourceBrowser] }, + params: { resource: 'urn:ads:platform:file-service:v1:/files/123' }, + query: {}, + }; + const res = { send: jest.fn() }; + const next = jest.fn(); + + const page = {}; + const results = [ + { + label: 'Test label', + value: 'test-label', + }, + ]; + repositoryMock.getTags.mockResolvedValueOnce({ results, page }); + + const handler = getResourceTags(apiId, repositoryMock); + await handler(req as unknown as Request, res as unknown as Response, next); + expect(repositoryMock.getTags).toHaveBeenCalledWith( + 10, + undefined, + expect.objectContaining({ tenantIdEquals: tenantId, resourceUrnEquals: expect.any(AdspId) }) + ); + expect(res.send).toHaveBeenCalledWith( + expect.objectContaining({ + page, + results: expect.arrayContaining([expect.objectContaining(results[0])]), + }) + ); + }); + + it('can get tags with query params', async () => { + const req = { + tenant: { id: tenantId }, + user: { tenantId, id: 'tester', name: 'Tester', roles: [ServiceRoles.ResourceBrowser] }, + params: { resource: 'urn:ads:platform:file-service:v1:/files/123' }, + query: { top: '42', after: '123' }, + }; + const res = { send: jest.fn() }; + const next = jest.fn(); + + const page = {}; + const results = [ + { + label: 'Test label', + value: 'test-label', + }, + ]; + repositoryMock.getTags.mockResolvedValueOnce({ results, page }); + + const handler = getResourceTags(apiId, repositoryMock); + await handler(req as unknown as Request, res as unknown as Response, next); + expect(repositoryMock.getTags).toHaveBeenCalledWith( + 42, + '123', + expect.objectContaining({ tenantIdEquals: tenantId, resourceUrnEquals: expect.any(AdspId) }) + ); + expect(res.send).toHaveBeenCalledWith( + expect.objectContaining({ + page, + results: expect.arrayContaining([expect.objectContaining(results[0])]), + }) + ); + }); + + it('can call next with unauthorized', async () => { + const req = { + tenant: { id: tenantId }, + user: { tenantId, id: 'tester', name: 'Tester', roles: [] }, + params: { resource: 'urn:ads:platform:file-service:v1:/files/123' }, + query: {}, + }; + const res = { send: jest.fn() }; + const next = jest.fn(); + + const handler = getResourceTags(apiId, repositoryMock); + await handler(req as unknown as Request, res as unknown as Response, next); + expect(res.send).not.toHaveBeenCalled(); + expect(next).toBeCalledWith(expect.any(UnauthorizedUserError)); + }); + }); }); diff --git a/apps/directory-service/src/directory/router/resource.swagger.yml b/apps/directory-service/src/directory/router/resource.swagger.yml index fecf2e8912..2609404081 100644 --- a/apps/directory-service/src/directory/router/resource.swagger.yml +++ b/apps/directory-service/src/directory/router/resource.swagger.yml @@ -151,6 +151,30 @@ components: 401: description: Unauthorized +/resource/v1/tags/{tag}: + get: + tags: + - Resource + description: Retrieves a tag. + parameters: + - name: tag + description: Value of the tag. + in: path + required: true + schema: + type: string + responses: + 200: + description: Request completed successfully + content: + application/json: + schema: + $ref: "#/components/schemas/Tag" + 400: + description: Bad request + 401: + description: Unauthorized + /resource/v1/tags/{tag}/resources: get: tags: @@ -175,6 +199,15 @@ components: required: false schema: type: boolean + - name: criteria + description: Criteria for the resources to return. + in: query + required: false + schema: + type: object + properties: + typeEquals: + type: string - name: tag description: Value of the tag. in: path @@ -214,3 +247,134 @@ components: description: Bad request 401: description: Unauthorized + +/resource/v1/resources: + get: + tags: + - Resource + description: Retrieves resources. + parameters: + - name: criteria + description: Criteria for the resources to return. + in: query + required: false + schema: + type: object + properties: + typeEquals: + type: string + responses: + 200: + description: Request completed successfully + content: + application/json: + schema: + type: object + properties: + results: + type: array + items: + $ref: "#/components/schemas/Resource" + page: + type: object + properties: + next: + description: Cursor for requesting the next next page, if any. Next will be undefined if no more results are available. + example: Ng== + type: string + size: + description: The number of results returned. + example: 10 + type: number + after: + description: | + The cursor used to generate the current page. Note - Do not use this as the *after* parameter in subsequent calls, + as it will just repeat the set just returned. + example: Mw== + type: string + 400: + description: Bad request + 401: + description: Unauthorized + +/resource/v1/resources/{resource}: + get: + tags: + - Resource + description: Retrieves a resource. + parameters: + - name: resource + description: URN of the resource. + in: path + required: true + schema: + type: string + responses: + 200: + description: Request completed successfully + content: + application/json: + schema: + $ref: "#/components/schemas/Resource" + 400: + description: Bad request + 401: + description: Unauthorized + +/resource/v1/resources/{resource}/tags: + get: + tags: + - Resource + description: Retrieves tags of a resource. + parameters: + - name: resource + description: URN of the resource. + in: path + required: true + schema: + type: string + - name: top + description: Number of results to retrieve. + in: query + required: false + schema: + type: number + - name: after + description: Cursor for page of results to retrieve. + in: query + required: false + schema: + type: string + responses: + 200: + description: Request completed successfully + content: + application/json: + schema: + type: object + properties: + results: + type: array + items: + $ref: "#/components/schemas/Tag" + page: + type: object + properties: + next: + description: Cursor for requesting the next next page, if any. Next will be undefined if no more results are available. + example: Ng== + type: string + size: + description: The number of results returned. + example: 10 + type: number + after: + description: | + The cursor used to generate the current page. Note - Do not use this as the *after* parameter in subsequent calls, + as it will just repeat the set just returned. + example: Mw== + type: string + 400: + description: Bad request + 401: + description: Unauthorized diff --git a/apps/directory-service/src/directory/router/resource.ts b/apps/directory-service/src/directory/router/resource.ts index e93e9ab312..c752535aa1 100644 --- a/apps/directory-service/src/directory/router/resource.ts +++ b/apps/directory-service/src/directory/router/resource.ts @@ -6,10 +6,10 @@ import { ServiceDirectory, UnauthorizedUserError, } from '@abgov/adsp-service-sdk'; -import { createValidationHandler, InvalidOperationError } from '@core-services/core-common'; +import { createValidationHandler, InvalidOperationError, NotFoundError } from '@core-services/core-common'; import * as dashify from 'dashify'; import { RequestHandler, Router } from 'express'; -import { body, query } from 'express-validator'; +import { body, param, query } from 'express-validator'; import { Logger } from 'winston'; import { DirectoryRepository } from '../repository'; import { ServiceRoles } from '../roles'; @@ -45,6 +45,34 @@ export function getTags(apiId: AdspId, repository: DirectoryRepository): Request }; } +export function getTag(apiId: AdspId, repository: DirectoryRepository): RequestHandler { + return async (req, res, next) => { + try { + const user = req.user; + const tenantId = req.tenant?.id; + const { tag } = req.params; + + if (!isAllowedUser(user, tenantId, [ServiceRoles.ResourceBrowser, ServiceRoles.ResourceTagger])) { + throw new UnauthorizedUserError('get tags', user); + } + + const { results } = await repository.getTags(1, null, { + tenantIdEquals: tenantId, + valueEquals: tag, + }); + + const [result] = results; + if (!result) { + throw new NotFoundError('tag', tag); + } + + res.send(mapTag(apiId, result)); + } catch (err) { + next(err); + } + }; +} + export function tagOperation( apiId: AdspId, logger: Logger, @@ -113,14 +141,15 @@ export function tagOperation( const { tag, resource, tagged, isNewResource } = await repository.applyTag(targetTag, targetResource); const tagResult = mapTag(apiId, tag); + const resourceResult = mapResource(apiId, resource); result = { tagged, tag: tagResult, - resource: mapResource(resource), + resource: resourceResult, }; if (tagged) { - event = taggedResource(resource, tagResult, user, isNewResource); + event = taggedResource(tenantId, resourceResult, tagResult, user, isNewResource); } break; } @@ -128,14 +157,15 @@ export function tagOperation( const { tag, resource, untagged } = await repository.removeTag(targetTag, targetResource); const tagResult = mapTag(apiId, tag); + const resourceResult = mapResource(apiId, resource); result = { untagged, tag: tagResult, - resource: mapResource(resource), + resource: resourceResult, }; if (untagged) { - event = untaggedResource(resource, tagResult, user); + event = untaggedResource(tenantId, resourceResult, tagResult, user); } break; } @@ -164,29 +194,27 @@ export function tagOperation( }; } -export function getTaggedResources(repository: DirectoryRepository): RequestHandler { +export function getTaggedResources(apiId: AdspId, repository: DirectoryRepository): RequestHandler { return async (req, res, next) => { try { const user = req.user; const tenantId = req.tenant?.id; const { tag } = req.params; - const { top: topValue, after, includeRepresents: includeRepresentsValue } = req.query; + const { top: topValue, after, includeRepresents: includeRepresentsValue, criteria: criteriaValue } = req.query; const top = topValue ? parseInt(topValue as string) : 10; const includeRepresents = includeRepresentsValue === 'true'; + const criteria = criteriaValue ? JSON.parse(criteriaValue as string) : null; if (!isAllowedUser(user, tenantId, [ServiceRoles.ResourceBrowser, ServiceRoles.ResourceTagger])) { throw new UnauthorizedUserError('get tagged resources', user); } - const { results, page } = await repository.getTaggedResources(tenantId, tag, top, after as string); + const { results, page } = await repository.getTaggedResources(tenantId, tag, top, after as string, criteria); // If includeData is true, then resolve the represented resource. // This is effectively a join across APIs. if (includeRepresents) { - const configuration = await req.getServiceConfiguration( - null, - tenantId - ); + const configuration = await req.getServiceConfiguration(); for (const result of results) { const type = configuration.getResourceType(result.urn); @@ -200,7 +228,93 @@ export function getTaggedResources(repository: DirectoryRepository): RequestHand } res.send({ - results: results.map(mapResource), + results: results.map((result) => mapResource(apiId, result)), + page, + }); + } catch (err) { + next(err); + } + }; +} + +export function getResources(apiId: AdspId, repository: DirectoryRepository): RequestHandler { + return async (req, res, next) => { + try { + const user = req.user; + const tenantId = req.tenant?.id; + const { top: topValue, after, criteria: criteriaValue } = req.query; + const top = topValue ? parseInt(topValue as string) : 10; + const criteria = criteriaValue ? JSON.parse(criteriaValue as string) : null; + + if (!isAllowedUser(user, tenantId, [ServiceRoles.ResourceBrowser, ServiceRoles.ResourceTagger])) { + throw new UnauthorizedUserError('get tags', user); + } + + const { results, page } = await repository.getResources(top, after as string, { + ...criteria, + tenantIdEquals: tenantId, + }); + + res.send({ + results: results.map((result) => mapResource(apiId, result)), + page, + }); + } catch (err) { + next(err); + } + }; +} + +export function getResource(apiId: AdspId, repository: DirectoryRepository): RequestHandler { + return async (req, res, next) => { + try { + const user = req.user; + const tenantId = req.tenant?.id; + const { resource: resourceValue } = req.params; + const resource = AdspId.parse(resourceValue as string); + + if (!isAllowedUser(user, tenantId, [ServiceRoles.ResourceBrowser, ServiceRoles.ResourceTagger])) { + throw new UnauthorizedUserError('get tags', user); + } + + const { results } = await repository.getResources(1, null, { + tenantIdEquals: tenantId, + urnEquals: resource, + }); + + const [result] = results; + if (!result) { + throw new NotFoundError('resource', resourceValue); + } + + res.send(mapResource(apiId, result)); + } catch (err) { + next(err); + } + }; +} + +export function getResourceTags(apiId: AdspId, repository: DirectoryRepository): RequestHandler { + return async (req, res, next) => { + try { + const user = req.user; + const tenantId = req.tenant?.id; + const { resource: resourceValue } = req.params; + const { top: topValue, after } = req.query; + const top = topValue ? parseInt(topValue as string) : 10; + const resource = AdspId.parse(resourceValue as string); + + if (!isAllowedUser(user, tenantId, [ServiceRoles.ResourceBrowser, ServiceRoles.ResourceTagger])) { + throw new UnauthorizedUserError('get tags', user); + } + + const { results, page } = await repository.getTags(top, after as string, { + tenantIdEquals: tenantId, + resourceUrnEquals: resource, + }); + + res.send({ + results: results.map((result) => mapTag(apiId, result)), page, }); } catch (err) { @@ -247,14 +361,50 @@ export function createResourceRouter({ apiId, logger, directory, eventService, r tagOperation(apiId, logger, directory, eventService, repository) ); + router.get( + '/tags/:tag', + createValidationHandler( + param('tag') + .isString() + .matches(/^[0-9a-z-]{1,100}$/) + ), + getTag(apiId, repository) + ); + router.get( '/tags/:tag/resources', createValidationHandler( + param('tag') + .isString() + .matches(/^[0-9a-z-]{1,100}$/), query('top').optional().isInt({ min: 1, max: 500 }), query('after').optional().isString(), - query('includeRepresents').optional().isBoolean() + query('includeRepresents').optional().isBoolean(), + query('criteria').optional().isJSON() ), - getTaggedResources(repository) + getTaggedResources(apiId, repository) + ); + + router.get( + '/resources', + createValidationHandler( + query('top').optional().isInt({ min: 1, max: 500 }), + query('after').optional().isString(), + query('criteria').optional().isJSON() + ), + getResources(apiId, repository) + ); + + router.get( + '/resources/:resource', + createValidationHandler(param('resource').isString().isLength({ min: 1, max: 2000 })), + getResource(apiId, repository) + ); + + router.get( + '/resources/:resource/tags', + createValidationHandler(param('resource').isString().isLength({ min: 1, max: 2000 })), + getResourceTags(apiId, repository) ); return router; diff --git a/apps/directory-service/src/directory/types/resource.ts b/apps/directory-service/src/directory/types/resource.ts index a2e56bbabf..6f511b3698 100644 --- a/apps/directory-service/src/directory/types/resource.ts +++ b/apps/directory-service/src/directory/types/resource.ts @@ -18,6 +18,7 @@ export interface Resource { export interface TagCriteria { tenantIdEquals?: AdspId; resourceUrnEquals?: AdspId; + valueEquals?: string; } export interface ResourceTypeConfiguration { @@ -31,3 +32,9 @@ export interface ResourceTypeConfiguration { resourceIdPath: string; }; } + +export interface ResourceCriteria { + tenantIdEquals?: AdspId; + urnEquals?: AdspId; + typeEquals?: string; +} diff --git a/apps/directory-service/src/main.ts b/apps/directory-service/src/main.ts index 5eacaa9dad..a406905618 100644 --- a/apps/directory-service/src/main.ts +++ b/apps/directory-service/src/main.ts @@ -136,12 +136,12 @@ const initializeApp = async (): Promise => { { namespace: serviceId.service, name: TaggedResourceDefinition.name, - resourceIdPath: 'tag._links.resources.href', + resourceIdPath: ['tag._links.resources.href', 'resource._links.tags.href'], }, { namespace: serviceId.service, name: UntaggedResourceDefinition.name, - resourceIdPath: 'tag._links.resources.href', + resourceIdPath: ['tag._links.resources.href', 'resource._links.tags.href'], }, ], }, diff --git a/apps/directory-service/src/mongo/repository.ts b/apps/directory-service/src/mongo/repository.ts index 3d789a4734..5c3cf96eff 100644 --- a/apps/directory-service/src/mongo/repository.ts +++ b/apps/directory-service/src/mongo/repository.ts @@ -3,7 +3,7 @@ import { Doc, decodeAfter, encodeNext, Results } from '@core-services/core-commo import { Document, Model, model, PipelineStage, Types } from 'mongoose'; import { Logger } from 'winston'; import { DirectoryEntity, DirectoryRepository } from '../directory'; -import { Criteria, Directory, Resource, Tag, TagCriteria } from '../directory/types'; +import { Criteria, Directory, Resource, ResourceCriteria, Tag, TagCriteria } from '../directory/types'; import { directorySchema, resourceSchema, resourceTagSchema, tagSchema } from './schema'; import { ResourceDoc, TagDoc } from './types'; @@ -140,7 +140,13 @@ export class MongoDirectoryRepository implements DirectoryRepository { query.tenantId = criteria?.tenantIdEquals ? criteria.tenantIdEquals.toString() : { $exists: false }; let docs: TagDoc[]; + + // Resource criteria is mutually exclusive with other criteria except tenant. if (!criteria.resourceUrnEquals) { + if (criteria.valueEquals) { + query.value = criteria.valueEquals; + } + docs = await this.tagModel.find(query, null, { lean: true }).skip(skip).limit(top).exec(); } else { const pipeline: PipelineStage[] = [ @@ -201,7 +207,13 @@ export class MongoDirectoryRepository implements DirectoryRepository { }; } - async getTaggedResources(tenantId: AdspId, tag: string, top: number, after: string): Promise> { + async getTaggedResources( + tenantId: AdspId, + tag: string, + top: number, + after: string, + criteria: ResourceCriteria + ): Promise> { const skip = decodeAfter(after); const pipeline: PipelineStage[] = [ @@ -252,6 +264,21 @@ export class MongoDirectoryRepository implements DirectoryRepository { }, }, ]; + + const query: Record = {}; + if (criteria) { + if (criteria.urnEquals) { + query.urn = criteria.urnEquals.toString(); + } + if (criteria.typeEquals) { + query.type = criteria.typeEquals; + } + + pipeline.push({ + $match: query, + }); + } + const docs: ResourceDoc[] = await this.tagModel.aggregate(pipeline).exec(); return { @@ -368,6 +395,35 @@ export class MongoDirectoryRepository implements DirectoryRepository { }; } + async getResources(top: number, after: string, criteria: ResourceCriteria) { + const skip = decodeAfter(after); + + const query: Record = {}; + + // Tenant criteria is either a specific tenant or tags without any tenant context (i.e. core). + // Querying across different tenants isn't supported. + query.tenantId = criteria?.tenantIdEquals ? criteria.tenantIdEquals.toString() : { $exists: false }; + + if (criteria.urnEquals) { + query.urn = criteria.urnEquals.toString(); + } + + if (criteria.typeEquals) { + query.type = criteria.typeEquals.toString(); + } + + const docs = await this.resourceModel.find(query).skip(skip).limit(top).exec(); + + return { + results: docs.map(this.fromResourceDoc), + page: { + after, + next: encodeNext(docs.length, top, skip), + size: docs.length, + }, + }; + } + async saveResource(resource: Resource): Promise { const doc = await this.resourceModel .findOneAndUpdate(