diff --git a/.changeset/funny-games-dance.md b/.changeset/funny-games-dance.md new file mode 100644 index 0000000..9fe5dd4 --- /dev/null +++ b/.changeset/funny-games-dance.md @@ -0,0 +1,5 @@ +--- +"motidata": minor +--- + +Ported over GroupRepository diff --git a/.changeset/wild-rats-attack.md b/.changeset/wild-rats-attack.md new file mode 100644 index 0000000..adcc7b1 --- /dev/null +++ b/.changeset/wild-rats-attack.md @@ -0,0 +1,6 @@ +--- +"motidata": major +--- + +Repository methods read json-data themselves and return it as part of the `SimpleResponseDTO` return value. +Remove calls to `Response.json` and instead handle the new return Type `SimpleResponseDTO`. diff --git a/package.json b/package.json index a401bf5..d7ea28e 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "watch": "vitest", "test": "vitest run", "ci": "npm run lint && npm run check-format && npm run test && npm run check-exports && npm run build", + "changeset": "changeset", "local-release": "changeset version && changeset publish", "release": "changeset publish", "prepublishOnly": "npm run ci" diff --git a/src/DTOs/GroupDetailsDTO.ts b/src/DTOs/GroupDetailsDTO.ts new file mode 100644 index 0000000..5788890 --- /dev/null +++ b/src/DTOs/GroupDetailsDTO.ts @@ -0,0 +1,7 @@ +import type { GroupMemberProfileDTO } from "./GroupMemberDTO.js"; + +export interface GroupDetailsDTO { + groupName: string; + members: GroupMemberProfileDTO[]; + inviteCode: string; +} diff --git a/src/DTOs/GroupMemberDTO.ts b/src/DTOs/GroupMemberDTO.ts new file mode 100644 index 0000000..148b470 --- /dev/null +++ b/src/DTOs/GroupMemberDTO.ts @@ -0,0 +1,5 @@ +export interface GroupMemberProfileDTO { + userId: string; + username: string; + profileImageUri: string; +} diff --git a/src/DTOs/GroupMessageDTO.ts b/src/DTOs/GroupMessageDTO.ts new file mode 100644 index 0000000..5586870 --- /dev/null +++ b/src/DTOs/GroupMessageDTO.ts @@ -0,0 +1,14 @@ +export interface BaseGroupMessageDTO { + timestamp: string; + content: string; + //TODO: Research which Image Types are returned by Expo and Browser Camera, e.g. Blob or a Stream; + type: "TEXT" | "IMAGE"; + isMotiMateMessage: boolean; +} + +export interface GroupMessageDTO extends BaseGroupMessageDTO { + messageId: string; + authorId: string | null; + content: string; + clapCount: number; +} diff --git a/src/DTOs/RegistrationDTO.ts b/src/DTOs/RegistrationDTO.ts new file mode 100644 index 0000000..081e6c3 --- /dev/null +++ b/src/DTOs/RegistrationDTO.ts @@ -0,0 +1,15 @@ +export interface RegistrationDTO { + username: string; + email: string; + password: string; +} + +export const RegistrationHelper = { + buildFromObject({ + username = "", + email = "", + password = "", + }: RegistrationDTO) { + return { username, email, password }; + }, +}; diff --git a/src/DTOs/SimpleResponseDTO.ts b/src/DTOs/SimpleResponseDTO.ts new file mode 100644 index 0000000..be81a0f --- /dev/null +++ b/src/DTOs/SimpleResponseDTO.ts @@ -0,0 +1,46 @@ +import type { Serializable } from "child_process"; + +export interface SimpleResponseDTO< + BodyType extends Serializable | undefined = undefined, +> { + ok: boolean; + statusCode: number; + data?: BodyType; +} + +/** + * Collection of {@link SimpleResponseDTO} Helpers + */ +export const SimpleResponseHelpers = { + /** + * Due to limitations of NextJs Server Actions, [this can not be a class](https://react.dev/reference/rsc/use-server#serializable-parameters-and-return-values). + * @throws any `Response.json()` related error + */ + async transformToSimpleResponse< + ResponseDTO extends Serializable | undefined = undefined, + >(fetchResponse: Response): Promise> { + return { + ok: fetchResponse.ok, + statusCode: fetchResponse.status, + data: (await this._extractDataBasedOnContentType( + fetchResponse, + )) as ResponseDTO, + }; + }, + + /** + * @private + */ + async _extractDataBasedOnContentType( + fetchResponse: Response, + ): Promise { + const CONTENT_TYPE = fetchResponse.headers.get("Content-Type"); + if (CONTENT_TYPE === null) { + return undefined; + } else if (CONTENT_TYPE.includes("application/json")) { + return await fetchResponse.json(); + } else if (CONTENT_TYPE.includes("text/plain")) { + return await fetchResponse.text(); + } + }, +}; diff --git a/src/DTOs/UserDetailsDTO.ts b/src/DTOs/UserDetailsDTO.ts new file mode 100644 index 0000000..d7cab98 --- /dev/null +++ b/src/DTOs/UserDetailsDTO.ts @@ -0,0 +1,8 @@ +import type { GroupDetailsDTO } from "./GroupDetailsDTO.js"; + +export interface UserDetailsDTO { + userId: string; + personalGoal: number; + personalProgress: number; + groupInfo: GroupDetailsDTO; +} diff --git a/src/DTOs/__tests__/SimpleResponseDTO.test.ts b/src/DTOs/__tests__/SimpleResponseDTO.test.ts new file mode 100644 index 0000000..e3f3048 --- /dev/null +++ b/src/DTOs/__tests__/SimpleResponseDTO.test.ts @@ -0,0 +1,57 @@ +import { expect, test } from "vitest"; +import { + SimpleResponseHelpers, + type SimpleResponseDTO, +} from "../SimpleResponseDTO.js"; + +test("should transform to SimpleResponseDTO", async () => { + //GIVEN + const EXPECTED_STATUS = 200; + const EXPECTED_DATA = { aKey: "aValue" }; + const RESPONSE = new Response(JSON.stringify(EXPECTED_DATA), { + status: EXPECTED_STATUS, + headers: new Headers({ "Content-Type": "application/json" }), + }); + + //WHEN + const ACTUAL = + await SimpleResponseHelpers.transformToSimpleResponse< + typeof EXPECTED_DATA + >(RESPONSE); + + //THEN + expect(ACTUAL).toEqual>({ + ok: RESPONSE.ok, + statusCode: RESPONSE.status, + data: EXPECTED_DATA, + }); +}); + +test("should handle default, non json response", async () => { + //GIVEN + const EXPECTED_DATA = "12345"; + const RESPONSE = new Response(EXPECTED_DATA); + //WHEN + const ACTUAL = + await SimpleResponseHelpers._extractDataBasedOnContentType(RESPONSE); + + //THEN + expect(ACTUAL).toEqual(EXPECTED_DATA); +}); + +test("should handle Content-Type=application/json response", async () => { + //GIVEN + const EXPECTED_DATA = { testKey: "testValue" }; + const RESPONSE = new Response(JSON.stringify(EXPECTED_DATA), { + headers: new Headers({ + "Content-Type": "application/json; charset=utf-8", + }), + }); + + //WHEN + const ACTUAL = + await SimpleResponseHelpers._extractDataBasedOnContentType(RESPONSE); + + //THEN + expect(ACTUAL).toEqual(EXPECTED_DATA); +}); diff --git a/src/constants/ContentTypeHeader.ts b/src/constants/ContentTypeHeader.ts new file mode 100644 index 0000000..12b5fb4 --- /dev/null +++ b/src/constants/ContentTypeHeader.ts @@ -0,0 +1,5 @@ +export const ContentType = "Content-Type"; +export const MimeTypes = { + textPlain: "text/plain", + applicationJson: "application/json", +}; diff --git a/src/constants/DTOs/RegistrationDTO.ts b/src/constants/DTOs/RegistrationDTO.ts deleted file mode 100644 index 10e8bc5..0000000 --- a/src/constants/DTOs/RegistrationDTO.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * A Data Transfer Object containing details about a new user to be registered - */ -export class RegistrationDetails { - static formFieldNames = { - username: "username", - email: "email", - password: "password", - }; - - static buildFromObject({ - username = "", - email = "", - password = "", - }: Record) { - return new RegistrationDetails(username, email, password); - } - - constructor( - public username: string, - public email: string, - public password: string, - ) {} -} diff --git a/src/index.ts b/src/index.ts index cde19f9..c1654b5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1,19 @@ -export { UserRepository } from "./repositories/UserRepository.js"; +// Repositories: export type { SessionRepositoryInterface } from "./repositories/SessionRepositoryInterface.ts"; +export { UserRepository } from "./repositories/UserRepository.js"; +export { GroupRepository } from "./repositories/GroupRepository.js"; +//DTOs: +export type { SimpleResponseDTO as SimpleResponse } from "./DTOs/SimpleResponseDTO.ts"; +export type { RegistrationDTO } from "./DTOs/RegistrationDTO.ts"; +export type { UserDetailsDTO } from "./DTOs/UserDetailsDTO.ts"; +export type { GroupDetailsDTO } from "./DTOs/GroupDetailsDTO.ts"; +export type { GroupMemberProfileDTO } from "./DTOs/GroupMemberDTO.ts"; +export type { + GroupMessageDTO, + BaseGroupMessageDTO, +} from "./DTOs/GroupMessageDTO.ts"; +//DTO-Helpers: +export { SimpleResponseHelpers } from "./DTOs/SimpleResponseDTO.js"; +export { RegistrationHelper } from "./DTOs/RegistrationDTO.js"; +// Miscellanieous Types: +export type { BaseRepositoryConstructorParam } from "./repositories/BaseRepository.ts"; diff --git a/src/repositories/BaseRepository.ts b/src/repositories/BaseRepository.ts index 3f96196..5bd860c 100644 --- a/src/repositories/BaseRepository.ts +++ b/src/repositories/BaseRepository.ts @@ -1,15 +1,26 @@ +import type { Serializable } from "child_process"; +import { ContentType, MimeTypes } from "../constants/ContentTypeHeader.js"; import { CustomHeadersNames } from "../constants/CustomHeaders.js"; import type { SessionRepositoryInterface } from "./SessionRepositoryInterface.js"; type ApiBaseUrlType = (typeof BaseRepository.Api)[keyof typeof BaseRepository.Api]; -export interface BaseRepositoryConstructorArgs { +export interface BaseRepositoryConstructorParam { apiBaseUrl: ApiBaseUrlType; publicApiKey: string; sessionRepository: SessionRepositoryInterface; } +interface RequestParams { + route: string; + method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + queryParams?: URLSearchParams; + extraHeaders?: Headers; + body?: RequestInit["body"]; + signal?: AbortSignal | null; +} + export abstract class BaseRepository { static Api = { mockaroo: "https://my.api.mockaroo.com" }; @@ -28,20 +39,24 @@ export abstract class BaseRepository { public sessionRepository: SessionRepositoryInterface, ) {} + /** + * @throws any {@link sessionRepository} related Error + */ private async buildBaseHeaders() { - const HEADERS = new Headers(); - HEADERS.append( - CustomHeadersNames.sessionId, - await this.sessionRepository.readSessionId(), - ); - - HEADERS.append(CustomHeadersNames.apiKey, this.publicApiKey); + const HEADERS = new Headers({ + [CustomHeadersNames.sessionId]: + await this.sessionRepository.readSessionId(), + [CustomHeadersNames.apiKey]: this.publicApiKey, + [ContentType]: MimeTypes.applicationJson, + }); return HEADERS; } /** + * Sets the Content-Type to application/json by default. Can be overridden by settin {@link extraHeaders}. * @protected + * @throws any {@link sessionRepository} related Error */ async _bulildRequest({ route, @@ -50,14 +65,7 @@ export abstract class BaseRepository { extraHeaders = new Headers(), body, signal, - }: { - route: string; - method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; - queryParams?: URLSearchParams; - extraHeaders?: Headers; - body?: RequestInit["body"]; - signal?: AbortSignal | null; - }): Promise { + }: RequestParams): Promise { const headers = await this.buildBaseHeaders(); extraHeaders.forEach((headerValue, headerName) => { headers.append(headerName, headerValue); @@ -77,6 +85,20 @@ export abstract class BaseRepository { /** * @protected */ + async _buildJsonRequest( + unserializedBody: Serializable, + requestParams: Omit, + ): Promise { + return this._bulildRequest({ + ...requestParams, + body: JSON.stringify(unserializedBody), + }); + } + + /** + * @protected + * @throws any {@link sessionRepository} related Error + */ async _handleResponseAfterAuthentication( response: Response, ): Promise { diff --git a/src/repositories/GroupRepository.ts b/src/repositories/GroupRepository.ts new file mode 100644 index 0000000..6936c83 --- /dev/null +++ b/src/repositories/GroupRepository.ts @@ -0,0 +1,134 @@ +import { ContentType, MimeTypes } from "../constants/ContentTypeHeader.js"; +import type { BaseGroupMessageDTO } from "../DTOs/GroupMessageDTO.js"; +import { + SimpleResponseHelpers, + type SimpleResponseDTO, +} from "../DTOs/SimpleResponseDTO.js"; +import { + BaseRepository, + type BaseRepositoryConstructorParam, +} from "./BaseRepository.js"; + +export class GroupRepository extends BaseRepository { + constructor(baseArgs: BaseRepositoryConstructorParam) { + super( + baseArgs.apiBaseUrl, + baseArgs.publicApiKey, + baseArgs.sessionRepository, + ); + } + + /** + * @throws any `fetch()` related error + * @throws any `Response.json()` related error + * @throws any {@link sessionRepository} related Error + */ + async create(groupName: string): Promise { + const RESPONSE = await fetch( + await this._bulildRequest({ + route: "group", + method: "POST", + queryParams: new URLSearchParams({ name: groupName }), + body: groupName, + extraHeaders: new Headers({ + [ContentType]: MimeTypes.textPlain, + }), + }), + ); + + return await SimpleResponseHelpers.transformToSimpleResponse(RESPONSE); + } + + /** + * @throws any `fetch()` related error + * @throws any `Response.json()` related error + * @throws any {@link sessionRepository} related Error + */ + async join(joinCode: string): Promise { + const RESPONSE = await fetch( + await this._bulildRequest({ + route: "group/join", + method: "POST", + queryParams: new URLSearchParams({ code: joinCode }), + body: joinCode, + extraHeaders: new Headers({ + [ContentType]: MimeTypes.textPlain, + }), + }), + ); + + return await SimpleResponseHelpers.transformToSimpleResponse(RESPONSE); + } + + /** + * @throws any `fetch()` related error + * @throws any `Response.json()` related error + * @throws any {@link sessionRepository} related Error + */ + async sendMessage( + newMessageDTO: BaseGroupMessageDTO, + ): Promise { + //TODO: if process.env.NODE_ENV === "development" + const RESPONSE = await fetch( + await this._buildJsonRequest(newMessageDTO, { + route: "group/message", + method: "POST", + queryParams: new URLSearchParams({ + message: newMessageDTO.content, + }), + }), + ); + + return await SimpleResponseHelpers.transformToSimpleResponse(RESPONSE); + } + + /** + * @throws any `fetch()` related error + * @throws any `Response.json()` related error + * @throws any {@link sessionRepository} related Error + */ + async receiveExistingMessages(): Promise { + const RESPONSE = await fetch( + await this._bulildRequest({ + route: "group/message", + method: "GET", + queryParams: new URLSearchParams({ amount: "20" }), + }), + ); + + return await SimpleResponseHelpers.transformToSimpleResponse(RESPONSE); + } + + /** + * @throws any `fetch()` related error + * @throws any `Response.json()` related error + * @throws any {@link sessionRepository} related Error + */ + async receiveNewMessages(): Promise { + const RESPONSE = await fetch( + await this._bulildRequest({ + route: "group/message", + method: "GET", + queryParams: new URLSearchParams({ amount: "2" }), + }), + ); + + return await SimpleResponseHelpers.transformToSimpleResponse(RESPONSE); + } + + /** + * @throws any `fetch()` related error + * @throws any `Response.json()` related error + * @throws any {@link sessionRepository} related Error + */ + async leaveCurrentGroup(): Promise { + const RESPONSE = await fetch( + await this._bulildRequest({ + route: "group/leave", + method: "POST", + }), + ); + + return await SimpleResponseHelpers.transformToSimpleResponse(RESPONSE); + } +} diff --git a/src/repositories/UserRepository.ts b/src/repositories/UserRepository.ts index a7224e7..d54a892 100644 --- a/src/repositories/UserRepository.ts +++ b/src/repositories/UserRepository.ts @@ -1,11 +1,16 @@ -import type { RegistrationDetails } from "../constants/DTOs/RegistrationDTO.js"; +import type { RegistrationDTO } from "../DTOs/RegistrationDTO.js"; +import { + SimpleResponseHelpers, + type SimpleResponseDTO, +} from "../DTOs/SimpleResponseDTO.js"; +import type { UserDetailsDTO } from "../DTOs/UserDetailsDTO.js"; import { BaseRepository, - type BaseRepositoryConstructorArgs, + type BaseRepositoryConstructorParam, } from "./BaseRepository.js"; export class UserRepository extends BaseRepository { - constructor(baseArgs: BaseRepositoryConstructorArgs) { + constructor(baseArgs: BaseRepositoryConstructorParam) { super( baseArgs.apiBaseUrl, baseArgs.publicApiKey, @@ -14,11 +19,12 @@ export class UserRepository extends BaseRepository { } /** - * Sends a request to register the new user with {@link RegistrationDetails} and handles saving the sessionID from the Responses Cookie + * Sends a request to register the new user with {@link RegistrationDTO} and handles saving the sessionID from the Responses Header * @throws any `fetch()` related error + * @throws any `Response.json()` related error * @throws any {@link sessionRepository} related Error */ - async registerUser(body: RegistrationDetails) { + async registerUser(body: RegistrationDTO): Promise { const RESPONSE = await fetch( await this._bulildRequest({ route: BaseRepository.Routes.registration, @@ -30,15 +36,16 @@ export class UserRepository extends BaseRepository { await this._handleResponseAfterAuthentication(RESPONSE); - return RESPONSE; + return SimpleResponseHelpers.transformToSimpleResponse(RESPONSE); } /** * @throws any `fetch()` related error + * @throws any `Response.json()` related error * @throws any {@link sessionRepository} related Error */ - async verifyUser(verificationCode: string) { - return fetch( + async verifyUser(verificationCode: string): Promise { + const RESPONSE = await fetch( await this._bulildRequest({ route: BaseRepository.Routes.activation, method: "POST", @@ -46,14 +53,17 @@ export class UserRepository extends BaseRepository { body: JSON.stringify(verificationCode), }), ); + + return SimpleResponseHelpers.transformToSimpleResponse(RESPONSE); } /** * @throws any `fetch()` related error + * @throws any `Response.json()` related error * @throws any {@link sessionRepository} related Error */ - async updatePersonalGoal(goalPerWeek: number) { - return fetch( + async updatePersonalGoal(goalPerWeek: number): Promise { + const RESPONSE = await fetch( await this._bulildRequest({ route: BaseRepository.Routes.personalGoal, method: "PUT", @@ -63,21 +73,26 @@ export class UserRepository extends BaseRepository { body: JSON.stringify(goalPerWeek), }), ); + + return SimpleResponseHelpers.transformToSimpleResponse(RESPONSE); } /** - * - * * @throws any `fetch()` related error + * @throws any `Response.json()` related error * @throws any {@link sessionRepository} related Error */ - async getUserInfo(abortSignal: AbortSignal) { - return fetch( + async getUserInfo( + abortSignal: AbortSignal, + ): Promise> { + const RESPONSE = await fetch( await this._bulildRequest({ route: BaseRepository.Routes.userInfo, method: "GET", signal: abortSignal, }), ); + + return SimpleResponseHelpers.transformToSimpleResponse(RESPONSE); } } diff --git a/src/repositories/__tests__/UserRepository.test.ts b/src/repositories/__tests__/UserRepository.test.ts index 6919961..d3e7413 100644 --- a/src/repositories/__tests__/UserRepository.test.ts +++ b/src/repositories/__tests__/UserRepository.test.ts @@ -4,6 +4,7 @@ import { UserRepository } from "../UserRepository.js"; import { CustomHeadersNames } from "../../constants/CustomHeaders.js"; import { MockSessionRepository } from "./mocks/SessionRepositoryMock.js"; import { afterEach } from "node:test"; +import { ContentType, MimeTypes } from "../../constants/ContentTypeHeader.js"; const EXPECTED_TEST_KEY = "TEST12345"; @@ -17,7 +18,7 @@ afterEach(() => { MockSessionRepository.saveSessionId(""); }); -test("should append SessionId and ApiKey as Headers", async () => { +test("should append SessionId, ApiKey and Json-ContentType as Headers", async () => { //GIVEN const EXPECTED_SESSION_ID = "ID1111"; MockSessionRepository.saveSessionId(EXPECTED_SESSION_ID); @@ -30,6 +31,7 @@ test("should append SessionId and ApiKey as Headers", async () => { //THEN expect(ACTUAL.get(CustomHeadersNames.sessionId)).toBe(EXPECTED_SESSION_ID); expect(ACTUAL.get(CustomHeadersNames.apiKey)).toBe(EXPECTED_TEST_KEY); + expect(ACTUAL.get(ContentType)).toBe(MimeTypes.applicationJson); }); test("should save session id from header to session repo", async () => {