diff --git a/.changeset/curvy-geese-compare.md b/.changeset/curvy-geese-compare.md new file mode 100644 index 0000000..a7020e0 --- /dev/null +++ b/.changeset/curvy-geese-compare.md @@ -0,0 +1,5 @@ +--- +"@eventcatalog/sdk": patch +--- + +feat(sdk): added support for services diff --git a/src/docs.ts b/src/docs.ts index 0060c4e..8e3a5bd 100644 --- a/src/docs.ts +++ b/src/docs.ts @@ -26,3 +26,4 @@ * @module docs */ export * from './events'; +export * from './services'; diff --git a/src/events.ts b/src/events.ts index d25d84b..2d1efc0 100644 --- a/src/events.ts +++ b/src/events.ts @@ -140,6 +140,7 @@ export const rmEventById = (directory: string) => async (id: string, version?: s * Version an event by it's id. * * Takes the latest event and moves it to a versioned directory. + * All files with this event are also versioned (e.g /events/InventoryAdjusted/schema.json) * * @example * ```ts @@ -149,7 +150,7 @@ export const rmEventById = (directory: string) => async (id: string, version?: s * * // moves the latest InventoryAdjusted event to a versioned directory * // the version within that event is used as the version number. - * await verionEvent('InventoryAdjusted'); + * await versionEvent('InventoryAdjusted'); * * ``` */ diff --git a/src/index.ts b/src/index.ts index 8621c57..0d2a09a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ import { join } from 'node:path'; import { rmEvent, rmEventById, writeEvent, versionEvent, getEvent, addFileToEvent, addSchemaToEvent } from './events'; +import { writeService, getService, versionService, rmService, rmServiceById, addFileToService } from './services'; /** * Init the SDK for EventCatalog @@ -59,5 +60,54 @@ export default (path: string) => { * @returns */ addSchemaToEvent: addSchemaToEvent(join(path, 'events')), + + /** + * ================================ + * SERVICES + * ================================ + */ + + /** + * Adds a service to EventCatalog + * + * @param service - The service to write + * @param options - Optional options to write the event + * + */ + writeService: writeService(join(path, 'services')), + /** + * Returns a service from EventCatalog + * @param id - The id of the service to retrieve + * @param version - Optional id of the version to get + * @returns + */ + getService: getService(join(path, 'services')), + /** + * Moves a given service id to the version directory + * @param directory + */ + versionService: versionService(join(path, 'services')), + /** + * Remove a service from EventCatalog (modeled on the standard POSIX rm utility) + * + * @param path - The path to your service, e.g. `/InventoryService` + * + */ + rmService: rmService(join(path, 'services')), + /** + * Remove an service by an service id + * + * @param id - The id of the service you want to remove + * + */ + rmServiceById: rmServiceById(join(path, 'services')), + /** + * Adds a file to the given service + * @param id - The id of the service to add the file to + * @param file - File contents to add including the content and the file name + * @param version - Optional version of the service to add the file to + * @returns + */ + addFileToService: addFileToService(join(path, 'services')), }; }; diff --git a/src/services.ts b/src/services.ts new file mode 100644 index 0000000..6000d1a --- /dev/null +++ b/src/services.ts @@ -0,0 +1,214 @@ +import matter from 'gray-matter'; +import { copyDir, findFileById, getFiles, searchFilesForId, versionExists } from './internal/utils'; +import type { Service } from './types'; +import fs from 'node:fs/promises'; +import { dirname, join } from 'node:path'; + +/** + * Returns a service from EventCatalog. + * + * You can optionally specify a version to get a specific version of the service + * + * @example + * ```ts + * import utils from '@eventcatalog/utils'; + * + * const { getService } = utils('/path/to/eventcatalog'); + * + * // Gets the latest version of the event + * cont event = await getService('InventoryService'); + * + * // Gets a version of the event + * cont event = await getService('InventoryService', '0.0.1'); + * ``` + */ +export const getService = + (directory: string) => + async (id: string, version?: string): Promise => { + const file = await findFileById(directory, id, version); + + if (!file) throw new Error(`No service found for the given id: ${id}` + (version ? ` and version ${version}` : '')); + + const { data, content } = matter.read(file); + + return { + ...data, + markdown: content.trim(), + } as Service; + }; + +/** + * Write an event to EventCatalog. + * + * You can optionally overide the path of the event. + * + * @example + * ```ts + * import utils from '@eventcatalog/utils'; + * + * const { writeService } = utils('/path/to/eventcatalog'); + * + * // Write a service + * // Event would be written to services/InventoryService + * await writeService({ + * id: 'InventoryService', + * name: 'Inventory Service', + * version: '0.0.1', + * summary: 'Service that handles the inventory', + * markdown: '# Hello world', + * }); + * + * // Write an event to the catalog but override the path + * // Event would be written to services/Inventory/InventoryService + * await writeService({ + * id: 'InventoryService', + * name: 'Inventory Adjusted', + * version: '0.0.1', + * summary: 'This is a summary', + * markdown: '# Hello world', + * }, { path: "/Inventory/InventoryService"}); + * ``` + */ +export const writeService = + (directory: string) => + async (service: Service, options: { path: string } = { path: '' }) => { + // Get the path + const path = options.path || `/${service.id}`; + const exists = await versionExists(directory, service.id, service.version); + + if (exists) { + throw new Error(`Failed to write service as the version ${service.version} already exists`); + } + + const { markdown, ...frontmatter } = service; + const document = matter.stringify(markdown.trim(), frontmatter); + await fs.mkdir(join(directory, path), { recursive: true }); + await fs.writeFile(join(directory, path, 'index.md'), document); + }; + +/** + * Version a service by it's id. + * + * Takes the latest service and moves it to a versioned directory. + * All files with this service are also versioned. (e.g /services/InventoryService/openapi.yml) + * + * @example + * ```ts + * import utils from '@eventcatalog/utils'; + * + * const { versionService } = utils('/path/to/eventcatalog'); + * + * // moves the latest InventoryService service to a versioned directory + * // the version within that service is used as the version number. + * await versionService('InventoryService'); + * + * ``` + */ +export const versionService = (directory: string) => async (id: string) => { + // Find all the events in the directory + const files = await getFiles(`${directory}/**/index.md`); + const matchedFiles = await searchFilesForId(files, id); + + if (matchedFiles.length === 0) { + throw new Error(`No service found with id: ${id}`); + } + + // Service that is in the route of the project + const file = matchedFiles[0]; + const eventDirectory = dirname(file); + const { data: { version = '0.0.1' } = {} } = matter.read(file); + const targetDirectory = join(eventDirectory, 'versioned', version); + + await fs.mkdir(targetDirectory, { recursive: true }); + + // Copy the service to the versioned directory + await copyDir(directory, eventDirectory, targetDirectory, (src) => { + return !src.includes('versioned'); + }); + + // Remove all the files in the root of the resource as they have now been versioned + await fs.readdir(eventDirectory).then(async (resourceFiles) => { + await Promise.all( + resourceFiles.map(async (file) => { + if (file !== 'versioned') { + await fs.rm(join(eventDirectory, file), { recursive: true }); + } + }) + ); + }); +}; + +/** + * Delete a service at it's given path. + * + * @example + * ```ts + * import utils from '@eventcatalog/utils'; + * + * const { rmService } = utils('/path/to/eventcatalog'); + * + * // Removes the service at services/InventoryService + * await rmService('/InventoryService'); + * ``` + */ +export const rmService = (directory: string) => async (path: string) => { + await fs.rm(join(directory, path), { recursive: true }); +}; + +/** + * Delete a service by it's id. + * + * Optionally specify a version to delete a specific version of the service. + * + * @example + * ```ts + * import utils from '@eventcatalog/utils'; + * + * const { rmServiceById } = utils('/path/to/eventcatalog'); + * + * // deletes the latest InventoryService event + * await rmServiceById('InventoryService'); + * + * // deletes a specific version of the InventoryService event + * await rmServiceById('InventoryService', '0.0.1'); + * ``` + */ +export const rmServiceById = (directory: string) => async (id: string, version?: string) => { + // Find all the events in the directory + const files = await getFiles(`${directory}/**/index.md`); + + const matchedFiles = await searchFilesForId(files, id, version); + + if (matchedFiles.length === 0) { + throw new Error(`No service found with id: ${id}`); + } + + await Promise.all(matchedFiles.map((file) => fs.rm(file))); +}; + +/** + * Add a file to a service by it's id. + * + * Optionally specify a version to add a file to a specific version of the service. + * + * @example + * ```ts + * import utils from '@eventcatalog/utils'; + * + * const { addFileToService } = utils('/path/to/eventcatalog'); + * + * // adds a file to the latest InventoryService event + * await addFileToService('InventoryService', { content: 'Hello world', fileName: 'hello.txt' }); + * + * // adds a file to a specific version of the InventoryService event + * await addFileToService('InventoryService', { content: 'Hello world', fileName: 'hello.txt' }, '0.0.1'); + * + * ``` + */ +export const addFileToService = + (directory: string) => async (id: string, file: { content: string; fileName: string }, version?: string) => { + const pathToEvent = await findFileById(directory, id, version); + if (!pathToEvent) throw new Error('Cannot find directory to write file to'); + const contentDirectory = dirname(pathToEvent); + await fs.writeFile(join(contentDirectory, file.fileName), file.content); + }; diff --git a/src/test/events.test.ts b/src/test/events.test.ts index 6f3b3b5..3468df1 100644 --- a/src/test/events.test.ts +++ b/src/test/events.test.ts @@ -4,7 +4,7 @@ import utils from '../index'; import path from 'node:path'; import fs from 'node:fs'; -const CATALOG_PATH = path.join(__dirname, 'catalog'); +const CATALOG_PATH = path.join(__dirname, 'catalog-events'); const { writeEvent, getEvent, rmEvent, rmEventById, versionEvent, addFileToEvent, addSchemaToEvent } = utils(CATALOG_PATH); @@ -15,7 +15,7 @@ beforeEach(() => { }); afterEach(() => { - // fs.rmSync(CATALOG_PATH, { recursive: true, force: true }); + fs.rmSync(CATALOG_PATH, { recursive: true, force: true }); }); describe('Events SDK', () => { diff --git a/src/test/services.test.ts b/src/test/services.test.ts new file mode 100644 index 0000000..2f7434d --- /dev/null +++ b/src/test/services.test.ts @@ -0,0 +1,311 @@ +// sum.test.js +import { expect, it, describe, beforeEach, afterEach } from 'vitest'; +import utils from '../index'; +import path from 'node:path'; +import fs from 'node:fs'; + +const CATALOG_PATH = path.join(__dirname, 'catalog-services'); + +const { writeService, getService, versionService, rmService, rmServiceById, addFileToService } = utils(CATALOG_PATH); + +// clean the catalog before each test +beforeEach(() => { + fs.rmSync(CATALOG_PATH, { recursive: true, force: true }); + fs.mkdirSync(CATALOG_PATH, { recursive: true }); +}); + +afterEach(() => { + fs.rmSync(CATALOG_PATH, { recursive: true, force: true }); +}); + +describe('Services SDK', () => { + describe('getService', () => { + it('returns the given service id from EventCatalog and the latest version when no version is given,', async () => { + await writeService({ + id: 'InventoryService', + name: 'Inventory Service', + version: '0.0.1', + summary: 'Service tat handles the inventory', + markdown: '# Hello world', + }); + + const test = await getService('InventoryService'); + + expect(test).toEqual({ + id: 'InventoryService', + name: 'Inventory Service', + version: '0.0.1', + summary: 'Service tat handles the inventory', + markdown: '# Hello world', + }); + }); + + it('returns the given service id from EventCatalog and the requested version when a version is given,', async () => { + await writeService({ + id: 'InventoryService', + name: 'Inventory Service', + version: '0.0.1', + summary: 'Service tat handles the inventory', + markdown: '# Hello world', + }); + + await versionService('InventoryService'); + + await writeService({ + id: 'InventoryService', + name: 'Inventory Service', + version: '1.0.0', + summary: 'Service tat handles the inventory', + markdown: '# Hello world', + }); + + const test = await getService('InventoryService', '0.0.1'); + + expect(test).toEqual({ + id: 'InventoryService', + name: 'Inventory Service', + version: '0.0.1', + summary: 'Service tat handles the inventory', + markdown: '# Hello world', + }); + }); + + it('throws an error if the service is not found', async () => { + await expect(getService('PaymentService')).rejects.toThrowError('No service found for the given id: PaymentService'); + }); + + it('throws an error if the service is found but not the version', async () => { + await writeService({ + id: 'InventoryService', + name: 'Inventory Service', + version: '0.0.1', + summary: 'Service tat handles the inventory', + markdown: '# Hello world', + }); + + await expect(getService('InventoryService', '1.0.0')).rejects.toThrowError( + 'No service found for the given id: InventoryService and version 1.0.0' + ); + }); + }); + + describe('writeService', () => { + it('writes the given service to EventCatalog and assumes the path if one if not given', async () => { + await writeService({ + id: 'InventoryService', + name: 'Inventory Service', + version: '0.0.1', + summary: 'Service tat handles the inventory', + markdown: '# Hello world', + }); + + const service = await getService('InventoryService'); + + expect(fs.existsSync(path.join(CATALOG_PATH, 'services/InventoryService', 'index.md'))).toBe(true); + + expect(service).toEqual({ + id: 'InventoryService', + name: 'Inventory Service', + version: '0.0.1', + summary: 'Service tat handles the inventory', + markdown: '# Hello world', + }); + }); + + it('writes the given service to EventCatalog under the correct path when a path is given', async () => { + await writeService( + { + id: 'InventoryService', + name: 'Inventory Service', + version: '0.0.1', + summary: 'Service tat handles the inventory', + markdown: '# Hello world', + }, + { path: '/Inventory/InventoryService' } + ); + + expect(fs.existsSync(path.join(CATALOG_PATH, 'services/Inventory/InventoryService', 'index.md'))).toBe(true); + }); + + it('throws an error when trying to write an service that already exists', async () => { + await writeService({ + id: 'InventoryService', + name: 'Inventory Service', + version: '0.0.1', + summary: 'Service tat handles the inventory', + markdown: '# Hello world', + }); + + await expect( + writeService({ + id: 'InventoryService', + name: 'Inventory Service', + version: '0.0.1', + summary: 'Service tat handles the inventory', + markdown: '# Hello world', + }) + ).rejects.toThrowError('Failed to write service as the version 0.0.1 already exists'); + }); + }); + + describe('versionService', () => { + it('adds the given service to the versioned directory and removes itself from the root', async () => { + await writeService({ + id: 'InventoryService', + name: 'Inventory Service', + version: '0.0.1', + summary: 'Service tat handles the inventory', + markdown: '# Hello world', + }); + + await versionService('InventoryService'); + + expect(fs.existsSync(path.join(CATALOG_PATH, 'services/InventoryService/versioned/0.0.1', 'index.md'))).toBe(true); + expect(fs.existsSync(path.join(CATALOG_PATH, 'services/InventoryService', 'index.md'))).toBe(false); + }); + it('adds the given service to the versioned directory and all files that are associated to it', async () => { + await writeService({ + id: 'InventoryService', + name: 'Inventory Service', + version: '0.0.1', + summary: 'Service tat handles the inventory', + markdown: '# Hello world', + }); + + // Add random file in there + await fs.writeFileSync(path.join(CATALOG_PATH, 'services/InventoryService', 'schema.json'), 'SCHEMA!'); + + await versionService('InventoryService'); + + expect(fs.existsSync(path.join(CATALOG_PATH, 'services/InventoryService/versioned/0.0.1', 'index.md'))).toBe(true); + expect(fs.existsSync(path.join(CATALOG_PATH, 'services/InventoryService/versioned/0.0.1', 'schema.json'))).toBe(true); + expect(fs.existsSync(path.join(CATALOG_PATH, 'services/InventoryService', 'index.md'))).toBe(false); + expect(fs.existsSync(path.join(CATALOG_PATH, 'services/InventoryService', 'schema.json'))).toBe(false); + }); + }); + + describe('rmService', () => { + it('removes a service from eventcatalog by the given path', async () => { + await writeService({ + id: 'InventoryService', + name: 'Inventory Service', + version: '0.0.1', + summary: 'Service tat handles the inventory', + markdown: '# Hello world', + }); + + expect(fs.existsSync(path.join(CATALOG_PATH, 'services/InventoryService', 'index.md'))).toBe(true); + + await rmService('/InventoryService'); + + expect(fs.existsSync(path.join(CATALOG_PATH, 'services/InventoryService', 'index.md'))).toBe(false); + }); + }); + + describe('rmServiceById', () => { + it('removes a service from eventcatalog by id', async () => { + await writeService({ + id: 'InventoryService', + name: 'Inventory Service', + version: '0.0.1', + summary: 'Service tat handles the inventory', + markdown: '# Hello world', + }); + + expect(fs.existsSync(path.join(CATALOG_PATH, 'services/InventoryService', 'index.md'))).toBe(true); + + await rmServiceById('InventoryService'); + + expect(fs.existsSync(path.join(CATALOG_PATH, 'services/InventoryService', 'index.md'))).toBe(false); + }); + + it('removes a service from eventcatalog by id and version', async () => { + await writeService({ + id: 'InventoryService', + name: 'Inventory Service', + version: '0.0.1', + summary: 'Service tat handles the inventory', + markdown: '# Hello world', + }); + + expect(fs.existsSync(path.join(CATALOG_PATH, 'services/InventoryService', 'index.md'))).toBe(true); + + await rmServiceById('InventoryService', '0.0.1'); + + expect(fs.existsSync(path.join(CATALOG_PATH, 'services/InventoryService', 'index.md'))).toBe(false); + }); + + it('if version is given, only removes that version and not any other versions of the service', async () => { + // write the first events + await writeService({ + id: 'InventoryService', + name: 'Inventory Service', + version: '0.0.1', + summary: 'Service tat handles the inventory', + markdown: '# Hello world', + }); + + await versionService('InventoryService'); + + // Write the versioned event + await writeService({ + id: 'InventoryService', + name: 'Inventory Adjusted', + version: '0.0.2', + summary: 'This is a summary', + markdown: '# Hello world', + }); + + expect(fs.existsSync(path.join(CATALOG_PATH, 'services/InventoryService', 'index.md'))).toBe(true); + expect(fs.existsSync(path.join(CATALOG_PATH, 'services/InventoryService/versioned/0.0.1', 'index.md'))).toBe(true); + + await rmServiceById('InventoryService', '0.0.1'); + + expect(fs.existsSync(path.join(CATALOG_PATH, 'services/InventoryService', 'index.md'))).toBe(true); + + expect(fs.existsSync(path.join(CATALOG_PATH, 'services/InventoryAdjusted/versioned/0.0.2', 'index.md'))).toBe(false); + }); + }); + + describe('addFileToService', () => { + it('takes a given file and writes it to the location of the given service', async () => { + const file = { content: 'hello', fileName: 'test.txt' }; + + await writeService({ + id: 'InventoryService', + name: 'Inventory Service', + version: '0.0.1', + summary: 'Service tat handles the inventory', + markdown: '# Hello world', + }); + + await addFileToService('InventoryService', file); + + expect(fs.existsSync(path.join(CATALOG_PATH, 'services/InventoryService', 'test.txt'))).toBe(true); + }); + + it('takes a given file and version and writes the file to the correct location', async () => { + const file = { content: 'hello', fileName: 'test.txt' }; + + await writeService({ + id: 'InventoryService', + name: 'Inventory Service', + version: '0.0.1', + summary: 'Service tat handles the inventory', + markdown: '# Hello world', + }); + + await versionService('InventoryService'); + + await addFileToService('InventoryService', file, '0.0.1'); + + expect(fs.existsSync(path.join(CATALOG_PATH, 'services/InventoryService/versioned/0.0.1', 'test.txt'))).toBe(true); + }); + + it('throws an error when trying to write to a service that does not exist', () => { + const file = { content: 'hello', fileName: 'test.txt' }; + + expect(addFileToService('InventoryService', file)).rejects.toThrowError('Cannot find directory to write file to'); + }); + }); +}); diff --git a/src/types.d.ts b/src/types.d.ts index 728098b..a65f84a 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -1,18 +1,59 @@ -export type Badge = { - content: string; - backgroundColor: string; - textColor: string; -}; - -export type Event = { +// Base type for all resources (domains, services and messages) +export interface BaseSchema { id: string; name: string; + summary?: string; version: string; - summary: string; - owners?: string[]; badges?: Badge[]; - // Path to the schema file. + owners?: string[]; schemaPath?: string; - // Markdown content of the event markdown: string; +} + +export type ResourcePointer = { + id: string; + version: string; }; + +export type Message = Event | Command; + +export interface Event extends BaseSchema {} +export interface Command extends BaseSchema {} + +export interface Service extends BaseSchema { + sends?: ResourcePointer[]; + receives?: ResourcePointer[]; +} + +export interface Team { + id: string; + name: string; + summary?: string; + email?: string; + hidden?: boolean; + slackDirectMessageUrl?: string; + members?: User[]; + ownedCommands?: Command[]; + ownedServices?: Service[]; + ownedEvents?: Event[]; +} + +export interface User { + id: string; + name: string; + avatarUrl: string; + role?: string; + hidden?: boolean; + email?: string; + slackDirectMessageUrl?: string; + ownedServices?: Service[]; + ownedEvents?: Event[]; + ownedCommands?: Command[]; + associatedTeams?: Team[]; +} + +export interface Badge { + content: string; + backgroundColor: string; + textColor: string; +}