Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[CES-644] List tasks #57

Merged
merged 6 commits into from
Jan 15, 2025
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions apps/to-do-api/docs/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,22 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/ProblemJSON'
get:
operationId: listTasks
summary: List tasks
responses:
'200':
description: Returns a list of tasks.
content:
application/json:
schema:
$ref: '#/components/schemas/TaskItemList'
'500':
description: Internal server error
content:
application/json:
schema:
$ref: '#/components/schemas/ProblemJSON'

components:
schemas:
Expand Down Expand Up @@ -94,6 +110,10 @@ components:
$ref: '#/components/schemas/TaskTitle'
state:
$ref: '#/components/schemas/TaskState'
TaskItemList:
type: array
items:
$ref: '#/components/schemas/TaskItem'
ProblemJSON:
type: object
required:
Expand Down
11 changes: 11 additions & 0 deletions apps/to-do-api/src/adapters/azure/cosmosdb/TaskRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ 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 { TaskCodec } from "../../../domain/Task.js";
import { TaskRepository } from "../../../domain/TaskRepository.js";
import { decodeFromFeed } from "./decode.js";
import { cosmosErrorToDomainError } from "./errors.js";

export const makeTaskRepository = (container: Container): TaskRepository => ({
Expand All @@ -12,4 +14,13 @@ export const makeTaskRepository = (container: Container): TaskRepository => ({
TE.tryCatch(() => container.items.create(task), E.toError),
TE.mapBoth(cosmosErrorToDomainError, () => task),
),
list: () =>
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: Currently, due to the goal of this playground, no pagination is available.

If we need that, we are going to update this function

pipe(
TE.tryCatch(
() => container.items.query("SELECT * FROM c").fetchAll(),
E.toError,
),
TE.flatMapEither(decodeFromFeed(TaskCodec)),
TE.mapLeft(cosmosErrorToDomainError),
),
});
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as E from "fp-ts/lib/Either.js";
import { describe, expect, it } from "vitest";

import { aTask } from "../../../../domain/__tests__/data.js";
import { TaskCodec } from "../../../../domain/Task.js";
import { ItemAlreadyExists } from "../../../../domain/errors.js";
import { makeTaskRepository } from "../TaskRepository.js";
import { makeContainerMock } from "./mocks.js";
Expand Down Expand Up @@ -41,4 +42,52 @@ describe("TaskRepository", () => {
expect(container.items.create).nthCalledWith(1, aTask);
});
});

describe("list", () => {
it("should return a Left with the error", async () => {
const container = makeContainerMock();

const error = new Error("Something went wrong");
container.items.query.mockReturnValueOnce({
fetchAll: () => Promise.reject(error),
});

const repository = makeTaskRepository(container as unknown as Container);

const actual = await repository.list()();
expect(actual).toStrictEqual(E.left(error));
});
it("should return a Left with the decoding error", async () => {
const container = makeContainerMock();

const anInvalidObject = { key: "aKey" };
container.items.query.mockReturnValueOnce({
fetchAll: () => Promise.resolve({ resources: [anInvalidObject] }),
});

const repository = makeTaskRepository(container as unknown as Container);

const actual = await repository.list()();
expect(actual).toStrictEqual(
E.left(
new Error(
`Unable to parse the resources using codec ${TaskCodec.name}`,
),
),
);
});
it("should return a Right with list of tasks", async () => {
const container = makeContainerMock();

container.items.query.mockReturnValueOnce({
fetchAll: () => Promise.resolve({ resources: [{ ...aTask }] }),
});

const repository = makeTaskRepository(container as unknown as Container);

const actual = await repository.list()();
expect(actual).toStrictEqual(E.right([aTask]));
expect(container.items.query).nthCalledWith(1, "SELECT * FROM c");
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@ export const makeContainerMock = () =>
mock({
items: {
create: mockFn(),
query: mockFn(),
},
});
21 changes: 21 additions & 0 deletions apps/to-do-api/src/adapters/azure/cosmosdb/decode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { FeedResponse } from "@azure/cosmos";
import * as E from "fp-ts/lib/Either.js";
import { pipe } from "fp-ts/lib/function.js";
import * as t from "io-ts";

/**
* Decode a list of resources, extracted from a FeedResponse, using a codec.
*
* @param codec the io-ts codec to use to decode the resources
*/
export const decodeFromFeed =
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thought: this function, and also other decode functions, might be in a separate package and could be used to parse what's coming from the CosmosDB server to a well-known domain type.

<A, O>(codec: t.Type<A, O>) =>
<T extends FeedResponse<unknown>>(list: T) =>
pipe(
list.resources,
t.array(codec).decode,
E.mapLeft(
() =>
new Error(`Unable to parse the resources using codec ${codec.name}`),
),
);
30 changes: 30 additions & 0 deletions apps/to-do-api/src/adapters/azure/functions/get-tasks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import * as H from "@pagopa/handler-kit";
import { httpAzureFunction } from "@pagopa/handler-kit-azure-func";
import * as RTE from "fp-ts/lib/ReaderTaskEither.js";
import * as RA from "fp-ts/lib/ReadonlyArray.js";
import { flow, pipe } from "fp-ts/lib/function.js";

import { Capabilities } from "../../../domain/Capabilities.js";
import { listTasks } from "../../../domain/TaskRepository.js";
import { TaskItemList } from "../../../generated/definitions/internal/TaskItemList.js";
import { toHttpProblemJson, toTaskItemAPI } from "../../http/codec.js";

type Env = Pick<Capabilities, "taskRepository">;

const makeHandlerKitHandler: H.Handler<
H.HttpRequest,
| H.HttpResponse<H.ProblemJson, H.HttpErrorStatusCode>
| H.HttpResponse<TaskItemList>,
Env
> = H.of(() =>
pipe(
RTE.ask<Env>(),
// execute use case
RTE.flatMap(listTasks),
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: in this case, the listTasks isn't a use case but a function under the domain layer.

this is wanted because the use case would be just a wrapper around the function already used, the listTasks.
If the list operation is going to be more complicated, with some custom logic before sending the result, then a use case would be the place where to hide that logic.

// handle result and prepare response
RTE.mapBoth(toHttpProblemJson, flow(RA.map(toTaskItemAPI), H.successJson)),
RTE.orElseW(RTE.of),
),
);

export const makeGetTasksHandler = httpAzureFunction(makeHandlerKitHandler);
7 changes: 7 additions & 0 deletions apps/to-do-api/src/domain/TaskRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,17 @@ import { Task } from "./Task.js";

export interface TaskRepository {
insert: (task: Task) => TE.TaskEither<Error, Task>;
list: () => TE.TaskEither<Error, readonly Task[]>;
}

export const insertTask = (task: Task) =>
pipe(
RTE.ask<Pick<Capabilities, "taskRepository">>(),
RTE.flatMapTaskEither(({ taskRepository }) => taskRepository.insert(task)),
);

export const listTasks = () =>
pipe(
RTE.ask<Pick<Capabilities, "taskRepository">>(),
RTE.flatMapTaskEither(({ taskRepository }) => taskRepository.list()),
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/**
* Do not edit this file it is auto-generated by io-utils / gen-api-models.
* See https://github.com/pagopa/io-utils
*/
/* eslint-disable */

import * as t from "io-ts";
import { TaskItem } from "./TaskItem.js";

export type TaskItemList = t.TypeOf<typeof TaskItemList>;
export const TaskItemList = t.readonlyArray(TaskItem, "array of TaskItem");
8 changes: 8 additions & 0 deletions apps/to-do-api/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ 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 { makeGetTasksHandler } from "./adapters/azure/functions/get-tasks.js";
import { makeInfoHandler } from "./adapters/azure/functions/info.js";
import { makeTaskIdGenerator } from "./adapters/ulid/id-generator.js";
import { getConfigOrError } from "./config.js";
Expand Down Expand Up @@ -44,3 +45,10 @@ app.http("createTask", {
methods: ["POST"],
route: "tasks",
});

app.http("getTask", {
authLevel: "function",
handler: makeGetTasksHandler(env),
methods: ["GET"],
route: "tasks",
});
Loading