Skip to content

Commit d8a2a0a

Browse files
Merge pull request #25 from QuentinHourdeaux/feat/draft-read-and-create-api
Add draft read and create API
2 parents e3af8d9 + ba9946e commit d8a2a0a

27 files changed

Lines changed: 2872 additions & 85 deletions

api/core/draft/input.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import type { DateTime } from "effect";
2+
3+
export interface CreateDraftInput {
4+
readonly title: string;
5+
readonly description?: string;
6+
readonly stateId?: string;
7+
readonly stackId?: string | null;
8+
}
9+
10+
export interface CreateDraftRecord {
11+
readonly id: string;
12+
readonly title: string;
13+
readonly description: string;
14+
readonly createdAt: DateTime.Utc;
15+
readonly updatedAt: DateTime.Utc;
16+
readonly stateId?: string;
17+
readonly stackId?: string | null;
18+
}

api/core/draft/service-live.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { Effect, Layer } from "effect";
2+
import { utcDateTimeFromDate } from "../../lib/time/utc.ts";
3+
import { DraftNotFoundError } from "../errors.ts";
4+
import {
5+
DraftService,
6+
type DraftServiceApi,
7+
type DraftServiceDependencies,
8+
} from "./service.ts";
9+
import type { DraftStoreApi } from "./store.ts";
10+
import {
11+
validateDescription,
12+
validateDraftId,
13+
validateStackId,
14+
validateStateId,
15+
validateTitle,
16+
} from "./validation.ts";
17+
18+
export const makeDraftService = (
19+
draftStore: DraftStoreApi,
20+
dependencies: DraftServiceDependencies,
21+
): DraftServiceApi => ({
22+
listDrafts: () => draftStore.list(),
23+
24+
getDraft: (draftId) =>
25+
Effect.gen(function* () {
26+
const validatedId = yield* validateDraftId(draftId);
27+
const draft = yield* draftStore.findById(validatedId);
28+
29+
if (draft === null) {
30+
return yield* Effect.fail(
31+
new DraftNotFoundError({
32+
draftId: validatedId,
33+
}),
34+
);
35+
}
36+
37+
return draft;
38+
}),
39+
40+
createDraft: (input) =>
41+
Effect.gen(function* () {
42+
const title = yield* validateTitle(input.title);
43+
const description = yield* validateDescription(input.description ?? "");
44+
45+
if (input.stateId !== undefined) {
46+
yield* validateStateId(input.stateId);
47+
}
48+
49+
if (input.stackId !== undefined && input.stackId !== null) {
50+
yield* validateStackId(input.stackId);
51+
}
52+
53+
const timestamp = utcDateTimeFromDate(dependencies.now());
54+
55+
return yield* draftStore.createWithResolvedStateAndStack({
56+
id: dependencies.generateId(),
57+
title,
58+
description,
59+
stateId: input.stateId,
60+
stackId: input.stackId,
61+
createdAt: timestamp,
62+
updatedAt: timestamp,
63+
});
64+
}),
65+
});
66+
67+
export const DraftServiceLive = (
68+
draftStore: DraftStoreApi,
69+
dependencies: DraftServiceDependencies,
70+
): Layer.Layer<DraftService> =>
71+
Layer.succeed(DraftService, makeDraftService(draftStore, dependencies));

api/core/draft/service.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { Context, Effect } from "effect";
2+
import type { Draft } from "../../defs/draft/draft.ts";
3+
import type {
4+
DraftNotFoundError,
5+
InvalidStateScopeError,
6+
StackNotFoundError,
7+
StateNotFoundError,
8+
UnknownDraftStoreError,
9+
ValidationError,
10+
} from "../errors.ts";
11+
import type { CreateDraftInput } from "./input.ts";
12+
13+
export interface DraftServiceDependencies {
14+
readonly generateId: () => string;
15+
readonly now: () => Date;
16+
}
17+
18+
export interface DraftServiceApi {
19+
readonly listDrafts: () => Effect.Effect<
20+
readonly Draft[],
21+
UnknownDraftStoreError
22+
>;
23+
readonly getDraft: (
24+
draftId: string,
25+
) => Effect.Effect<
26+
Draft,
27+
ValidationError | UnknownDraftStoreError | DraftNotFoundError
28+
>;
29+
readonly createDraft: (
30+
input: CreateDraftInput,
31+
) => Effect.Effect<
32+
Draft,
33+
| ValidationError
34+
| UnknownDraftStoreError
35+
| StateNotFoundError
36+
| InvalidStateScopeError
37+
| StackNotFoundError
38+
>;
39+
}
40+
41+
export class DraftService extends Context.Tag("stackdraft/DraftService")<
42+
DraftService,
43+
DraftServiceApi
44+
>() {}
45+
46+
export const listDrafts = (): Effect.Effect<
47+
readonly Draft[],
48+
UnknownDraftStoreError,
49+
DraftService
50+
> => Effect.flatMap(DraftService, (service) => service.listDrafts());
51+
52+
export const getDraft = (
53+
draftId: string,
54+
): Effect.Effect<
55+
Draft,
56+
ValidationError | UnknownDraftStoreError | DraftNotFoundError,
57+
DraftService
58+
> => Effect.flatMap(DraftService, (service) => service.getDraft(draftId));
59+
60+
export const createDraft = (
61+
input: CreateDraftInput,
62+
): Effect.Effect<
63+
Draft,
64+
| ValidationError
65+
| UnknownDraftStoreError
66+
| StateNotFoundError
67+
| InvalidStateScopeError
68+
| StackNotFoundError,
69+
DraftService
70+
> => Effect.flatMap(DraftService, (service) => service.createDraft(input));

api/core/draft/store.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { Context, Effect } from "effect";
2+
import type { Draft } from "../../defs/draft/draft.ts";
3+
import type {
4+
InvalidStateScopeError,
5+
StackNotFoundError,
6+
StateNotFoundError,
7+
UnknownDraftStoreError,
8+
} from "../errors.ts";
9+
import type { CreateDraftRecord } from "./input.ts";
10+
11+
export interface DraftStoreApi {
12+
readonly list: () => Effect.Effect<readonly Draft[], UnknownDraftStoreError>;
13+
readonly findById: (
14+
draftId: string,
15+
) => Effect.Effect<Draft | null, UnknownDraftStoreError>;
16+
readonly createWithResolvedStateAndStack: (
17+
draft: CreateDraftRecord,
18+
) => Effect.Effect<
19+
Draft,
20+
| UnknownDraftStoreError
21+
| StateNotFoundError
22+
| InvalidStateScopeError
23+
| StackNotFoundError
24+
>;
25+
}
26+
27+
export class DraftStore extends Context.Tag("stackdraft/DraftStore")<
28+
DraftStore,
29+
DraftStoreApi
30+
>() {}

api/core/draft/validation.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { Effect } from "effect";
2+
import { ValidationError } from "../errors.ts";
3+
import { isUuid } from "../../lib/validation/uuid.ts";
4+
5+
const draftTitleMinLength = 1;
6+
const draftTitleMaxLength = 160;
7+
const descriptionMaxLength = 20000;
8+
9+
export const validateTitle = (
10+
title: string,
11+
): Effect.Effect<string, ValidationError> => {
12+
const trimmed = title.trim();
13+
14+
if (trimmed.length < draftTitleMinLength) {
15+
return Effect.fail(
16+
new ValidationError({
17+
fields: {
18+
title: "Title is required.",
19+
},
20+
}),
21+
);
22+
}
23+
24+
if (trimmed.length > draftTitleMaxLength) {
25+
return Effect.fail(
26+
new ValidationError({
27+
fields: {
28+
title: "Title must be 160 characters or fewer.",
29+
},
30+
}),
31+
);
32+
}
33+
34+
return Effect.succeed(trimmed);
35+
};
36+
37+
export const validateDescription = (
38+
description: string,
39+
): Effect.Effect<string, ValidationError> => {
40+
if (description.length > descriptionMaxLength) {
41+
return Effect.fail(
42+
new ValidationError({
43+
fields: {
44+
description: "Description must be 20,000 characters or fewer.",
45+
},
46+
}),
47+
);
48+
}
49+
50+
return Effect.succeed(description);
51+
};
52+
53+
export const validateDraftId = (
54+
draftId: string,
55+
): Effect.Effect<string, ValidationError> => {
56+
if (!isUuid(draftId)) {
57+
return Effect.fail(
58+
new ValidationError({
59+
fields: {
60+
draftId: "Draft ID must be a valid UUID.",
61+
},
62+
}),
63+
);
64+
}
65+
66+
return Effect.succeed(draftId);
67+
};
68+
69+
export const validateStateId = (
70+
stateId: string,
71+
): Effect.Effect<string, ValidationError> => {
72+
if (!isUuid(stateId)) {
73+
return Effect.fail(
74+
new ValidationError({
75+
fields: {
76+
stateId: "State ID must be a valid UUID.",
77+
},
78+
}),
79+
);
80+
}
81+
82+
return Effect.succeed(stateId);
83+
};
84+
85+
export const validateStackId = (
86+
stackId: string,
87+
): Effect.Effect<string, ValidationError> => {
88+
if (!isUuid(stackId)) {
89+
return Effect.fail(
90+
new ValidationError({
91+
fields: {
92+
stackId: "Stack ID must be a valid UUID.",
93+
},
94+
}),
95+
);
96+
}
97+
98+
return Effect.succeed(stackId);
99+
};

api/core/errors.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,17 @@ export class StackNotFoundError extends Data.TaggedError("StackNotFoundError")<{
6666
readonly stackId: string;
6767
}> {}
6868

69+
// Draft
70+
71+
export class UnknownDraftStoreError
72+
extends Data.TaggedError("UnknownDraftStoreError")<{
73+
readonly cause: unknown;
74+
}> {}
75+
76+
export class DraftNotFoundError extends Data.TaggedError("DraftNotFoundError")<{
77+
readonly draftId: string;
78+
}> {}
79+
6980
// Database
7081

7182
export class DatabaseError extends Data.TaggedError("DatabaseError")<{

api/defs/draft/draft-schema.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { Schema } from "effect";
2+
import { UuidSchema } from "../../lib/validation/uuid.ts";
3+
4+
export const DraftSchema = Schema.Struct({
5+
id: UuidSchema,
6+
stackId: Schema.NullOr(UuidSchema),
7+
title: Schema.String,
8+
description: Schema.String,
9+
stateId: UuidSchema,
10+
createdAt: Schema.DateTimeUtc,
11+
updatedAt: Schema.DateTimeUtc,
12+
});
13+
14+
export const DraftsResponseSchema = Schema.Struct({
15+
drafts: Schema.Array(DraftSchema),
16+
});
17+
18+
export const CreateDraftBodySchema = Schema.Struct({
19+
title: Schema.String,
20+
description: Schema.optional(Schema.String),
21+
stateId: Schema.optional(UuidSchema),
22+
stackId: Schema.optional(Schema.NullOr(UuidSchema)),
23+
});
24+
25+
export type DraftResponse = Schema.Schema.Type<typeof DraftSchema>;
26+
export type DraftsResponse = Schema.Schema.Type<typeof DraftsResponseSchema>;
27+
export type CreateDraftBody = Schema.Schema.Type<typeof CreateDraftBodySchema>;
28+
29+
export const encodeDraftsResponse = Schema.encodeSync(DraftsResponseSchema);
30+
export const encodeDraftResponse = Schema.encodeSync(DraftSchema);

api/defs/draft/draft.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import type { DateTime } from "effect";
2+
3+
export interface Draft {
4+
readonly id: string;
5+
readonly stackId: string | null;
6+
readonly title: string;
7+
readonly description: string;
8+
readonly stateId: string;
9+
readonly createdAt: DateTime.Utc;
10+
readonly updatedAt: DateTime.Utc;
11+
}

0 commit comments

Comments
 (0)