From f5898f5d08616727bea9ba303fb6f0276530987b Mon Sep 17 00:00:00 2001 From: John Brunton <1276413+jbrunton@users.noreply.github.com> Date: Sun, 22 Sep 2024 11:35:35 -0400 Subject: [PATCH] refactor: message and command entities (#229) --- .../api/src/app/messages/command.service.ts | 9 +- .../api/src/app/messages/messages.module.ts | 2 +- .../src/app/messages/messages.service.spec.ts | 8 +- .../api/src/app/messages/messages.service.ts | 29 ++-- services/api/src/domain/entities/command.ts | 22 --- services/api/src/domain/entities/commands.ts | 14 ++ .../src/domain/entities/messages/message.ts | 39 +++-- .../api/src/domain/usecases/commands/help.ts | 9 +- .../usecases/commands/parse/commands.ts | 156 ++++++++++++++++++ .../domain/usecases/commands/parse/index.ts | 24 --- .../{parse.spec.ts => parse-command.spec.ts} | 32 ++-- .../usecases/commands/parse/parse-command.ts | 30 ++++ .../{command.parser.ts => parsed-command.ts} | 98 ++++++----- .../parse/parsers/about-room-parser.ts | 16 -- .../parse/parsers/approve-request-parser.ts | 16 -- .../commands/parse/parsers/help.parser.ts | 14 -- .../usecases/commands/parse/parsers/index.ts | 24 --- .../commands/parse/parsers/invite-parser.ts | 16 -- .../commands/parse/parsers/leave.parser.ts | 14 -- .../commands/parse/parsers/lorem.parser.ts | 20 --- .../parse/parsers/rename.room.parser.ts | 17 -- .../parse/parsers/rename.user.parser.ts | 17 -- .../parsers/set-room-content-policy-parser.ts | 24 --- .../parsers/set-room-join-policy-parser.ts | 24 --- .../commands/parse/tokenize-command.spec.ts | 26 +++ .../commands/parse/tokenize-command.ts | 47 ++++++ .../usecases/messages/parse-message.spec.ts | 36 ---- .../domain/usecases/messages/parse-message.ts | 50 ------ 28 files changed, 399 insertions(+), 434 deletions(-) delete mode 100644 services/api/src/domain/entities/command.ts create mode 100644 services/api/src/domain/entities/commands.ts create mode 100644 services/api/src/domain/usecases/commands/parse/commands.ts delete mode 100644 services/api/src/domain/usecases/commands/parse/index.ts rename services/api/src/domain/usecases/commands/parse/{parse.spec.ts => parse-command.spec.ts} (91%) create mode 100644 services/api/src/domain/usecases/commands/parse/parse-command.ts rename services/api/src/domain/usecases/commands/parse/{command.parser.ts => parsed-command.ts} (58%) delete mode 100644 services/api/src/domain/usecases/commands/parse/parsers/about-room-parser.ts delete mode 100644 services/api/src/domain/usecases/commands/parse/parsers/approve-request-parser.ts delete mode 100644 services/api/src/domain/usecases/commands/parse/parsers/help.parser.ts delete mode 100644 services/api/src/domain/usecases/commands/parse/parsers/index.ts delete mode 100644 services/api/src/domain/usecases/commands/parse/parsers/invite-parser.ts delete mode 100644 services/api/src/domain/usecases/commands/parse/parsers/leave.parser.ts delete mode 100644 services/api/src/domain/usecases/commands/parse/parsers/lorem.parser.ts delete mode 100644 services/api/src/domain/usecases/commands/parse/parsers/rename.room.parser.ts delete mode 100644 services/api/src/domain/usecases/commands/parse/parsers/rename.user.parser.ts delete mode 100644 services/api/src/domain/usecases/commands/parse/parsers/set-room-content-policy-parser.ts delete mode 100644 services/api/src/domain/usecases/commands/parse/parsers/set-room-join-policy-parser.ts create mode 100644 services/api/src/domain/usecases/commands/parse/tokenize-command.spec.ts create mode 100644 services/api/src/domain/usecases/commands/parse/tokenize-command.ts delete mode 100644 services/api/src/domain/usecases/messages/parse-message.spec.ts delete mode 100644 services/api/src/domain/usecases/messages/parse-message.ts diff --git a/services/api/src/app/messages/command.service.ts b/services/api/src/app/messages/command.service.ts index e977ec6f..4d21ab66 100644 --- a/services/api/src/app/messages/command.service.ts +++ b/services/api/src/app/messages/command.service.ts @@ -1,4 +1,3 @@ -import { Command } from '@entities/command'; import { User } from '@entities/users/user'; import { Injectable } from '@nestjs/common'; import { HelpCommandUseCase } from '@usecases/commands/help'; @@ -6,13 +5,14 @@ import { RenameRoomUseCase } from '@usecases/rooms/rename'; import { RenameUserUseCase } from '@usecases/users/rename'; import { Dispatcher } from '@entities/messages/message'; import { LoremCommandUseCase } from '@usecases/commands/lorem'; -import { ParseCommandUseCase } from '@usecases/commands/parse'; +import { ParseCommandUseCase } from '@usecases/commands/parse/parse-command'; import { ConfigureRoomUseCase } from '@usecases/rooms/configure-room'; import { P, match } from 'ts-pattern'; import { InviteUseCase } from '@usecases/memberships/invite'; import { LeaveRoomUseCase } from '@usecases/memberships/leave'; import { AboutRoomUseCase } from '@usecases/rooms/about-room'; import { ApproveRequestUseCase } from '@usecases/memberships/approve-request'; +import { IncomingCommand } from '@entities/commands'; @Injectable() export class CommandService { @@ -30,9 +30,10 @@ export class CommandService { readonly dispatcher: Dispatcher, ) {} - async exec(command: Command, authenticatedUser: User): Promise { + async exec(command: IncomingCommand, authenticatedUser: User): Promise { const { roomId } = command; - const parsedCommand = await this.parse.exec(command); + const parsedCommand = this.parse.exec(command); + return match(parsedCommand) .with({ tag: 'help' }, () => this.help.exec({ roomId, authenticatedUser }), diff --git a/services/api/src/app/messages/messages.module.ts b/services/api/src/app/messages/messages.module.ts index cafc917f..ee3280a4 100644 --- a/services/api/src/app/messages/messages.module.ts +++ b/services/api/src/app/messages/messages.module.ts @@ -4,7 +4,7 @@ import { MessagesController } from './messages.controller'; import { AuthModule } from '@app/auth/auth.module'; import { SendMessageUseCase } from '@usecases/messages/send'; import { GetMessagesUseCase } from '@usecases/messages/get-messages'; -import { ParseCommandUseCase } from '@usecases/commands/parse'; +import { ParseCommandUseCase } from '@usecases/commands/parse/parse-command'; import { CommandService } from '@app/messages/command.service'; import { RenameRoomUseCase } from '@usecases/rooms/rename'; import { RenameUserUseCase } from '@usecases/users/rename'; diff --git a/services/api/src/app/messages/messages.service.spec.ts b/services/api/src/app/messages/messages.service.spec.ts index e43373d8..1a92e98d 100644 --- a/services/api/src/app/messages/messages.service.spec.ts +++ b/services/api/src/app/messages/messages.service.spec.ts @@ -6,9 +6,9 @@ import { User } from '@entities/users/user'; import { mock, MockProxy } from 'jest-mock-extended'; import { SendMessageUseCase } from '@usecases/messages/send'; import { CommandService } from './command.service'; -import { Command } from '@entities/command'; import { UnauthorizedException } from '@nestjs/common'; import { systemUser } from '@entities/users/system-user'; +import { IncomingCommand } from '@entities/commands'; describe('MessagesService', () => { let service: MessagesService; @@ -60,10 +60,10 @@ describe('MessagesService', () => { await service.handleMessage(message, authenticatedUser); - const expectedCommand: Command = { - tokens: ['help'], - canonicalInput: '/help', + const expectedCommand: IncomingCommand = { + content: '/help', roomId, + authorId: authenticatedUser.id, }; expect(command.exec).toHaveBeenCalledWith( expectedCommand, diff --git a/services/api/src/app/messages/messages.service.ts b/services/api/src/app/messages/messages.service.ts index 6d0b4b80..3338632e 100644 --- a/services/api/src/app/messages/messages.service.ts +++ b/services/api/src/app/messages/messages.service.ts @@ -2,9 +2,10 @@ import { HttpException, Injectable } from '@nestjs/common'; import { CreateMessageDto } from './dto/messages'; import { SendMessageUseCase } from '@usecases/messages/send'; import { CommandService } from '@app/messages/command.service'; -import { isCommand, parseMessage } from '@usecases/messages/parse-message'; import { systemUser } from '@entities/users/system-user'; import { User } from '@entities/users/user'; +import { IncomingMessage } from '@entities/messages/message'; +import { isCommand } from '@entities/commands'; @Injectable() export class MessagesService { @@ -17,10 +18,10 @@ export class MessagesService { incoming: CreateMessageDto, authenticatedUser: User, ): Promise { - const message = parseMessage({ + const message: IncomingMessage = { ...incoming, authorId: authenticatedUser.id, - }); + }; try { if (isCommand(message)) { @@ -30,18 +31,22 @@ export class MessagesService { } } catch (e) { if (e instanceof HttpException) { - await this.send.exec( - { - content: e.message, - roomId: incoming.roomId, - recipientId: authenticatedUser.id, - authorId: 'system', - }, + return await this.send.exec( + this.getErrorMessage(e, message), systemUser, ); - } else { - throw e; } + + throw e; } } + + private getErrorMessage(e: HttpException, message: IncomingMessage) { + return { + content: e.message, + roomId: message.roomId, + recipientId: message.authorId, + authorId: 'system', + }; + } } diff --git a/services/api/src/domain/entities/command.ts b/services/api/src/domain/entities/command.ts deleted file mode 100644 index 2c92f178..00000000 --- a/services/api/src/domain/entities/command.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * A type representing sent commands. - * Any message prefixed with a `/` is treated as a command, e.g. `/help`. - */ -export type Command = { - /** - * The tokenised command. Tokens are split by whitespace. - */ - tokens: string[]; - - /** - * The room the command was sent to. - */ - roomId: string; - - /** - * A canonical representation of the command. This removes excess whitespace. - * - * E.g. `/rename room My Room` would be represented as `/rename room My Room`. - */ - canonicalInput: string; -}; diff --git a/services/api/src/domain/entities/commands.ts b/services/api/src/domain/entities/commands.ts new file mode 100644 index 00000000..6f7ba81d --- /dev/null +++ b/services/api/src/domain/entities/commands.ts @@ -0,0 +1,14 @@ +import { IncomingMessage } from '@entities/messages/message'; + +/** + * An incoming message which is a command. Any message with content prefixed by a forward slash is + * considered a command, where or not it can be parsed. If it cannot be parsed, it is an invalid + * command. + */ +export type IncomingCommand = IncomingMessage & { + content: `/${string}`; +}; + +export const isCommand = ( + message: IncomingMessage, +): message is IncomingCommand => message.content.startsWith('/'); diff --git a/services/api/src/domain/entities/messages/message.ts b/services/api/src/domain/entities/messages/message.ts index 4cdc50f5..8f3ba237 100644 --- a/services/api/src/domain/entities/messages/message.ts +++ b/services/api/src/domain/entities/messages/message.ts @@ -7,19 +7,9 @@ export enum UpdatedEntity { } /** - * A message that has been sent by a user and is stored in the system. + * A message received by the system but not yet stored, executed or dispatched to clients. */ -export type SentMessage = { - /** - * Unique identifier for the message. - */ - id: string; - - /** - * The time the message was sent (in ms since the epoch). - */ - time: number; - +export type IncomingMessage = { /** * The text content of the message. */ @@ -34,7 +24,12 @@ export type SentMessage = { * The room the message was sent to. */ roomId: string; +}; +/** + * Type for draft messages which have not yet been sent by the system. + */ +export type DraftMessage = IncomingMessage & { /** * The recipient of the message. * If undefined, this is a public message sent to the room. @@ -50,6 +45,21 @@ export type SentMessage = { updatedEntities?: UpdatedEntity[]; }; +/** + * A message that has been sent by a user and is stored in the system. + */ +export type SentMessage = DraftMessage & { + /** + * Unique identifier for the message. + */ + id: string; + + /** + * The time the message was sent (in ms since the epoch). + */ + time: number; +}; + /** * A refinement of the `Message` type to indicate it is private. */ @@ -66,11 +76,6 @@ export const isPrivate = (message: SentMessage): message is PrivateMessage => { return (message as PrivateMessage).recipientId !== undefined; }; -/** - * Type for draft messages which have not yet been sent by the system. - */ -export type DraftMessage = Omit; - /** * Abstract class for dispatching and subscribing to messages. All messages will be sent and * delivered to subscribers through a `Dispatcher`. diff --git a/services/api/src/domain/usecases/commands/help.ts b/services/api/src/domain/usecases/commands/help.ts index e797919e..82328bf5 100644 --- a/services/api/src/domain/usecases/commands/help.ts +++ b/services/api/src/domain/usecases/commands/help.ts @@ -1,7 +1,8 @@ import { Dispatcher } from '@entities/messages/message'; import { User } from '@entities/users/user'; import { Injectable } from '@nestjs/common'; -import { parsers } from './parse/parsers'; + +import { commands } from '@usecases/commands/parse/commands'; export type HelpParams = { authenticatedUser: User; @@ -24,9 +25,9 @@ export class HelpCommandUseCase { private generateContent() { const title = 'Type to chat, or enter one of the following commands:'; - const commands = parsers.map( - (parser) => `* \`${parser.signature}\`: ${parser.summary}`, + const commandSummaries = commands.map( + (command) => `* \`${command.signature}\`: ${command.summary}`, ); - return [title, ...commands].join('\n'); + return [title, ...commandSummaries].join('\n'); } } diff --git a/services/api/src/domain/usecases/commands/parse/commands.ts b/services/api/src/domain/usecases/commands/parse/commands.ts new file mode 100644 index 00000000..a06d8592 --- /dev/null +++ b/services/api/src/domain/usecases/commands/parse/commands.ts @@ -0,0 +1,156 @@ +import { + buildCommand, + Command, + ParsedCommand, +} from '@usecases/commands/parse/parsed-command'; +import { z } from 'zod'; +import { ContentPolicy, JoinPolicy } from '@entities/rooms/room'; + +const aboutRoomCommand = buildCommand({ + signature: '/about room', + summary: 'about room (including policies)', + matchTokens: ['about', 'room'], + schema: z + .tuple([z.literal('about'), z.literal('room')]) + .transform(() => ({ + tag: 'aboutRoom', + params: null, + })), +}); + +const approveRequestCommand = buildCommand({ + signature: `/approve request {email}`, + summary: 'approve pending request to join the room', + matchTokens: ['approve', 'request'], + schema: z + .tuple([z.literal('approve'), z.literal('request'), z.string().email()]) + .transform(([, , email]) => ({ + tag: 'approveRequest', + params: { email }, + })), +}); + +const helpCommand = buildCommand({ + signature: '/help', + summary: 'list commands', + matchTokens: ['help'], + schema: z.tuple([z.literal('help')]).transform(() => ({ + tag: 'help', + params: null, + })), +}); + +const inviteCommand = buildCommand({ + signature: `/invite {email}`, + summary: 'invite a user to the room', + matchTokens: ['invite'], + schema: z + .tuple([z.literal('invite'), z.string().email()]) + .transform(([, email]) => ({ + tag: 'inviteUser', + params: { email }, + })), +}); + +const leaveCommand = buildCommand({ + signature: '/leave', + summary: 'leave room', + matchTokens: ['leave'], + schema: z.tuple([z.literal('leave')]).transform(() => ({ + tag: 'leave', + params: null, + })), +}); + +const loremCommand = buildCommand({ + signature: `/lorem {count} {'words' | 'paragraphs'}`, + summary: 'generate lorem text', + matchTokens: ['lorem'], + schema: z + .tuple([ + z.literal('lorem'), + z.coerce.number(), + z.enum(['words', 'paragraphs']), + ]) + .transform(([, count, typeToken]) => ({ + tag: 'lorem', + params: { count, typeToken }, + })), +}); + +const renameRoomCommand = buildCommand({ + signature: '/rename room {name}', + summary: 'change the room name', + matchTokens: ['rename', 'room'], + schema: z + .tuple([z.literal('rename'), z.literal('room'), z.string()]) + .rest(z.string()) + .transform(([, , name, ...rest]) => ({ + tag: 'renameRoom', + params: { newName: [name, ...rest].join(' ') }, + })), +}); + +const renameUserCommand = buildCommand({ + signature: '/rename user {name}', + summary: 'change your display name', + matchTokens: ['rename', 'user'], + schema: z + .tuple([z.literal('rename'), z.literal('user'), z.string()]) + .rest(z.string()) + .transform(([, , name, ...rest]) => ({ + tag: 'renameUser', + params: { newName: [name, ...rest].join(' ') }, + })), +}); + +const setRoomContentPolicyCommand = buildCommand({ + signature: `/set room content policy {'public', 'private'}`, + summary: 'set the room content policy', + matchTokens: ['set', 'room', 'content', 'policy'], + schema: z + .tuple([ + z.literal('set'), + z.literal('room'), + z.literal('content'), + z.literal('policy'), + z.enum([ContentPolicy.Private, ContentPolicy.Public]), + ]) + .rest(z.string()) + .transform(([, , , , contentPolicy]) => ({ + tag: 'setRoomContentPolicy', + params: { newContentPolicy: contentPolicy }, + })), +}); + +const setRoomJoinPolicyCommand = buildCommand({ + signature: `/set room join policy {'anyone', 'request', 'invite'}`, + summary: 'set the room join policy', + matchTokens: ['set', 'room', 'join', 'policy'], + schema: z + .tuple([ + z.literal('set'), + z.literal('room'), + z.literal('join'), + z.literal('policy'), + z.enum([JoinPolicy.Anyone, JoinPolicy.Invite, JoinPolicy.Request]), + ]) + .rest(z.string()) + .transform(([, , , , joinPolicy]) => ({ + tag: 'setRoomJoinPolicy', + params: { newJoinPolicy: joinPolicy }, + })), +}); + +export const commands: Command[] = [ + helpCommand, + loremCommand, + renameRoomCommand, + renameUserCommand, + leaveCommand, + setRoomJoinPolicyCommand, + setRoomContentPolicyCommand, + aboutRoomCommand, + inviteCommand, + approveRequestCommand, +]; diff --git a/services/api/src/domain/usecases/commands/parse/index.ts b/services/api/src/domain/usecases/commands/parse/index.ts deleted file mode 100644 index 7836e324..00000000 --- a/services/api/src/domain/usecases/commands/parse/index.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Command } from '@entities/command'; -import { BadRequestException, Injectable } from '@nestjs/common'; -import { parsers } from './parsers'; -import { ParsedCommand } from './command.parser'; - -@Injectable() -export class ParseCommandUseCase { - exec(command: Command): ParsedCommand { - for (const parser of parsers) { - const result = parser.parse(command); - if (result.match) { - return result.command; - } - } - - throw new BadRequestException(unrecognisedResponse(command)); - } -} - -const unrecognisedResponse = (command: Command): string => { - const title = `Unrecognised command \`${command.canonicalInput}\`.`; - const suggestion = 'Type `/help` for further assistance.'; - return [title, suggestion].join(' '); -}; diff --git a/services/api/src/domain/usecases/commands/parse/parse.spec.ts b/services/api/src/domain/usecases/commands/parse/parse-command.spec.ts similarity index 91% rename from services/api/src/domain/usecases/commands/parse/parse.spec.ts rename to services/api/src/domain/usecases/commands/parse/parse-command.spec.ts index e92135f7..10522e75 100644 --- a/services/api/src/domain/usecases/commands/parse/parse.spec.ts +++ b/services/api/src/domain/usecases/commands/parse/parse-command.spec.ts @@ -1,32 +1,34 @@ -import { Command } from '@entities/command'; import { BadRequestException } from '@nestjs/common'; -import { ParseCommandUseCase } from '.'; -import { ParsedCommand } from './command.parser'; +import { ParseCommandUseCase } from './parse-command'; +import { ParsedCommand } from './parsed-command'; import { ContentPolicy, JoinPolicy } from '@entities/rooms/room'; +import { IncomingCommand } from '@entities/commands'; describe('ParseCommandUseCase', () => { + const roomId = 'my-room'; + const authorId = '1'; + let parse: ParseCommandUseCase; beforeEach(() => { parse = new ParseCommandUseCase(); }); - const withMessage = (command: string) => { - const tokens = command.slice(1).split(' '); - const parsedCommand: Command = { - roomId: 'my-room', - tokens, - canonicalInput: `/${tokens.join(' ')}`, + const withMessage = (content: IncomingCommand['content']) => { + const command: IncomingCommand = { + roomId, + content, + authorId, }; const parse = new ParseCommandUseCase(); return { expectCommand: (expected: ParsedCommand) => { - const result = parse.exec(parsedCommand); + const result = parse.exec(command); expect(result).toEqual(expected); }, expectError: (...expectedMessage: string[]) => { - expect(() => parse.exec(parsedCommand)).toThrow( + expect(() => parse.exec(command)).toThrow( new BadRequestException(expectedMessage.join('\n')), ); }, @@ -233,10 +235,10 @@ describe('ParseCommandUseCase', () => { }); it('responds if the command is unrecognised', () => { - const command: Command = { - roomId: 'my-room', - tokens: ['not', 'a', 'command'], - canonicalInput: '/not a command', + const command: IncomingCommand = { + content: '/not a command', + roomId, + authorId, }; expect(() => parse.exec(command)).toThrow( new BadRequestException( diff --git a/services/api/src/domain/usecases/commands/parse/parse-command.ts b/services/api/src/domain/usecases/commands/parse/parse-command.ts new file mode 100644 index 00000000..92162726 --- /dev/null +++ b/services/api/src/domain/usecases/commands/parse/parse-command.ts @@ -0,0 +1,30 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { ParsedCommand } from './parsed-command'; +import { + TokenizedCommand, + tokenizeCommand, +} from '@usecases/commands/parse/tokenize-command'; +import { commands } from '@usecases/commands/parse/commands'; +import { IncomingCommand } from '@entities/commands'; + +@Injectable() +export class ParseCommandUseCase { + exec(command: IncomingCommand): ParsedCommand { + const tokenizedCommand = tokenizeCommand(command); + + for (const command of commands) { + const result = command.parse(tokenizedCommand); + if (result.match) { + return result.command; + } + } + + throw new BadRequestException(unrecognisedResponse(tokenizedCommand)); + } +} + +const unrecognisedResponse = (command: TokenizedCommand): string => { + const title = `Unrecognised command \`${command.canonicalInput}\`.`; + const suggestion = 'Type `/help` for further assistance.'; + return [title, suggestion].join(' '); +}; diff --git a/services/api/src/domain/usecases/commands/parse/command.parser.ts b/services/api/src/domain/usecases/commands/parse/parsed-command.ts similarity index 58% rename from services/api/src/domain/usecases/commands/parse/command.parser.ts rename to services/api/src/domain/usecases/commands/parse/parsed-command.ts index bba2ab8b..649d3c38 100644 --- a/services/api/src/domain/usecases/commands/parse/command.parser.ts +++ b/services/api/src/domain/usecases/commands/parse/parsed-command.ts @@ -1,9 +1,13 @@ -import { Command } from '@entities/command'; import { ContentPolicy, JoinPolicy } from '@entities/rooms/room'; import { BadRequestException } from '@nestjs/common'; import { equals } from 'rambda'; import { z, ZodIssue, ZodType } from 'zod'; +import { TokenizedCommand } from '@usecases/commands/parse/tokenize-command'; +/** + * A parsed command represents a valid command with type-safe parameters, and thus can be safely + * executed. + */ export type ParsedCommand = | { tag: 'help'; params: null } | { tag: 'renameRoom'; params: { newName: string } } @@ -30,40 +34,67 @@ export type ParseResult = export type CommandSchema = ZodType; -export type CommandParserParams = { +export type CommandParser = (command: TokenizedCommand) => ParseResult; + +export type Command = { + signature: string; + summary: string; + parse: CommandParser; +}; + +type CommandParserParams = { matchTokens: string[]; schema: CommandSchema; signature: string; +}; + +export type CommandParams = CommandParserParams & { summary: string; }; -export class CommandParser { - private readonly matchTokens: string[]; - private readonly schema: CommandSchema; - readonly signature: string; - readonly summary: string; +export const buildCommand = ({ + summary, + signature, + matchTokens, + schema, +}: CommandParams): Command => ({ + signature, + summary, + parse: commandParser({ signature, matchTokens, schema }), +}); - constructor({ - matchTokens, - schema, - signature, - summary, - }: CommandParserParams) { - this.matchTokens = matchTokens; - this.schema = schema; - this.signature = signature; - this.summary = summary; - } +export const commandParser = ({ + matchTokens, + schema, + signature, +}: CommandParserParams) => { + const isMatch = (command: TokenizedCommand): boolean => { + const actualTokens = command.tokens.slice(0, matchTokens.length); + return equals(matchTokens, actualTokens); + }; - parse(command: Command): ParseResult { - if (!this.isMatch(command)) { + const errorMap: z.ZodErrorMap = (issue, ctx) => { + if (issue.code === z.ZodIssueCode.too_small) { + return { + message: `Received too few arguments. Expected: \`${signature}\``, + }; + } else if (issue.code === z.ZodIssueCode.too_big) { + return { + message: `Received too many arguments. Expected: \`${signature}\``, + }; + } + return { message: ctx.defaultError }; + }; + + return (command: TokenizedCommand): ParseResult => { + if (!isMatch(command)) { return { match: false, }; } - const result = this.schema.safeParse(command.tokens, { - errorMap: this.errorMap, + const result = schema.safeParse(command.tokens, { + errorMap, }); if (result.success) { @@ -75,29 +106,10 @@ export class CommandParser { const error = formatError(result.error.errors, command); throw new BadRequestException(error); - } - - private isMatch(command: Command): boolean { - const expectedTokens = this.matchTokens; - const actualTokens = command.tokens.slice(0, this.matchTokens.length); - return equals(expectedTokens, actualTokens); - } - - private errorMap: z.ZodErrorMap = (issue, ctx) => { - if (issue.code === z.ZodIssueCode.too_small) { - return { - message: `Received too few arguments. Expected: \`${this.signature}\``, - }; - } else if (issue.code === z.ZodIssueCode.too_big) { - return { - message: `Received too many arguments. Expected: \`${this.signature}\``, - }; - } - return { message: ctx.defaultError }; }; -} +}; -const formatError = (errors: ZodIssue[], command: Command): string => { +const formatError = (errors: ZodIssue[], command: TokenizedCommand): string => { const title = `Error in command \`${command.canonicalInput}\`:`; const errorMessages = errors.map((error) => { if (error.path.length) { diff --git a/services/api/src/domain/usecases/commands/parse/parsers/about-room-parser.ts b/services/api/src/domain/usecases/commands/parse/parsers/about-room-parser.ts deleted file mode 100644 index 2bfb1bac..00000000 --- a/services/api/src/domain/usecases/commands/parse/parsers/about-room-parser.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { z } from 'zod'; -import { CommandParser, ParsedCommand } from '../command.parser'; - -const schema = z - .tuple([z.literal('about'), z.literal('room')]) - .transform(() => ({ - tag: 'aboutRoom', - params: null, - })); - -export const aboutRoomParser = new CommandParser({ - matchTokens: ['about', 'room'], - schema, - signature: '/about room', - summary: 'about room (including policies)', -}); diff --git a/services/api/src/domain/usecases/commands/parse/parsers/approve-request-parser.ts b/services/api/src/domain/usecases/commands/parse/parsers/approve-request-parser.ts deleted file mode 100644 index ef80730d..00000000 --- a/services/api/src/domain/usecases/commands/parse/parsers/approve-request-parser.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { z } from 'zod'; -import { CommandParser, ParsedCommand } from '../command.parser'; - -const schema = z - .tuple([z.literal('approve'), z.literal('request'), z.string().email()]) - .transform(([, , email]) => ({ - tag: 'approveRequest', - params: { email }, - })); - -export const approveRequestParser = new CommandParser({ - matchTokens: ['approve', 'request'], - schema, - signature: `/approve request {email}`, - summary: 'approve pending request to join the room', -}); diff --git a/services/api/src/domain/usecases/commands/parse/parsers/help.parser.ts b/services/api/src/domain/usecases/commands/parse/parsers/help.parser.ts deleted file mode 100644 index 75560e46..00000000 --- a/services/api/src/domain/usecases/commands/parse/parsers/help.parser.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { z } from 'zod'; -import { CommandParser, ParsedCommand } from '../command.parser'; - -const schema = z.tuple([z.literal('help')]).transform(() => ({ - tag: 'help', - params: null, -})); - -export const helpParser = new CommandParser({ - matchTokens: ['help'], - schema, - signature: '/help', - summary: 'list commands', -}); diff --git a/services/api/src/domain/usecases/commands/parse/parsers/index.ts b/services/api/src/domain/usecases/commands/parse/parsers/index.ts deleted file mode 100644 index 85a8d55d..00000000 --- a/services/api/src/domain/usecases/commands/parse/parsers/index.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { CommandParser } from '../command.parser'; -import { aboutRoomParser } from './about-room-parser'; -import { approveRequestParser } from './approve-request-parser'; -import { setRoomJoinPolicyParser } from './set-room-join-policy-parser'; -import { helpParser } from './help.parser'; -import { inviteParser } from './invite-parser'; -import { leaveParser } from './leave.parser'; -import { loremParser } from './lorem.parser'; -import { renameRoomParser } from './rename.room.parser'; -import { renameUserParser } from './rename.user.parser'; -import { setRoomContentPolicyParser } from './set-room-content-policy-parser'; - -export const parsers: CommandParser[] = [ - helpParser, - loremParser, - renameRoomParser, - renameUserParser, - leaveParser, - setRoomJoinPolicyParser, - setRoomContentPolicyParser, - aboutRoomParser, - inviteParser, - approveRequestParser, -]; diff --git a/services/api/src/domain/usecases/commands/parse/parsers/invite-parser.ts b/services/api/src/domain/usecases/commands/parse/parsers/invite-parser.ts deleted file mode 100644 index 8d2a4d78..00000000 --- a/services/api/src/domain/usecases/commands/parse/parsers/invite-parser.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { z } from 'zod'; -import { CommandParser, ParsedCommand } from '../command.parser'; - -const schema = z - .tuple([z.literal('invite'), z.string().email()]) - .transform(([, email]) => ({ - tag: 'inviteUser', - params: { email }, - })); - -export const inviteParser = new CommandParser({ - matchTokens: ['invite'], - schema, - signature: `/invite {email}`, - summary: 'invite a user to the room', -}); diff --git a/services/api/src/domain/usecases/commands/parse/parsers/leave.parser.ts b/services/api/src/domain/usecases/commands/parse/parsers/leave.parser.ts deleted file mode 100644 index e11e4424..00000000 --- a/services/api/src/domain/usecases/commands/parse/parsers/leave.parser.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { z } from 'zod'; -import { CommandParser, ParsedCommand } from '../command.parser'; - -const schema = z.tuple([z.literal('leave')]).transform(() => ({ - tag: 'leave', - params: null, -})); - -export const leaveParser = new CommandParser({ - matchTokens: ['leave'], - schema, - signature: '/leave', - summary: 'leave room', -}); diff --git a/services/api/src/domain/usecases/commands/parse/parsers/lorem.parser.ts b/services/api/src/domain/usecases/commands/parse/parsers/lorem.parser.ts deleted file mode 100644 index 48c36e6a..00000000 --- a/services/api/src/domain/usecases/commands/parse/parsers/lorem.parser.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { z } from 'zod'; -import { CommandParser, ParsedCommand } from '../command.parser'; - -const schema = z - .tuple([ - z.literal('lorem'), - z.coerce.number(), - z.enum(['words', 'paragraphs']), - ]) - .transform(([, count, typeToken]) => ({ - tag: 'lorem', - params: { count, typeToken }, - })); - -export const loremParser = new CommandParser({ - matchTokens: ['lorem'], - schema, - signature: `/lorem {count} {'words' | 'paragraphs'}`, - summary: 'generate lorem text', -}); diff --git a/services/api/src/domain/usecases/commands/parse/parsers/rename.room.parser.ts b/services/api/src/domain/usecases/commands/parse/parsers/rename.room.parser.ts deleted file mode 100644 index 1f7db17d..00000000 --- a/services/api/src/domain/usecases/commands/parse/parsers/rename.room.parser.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { z } from 'zod'; -import { CommandParser, ParsedCommand } from '../command.parser'; - -const schema = z - .tuple([z.literal('rename'), z.literal('room'), z.string()]) - .rest(z.string()) - .transform(([, , name, ...rest]) => ({ - tag: 'renameRoom', - params: { newName: [name, ...rest].join(' ') }, - })); - -export const renameRoomParser = new CommandParser({ - matchTokens: ['rename', 'room'], - schema, - signature: '/rename room {name}', - summary: 'change the room name', -}); diff --git a/services/api/src/domain/usecases/commands/parse/parsers/rename.user.parser.ts b/services/api/src/domain/usecases/commands/parse/parsers/rename.user.parser.ts deleted file mode 100644 index 2e1930f4..00000000 --- a/services/api/src/domain/usecases/commands/parse/parsers/rename.user.parser.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { z } from 'zod'; -import { CommandParser, ParsedCommand } from '../command.parser'; - -const schema = z - .tuple([z.literal('rename'), z.literal('user'), z.string()]) - .rest(z.string()) - .transform(([, , name, ...rest]) => ({ - tag: 'renameUser', - params: { newName: [name, ...rest].join(' ') }, - })); - -export const renameUserParser = new CommandParser({ - matchTokens: ['rename', 'user'], - schema, - signature: '/rename user {name}', - summary: 'change your display name', -}); diff --git a/services/api/src/domain/usecases/commands/parse/parsers/set-room-content-policy-parser.ts b/services/api/src/domain/usecases/commands/parse/parsers/set-room-content-policy-parser.ts deleted file mode 100644 index 53588ffc..00000000 --- a/services/api/src/domain/usecases/commands/parse/parsers/set-room-content-policy-parser.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { z } from 'zod'; -import { CommandParser, ParsedCommand } from '../command.parser'; -import { ContentPolicy } from '@entities/rooms/room'; - -const schema = z - .tuple([ - z.literal('set'), - z.literal('room'), - z.literal('content'), - z.literal('policy'), - z.enum([ContentPolicy.Private, ContentPolicy.Public]), - ]) - .rest(z.string()) - .transform(([, , , , contentPolicy]) => ({ - tag: 'setRoomContentPolicy', - params: { newContentPolicy: contentPolicy }, - })); - -export const setRoomContentPolicyParser = new CommandParser({ - matchTokens: ['set', 'room', 'content', 'policy'], - schema, - signature: `/set room content policy {'public', 'private'}`, - summary: 'set the room content policy', -}); diff --git a/services/api/src/domain/usecases/commands/parse/parsers/set-room-join-policy-parser.ts b/services/api/src/domain/usecases/commands/parse/parsers/set-room-join-policy-parser.ts deleted file mode 100644 index 3d48becf..00000000 --- a/services/api/src/domain/usecases/commands/parse/parsers/set-room-join-policy-parser.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { z } from 'zod'; -import { CommandParser, ParsedCommand } from '../command.parser'; -import { JoinPolicy } from '@entities/rooms/room'; - -const schema = z - .tuple([ - z.literal('set'), - z.literal('room'), - z.literal('join'), - z.literal('policy'), - z.enum([JoinPolicy.Anyone, JoinPolicy.Invite, JoinPolicy.Request]), - ]) - .rest(z.string()) - .transform(([, , , , joinPolicy]) => ({ - tag: 'setRoomJoinPolicy', - params: { newJoinPolicy: joinPolicy }, - })); - -export const setRoomJoinPolicyParser = new CommandParser({ - matchTokens: ['set', 'room', 'join', 'policy'], - schema, - signature: `/set room join policy {'anyone', 'request', 'invite'}`, - summary: 'set the room join policy', -}); diff --git a/services/api/src/domain/usecases/commands/parse/tokenize-command.spec.ts b/services/api/src/domain/usecases/commands/parse/tokenize-command.spec.ts new file mode 100644 index 00000000..eb9166ea --- /dev/null +++ b/services/api/src/domain/usecases/commands/parse/tokenize-command.spec.ts @@ -0,0 +1,26 @@ +import { tokenizeCommand } from '@usecases/commands/parse/tokenize-command'; + +describe('tokenizeCommand', () => { + const roomId = 'room-id'; + const authorId = 'user-id'; + + it('tokenizes commands', () => { + expect( + tokenizeCommand({ content: '/lorem 3 words', roomId, authorId }), + ).toEqual({ + canonicalInput: '/lorem 3 words', + roomId, + tokens: ['lorem', '3', 'words'], + }); + }); + + it('derives a canonical form by ignoring excess whitespace', () => { + expect( + tokenizeCommand({ content: '/lorem 3 words', roomId, authorId }), + ).toEqual({ + canonicalInput: '/lorem 3 words', + roomId, + tokens: ['lorem', '3', 'words'], + }); + }); +}); diff --git a/services/api/src/domain/usecases/commands/parse/tokenize-command.ts b/services/api/src/domain/usecases/commands/parse/tokenize-command.ts new file mode 100644 index 00000000..07b2efc0 --- /dev/null +++ b/services/api/src/domain/usecases/commands/parse/tokenize-command.ts @@ -0,0 +1,47 @@ +import { IncomingCommand } from '@entities/commands'; + +/** + * A type representing sent commands. + * Any message prefixed with a `/` is treated as a command, e.g. `/help`. + */ +export type TokenizedCommand = { + /** + * The tokenised command. Tokens are split by whitespace. + */ + tokens: string[]; + + /** + * The room the command was sent to. + */ + roomId: string; + + /** + * A canonical representation of the command. This removes excess whitespace. + * + * E.g. `/rename room My Room` would be represented as `/rename room My Room`. + */ + canonicalInput: string; +}; + +/** + * Tokenizes an incoming command. + * @param command The command to tokenize + * @returns The tokenized command + */ +export const tokenizeCommand = ({ + content, + roomId, +}: IncomingCommand): TokenizedCommand => { + const tokens = content + .slice(1) + .split(' ') + .filter((token) => token.length > 0); + + const canonicalInput = `/${tokens.join(' ')}`; + + return { + roomId, + tokens, + canonicalInput, + }; +}; diff --git a/services/api/src/domain/usecases/messages/parse-message.spec.ts b/services/api/src/domain/usecases/messages/parse-message.spec.ts deleted file mode 100644 index dcda6ea2..00000000 --- a/services/api/src/domain/usecases/messages/parse-message.spec.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { parseMessage } from './parse-message'; - -describe('parseMessage', () => { - const roomId = 'room-id'; - const authorId = 'user-id'; - - it('parses commands', () => { - expect( - parseMessage({ content: '/lorem 3 words', roomId, authorId }), - ).toEqual({ - canonicalInput: '/lorem 3 words', - roomId, - tokens: ['lorem', '3', 'words'], - }); - }); - - it('ignores excess whitespace in commands', () => { - expect( - parseMessage({ content: '/lorem 3 words', roomId, authorId }), - ).toEqual({ - canonicalInput: '/lorem 3 words', - roomId, - tokens: ['lorem', '3', 'words'], - }); - }); - - it('parses normal messages', () => { - expect( - parseMessage({ content: 'Hello, World!', roomId, authorId }), - ).toEqual({ - content: 'Hello, World!', - roomId, - authorId, - }); - }); -}); diff --git a/services/api/src/domain/usecases/messages/parse-message.ts b/services/api/src/domain/usecases/messages/parse-message.ts deleted file mode 100644 index 066f6f79..00000000 --- a/services/api/src/domain/usecases/messages/parse-message.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { Command } from '@entities/command'; -import { DraftMessage } from '@entities/messages/message'; - -export type ParsedMessage = DraftMessage | Command; - -export const isCommand = (message: ParsedMessage): message is Command => { - return (message as Command).tokens !== undefined; -}; - -export type ParseMessageParams = { - content: string; - roomId: string; - authorId: string; -}; - -/** - * - * @param params Details of the message to parse - * @returns The parsed message - */ -export const parseMessage = ({ - content, - roomId, - authorId, -}: ParseMessageParams): ParsedMessage => { - if (content.startsWith('/')) { - const tokens = content - .slice(1) - .split(' ') - .filter((token) => token.length > 0); - - const canonicalInput = `/${tokens.join(' ')}`; - - const command: Command = { - roomId, - tokens, - canonicalInput, - }; - - return command; - } - - const message: ParsedMessage = { - content, - roomId, - authorId, - }; - - return message; -};