diff --git a/.changeset/lazy-plants-reflect.md b/.changeset/lazy-plants-reflect.md new file mode 100644 index 0000000..f0c5c3d --- /dev/null +++ b/.changeset/lazy-plants-reflect.md @@ -0,0 +1,5 @@ +--- +"to-do-api": patch +--- + +[CES-640] Implements adapters used by `POST /tasks` diff --git a/apps/to-do-api/local.settings.json.example b/apps/to-do-api/local.settings.json.example index ddb885a..f3b2f4a 100644 --- a/apps/to-do-api/local.settings.json.example +++ b/apps/to-do-api/local.settings.json.example @@ -6,6 +6,8 @@ "NODE_ENV": "development", "COSMOSDB_DATABASE_NAME": "db", - "COSMOSDB_ENDPOINT": "" + "COSMOSDB_ENDPOINT": "", + + "COSMOSDB_TASKS_CONTAINER_NAME": "" } } diff --git a/apps/to-do-api/package.json b/apps/to-do-api/package.json index 2b1f593..d17d82a 100644 --- a/apps/to-do-api/package.json +++ b/apps/to-do-api/package.json @@ -4,9 +4,9 @@ "type": "module", "description": "Azure Function REST API for To Do List", "license": "MIT", - "main": "./dist/main.js", + "main": "./dist/src/main.js", "scripts": { - "clean": "shx rm -rf ./dist ./src/generated", + "clean": "shx rm -rf ./dist", "build": "tsc", "typecheck": "yarn build --noemit", "lint": "eslint src --fix", @@ -25,7 +25,8 @@ "@pagopa/handler-kit-azure-func": "^2.0.7", "@pagopa/ts-commons": "^13.1.2", "fp-ts": "^2.16.9", - "io-ts": "^2.2.22" + "io-ts": "^2.2.22", + "ulid": "^2.3.0" }, "devDependencies": { "@pagopa/eslint-config": "^4.0.1", diff --git a/apps/to-do-api/src/adapters/azure/cosmosdb/TaskRepository.ts b/apps/to-do-api/src/adapters/azure/cosmosdb/TaskRepository.ts index 34e5a6c..73bfad5 100644 --- a/apps/to-do-api/src/adapters/azure/cosmosdb/TaskRepository.ts +++ b/apps/to-do-api/src/adapters/azure/cosmosdb/TaskRepository.ts @@ -1,8 +1,15 @@ +import { Container } from "@azure/cosmos"; +import * as E from "fp-ts/lib/Either.js"; import * as TE from "fp-ts/lib/TaskEither.js"; +import { pipe } from "fp-ts/lib/function.js"; import { TaskRepository } from "../../../domain/TaskRepository.js"; +import { cosmosErrorToDomainError } from "./errors.js"; -// FIXME: Implements using cosmosDB SDK -export const makeTaskRepository = (): TaskRepository => ({ - insert: (task) => TE.of(task), +export const makeTaskRepository = (container: Container): TaskRepository => ({ + insert: (task) => + pipe( + TE.tryCatch(() => container.items.create(task), E.toError), + TE.mapBoth(cosmosErrorToDomainError, () => task), + ), }); diff --git a/apps/to-do-api/src/adapters/azure/cosmosdb/__tests__/TaskRepository.test.ts b/apps/to-do-api/src/adapters/azure/cosmosdb/__tests__/TaskRepository.test.ts new file mode 100644 index 0000000..0fe5e4e --- /dev/null +++ b/apps/to-do-api/src/adapters/azure/cosmosdb/__tests__/TaskRepository.test.ts @@ -0,0 +1,44 @@ +import { Container, ErrorResponse } from "@azure/cosmos"; +import * as E from "fp-ts/lib/Either.js"; +import { describe, expect, it } from "vitest"; + +import { aTask } from "../../../../domain/__tests__/data.js"; +import { ItemAlreadyExists } from "../../../../domain/errors.js"; +import { makeTaskRepository } from "../TaskRepository.js"; +import { makeContainerMock } from "./mocks.js"; + +describe("TaskRepository", () => { + describe("insert", () => { + it("should return ItemAlreadyExists error", async () => { + const container = makeContainerMock(); + + const error = new ErrorResponse(""); + error.code = 409; + + container.items.create.mockRejectedValueOnce(error); + + const repository = makeTaskRepository(container as unknown as Container); + + const actual = await repository.insert(aTask)(); + expect(actual).toMatchObject( + E.left( + new ItemAlreadyExists( + `The item already exists; original error body: ${error.body}`, + ), + ), + ); + expect(container.items.create).nthCalledWith(1, aTask); + }); + it("should return the persisted task", async () => { + const container = makeContainerMock(); + + container.items.create.mockResolvedValue(aTask); + + const repository = makeTaskRepository(container as unknown as Container); + + const actual = await repository.insert(aTask)(); + expect(actual).toStrictEqual(E.right(aTask)); + expect(container.items.create).nthCalledWith(1, aTask); + }); + }); +}); diff --git a/apps/to-do-api/src/adapters/azure/cosmosdb/__tests__/mocks.ts b/apps/to-do-api/src/adapters/azure/cosmosdb/__tests__/mocks.ts new file mode 100644 index 0000000..91d7fe9 --- /dev/null +++ b/apps/to-do-api/src/adapters/azure/cosmosdb/__tests__/mocks.ts @@ -0,0 +1,8 @@ +import { mock, mockFn } from "vitest-mock-extended"; + +export const makeContainerMock = () => + mock({ + items: { + create: mockFn(), + }, + }); diff --git a/apps/to-do-api/src/adapters/azure/cosmosdb/errors.ts b/apps/to-do-api/src/adapters/azure/cosmosdb/errors.ts new file mode 100644 index 0000000..e0289de --- /dev/null +++ b/apps/to-do-api/src/adapters/azure/cosmosdb/errors.ts @@ -0,0 +1,17 @@ +import { ErrorResponse } from "@azure/cosmos"; + +import { ItemAlreadyExists, ItemNotFound } from "../../../domain/errors.js"; + +export const cosmosErrorToDomainError = (error: Error) => { + if (error instanceof ErrorResponse) + if (error.code === 409) + return new ItemAlreadyExists( + `The item already exists; original error body: ${error.body}`, + ); + else if (error.code === 404) + return new ItemNotFound( + `The item was not found; original error body: ${error.body}`, + ); + else return error; + else return error; +}; diff --git a/apps/to-do-api/src/adapters/http/codec.ts b/apps/to-do-api/src/adapters/http/codec.ts index f3ff8c9..8d252c2 100644 --- a/apps/to-do-api/src/adapters/http/codec.ts +++ b/apps/to-do-api/src/adapters/http/codec.ts @@ -2,6 +2,7 @@ import * as H from "@pagopa/handler-kit"; import { pipe } from "fp-ts/lib/function.js"; import { Task } from "../../domain/Task.js"; +import { ItemAlreadyExists, ItemNotFound } from "../../domain/errors.js"; import { TaskItem as TaskItemAPI } from "../../generated/definitions/internal/TaskItem.js"; import { TaskStateEnum } from "../../generated/definitions/internal/TaskState.js"; @@ -14,5 +15,25 @@ export const toTaskItemAPI = (task: Task): TaskItemAPI => ({ * This function converts any Error into an HTTP error response. * @param err the error to convert. */ -export const toHttpProblemJson = (err: Error) => - pipe(err, H.toProblemJson, H.problemJson); +export const toHttpProblemJson = (err: Error) => { + if (err instanceof ItemNotFound) { + // ItemNotFound -> 404 HTTP + return pipe( + new H.HttpNotFoundError(err.message), + H.toProblemJson, + H.problemJson, + H.withStatusCode(404), + ); + } else if (err instanceof ItemAlreadyExists) { + // ItemAlreadyExists -> 409 HTTP + return pipe( + new H.HttpConflictError(err.message), + H.toProblemJson, + H.problemJson, + H.withStatusCode(409), + ); + } else { + // Everything else -> 500 HTTP + return pipe(err, H.toProblemJson, H.problemJson); + } +}; diff --git a/apps/to-do-api/src/adapters/ulid/__tests__/id-generator.test.ts b/apps/to-do-api/src/adapters/ulid/__tests__/id-generator.test.ts new file mode 100644 index 0000000..8fefdb5 --- /dev/null +++ b/apps/to-do-api/src/adapters/ulid/__tests__/id-generator.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; + +import { makeTaskIdGenerator } from "../id-generator.js"; + +describe("TaskIdGenerator", () => { + const taskIdGenerator = makeTaskIdGenerator(); + const firstId = taskIdGenerator.generate(); + const secondId = taskIdGenerator.generate(); + + it("should produce different ids", () => { + expect(firstId).not.toStrictEqual(secondId); + }); + it("should produce ordered ids", () => { + expect(firstId < secondId).toStrictEqual(true); + }); +}); diff --git a/apps/to-do-api/src/adapters/ulid/id-generator.ts b/apps/to-do-api/src/adapters/ulid/id-generator.ts new file mode 100644 index 0000000..a99d61d --- /dev/null +++ b/apps/to-do-api/src/adapters/ulid/id-generator.ts @@ -0,0 +1,10 @@ +import { monotonicFactory } from "ulid"; + +import { Task } from "../../domain/Task.js"; +import { TaskIdGenerator } from "../../domain/TaskIdGenerator.js"; + +const ulid = monotonicFactory(); + +export const makeTaskIdGenerator = (): TaskIdGenerator => ({ + generate: () => ulid() as Task["id"], +}); diff --git a/apps/to-do-api/src/domain/errors.ts b/apps/to-do-api/src/domain/errors.ts new file mode 100644 index 0000000..87d0dea --- /dev/null +++ b/apps/to-do-api/src/domain/errors.ts @@ -0,0 +1,11 @@ +export class TooManyRequestsError extends Error { + readonly _tag = "TooManyRequestsError"; +} + +export class ItemAlreadyExists extends Error { + readonly _tag = "ItemAlreadyExists"; +} + +export class ItemNotFound extends Error { + readonly _tag = "ItemNotFound"; +} diff --git a/apps/to-do-api/src/main.ts b/apps/to-do-api/src/main.ts index 9e1305a..9112144 100644 --- a/apps/to-do-api/src/main.ts +++ b/apps/to-do-api/src/main.ts @@ -7,8 +7,8 @@ import { pipe } from "fp-ts/lib/function.js"; import { makeTaskRepository } from "./adapters/azure/cosmosdb/TaskRepository.js"; import { makePostTaskHandler } from "./adapters/azure/functions/create-task.js"; import { makeInfoHandler } from "./adapters/azure/functions/info.js"; +import { makeTaskIdGenerator } from "./adapters/ulid/id-generator.js"; import { getConfigOrError } from "./config.js"; -import { Task } from "./domain/Task.js"; const config = pipe( getConfigOrError(process.env), @@ -23,12 +23,12 @@ const cosmosClient = new CosmosClient({ endpoint: config.cosmosDb.endpoint, }); -// FIXME +const db = cosmosClient.database(config.cosmosDb.dbName); +const taskContainer = db.container(config.cosmosDb.containers.tasks); + const env = { - taskIdGenerator: { - generate: () => "fake-task-id" as Task["id"], - }, - taskRepository: makeTaskRepository(), + taskIdGenerator: makeTaskIdGenerator(), + taskRepository: makeTaskRepository(taskContainer), }; app.http("info", { diff --git a/apps/to-do-api/src/use-cases/__tests__/create-task.test.ts b/apps/to-do-api/src/use-cases/__tests__/create-task.test.ts index 8cb800e..883f52c 100644 --- a/apps/to-do-api/src/use-cases/__tests__/create-task.test.ts +++ b/apps/to-do-api/src/use-cases/__tests__/create-task.test.ts @@ -30,6 +30,7 @@ describe("createTask", () => { const actual = await createTask(title)(env)(); expect(actual).toStrictEqual(E.right(task)); + expect(env.taskIdGenerator.generate).toHaveBeenCalledTimes(1); expect(env.taskRepository.insert).toHaveBeenCalledWith(task); }); }); diff --git a/yarn.lock b/yarn.lock index 3349534..b17987a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5502,6 +5502,7 @@ __metadata: shx: "npm:^0.3.4" swagger-cli: "npm:^4.0.4" typescript: "npm:^5.7.2" + ulid: "npm:^2.3.0" vitest: "npm:^2.1.8" vitest-mock-extended: "npm:^2.0.2" languageName: unknown