feat(domain): centralize data definition
This commit is contained in:
@@ -1,10 +1,10 @@
|
||||
import { createArticle } from "@basango/db/queries";
|
||||
import { createArticleResponseSchema, createArticleSchema } from "@basango/domain/models";
|
||||
import { OpenAPIHono, createRoute } from "@hono/zod-openapi";
|
||||
|
||||
import type { Context } from "#api/rest/init";
|
||||
import { withCrawlerAuth } from "#api/rest/middlewares/crawler";
|
||||
import { withDatabase } from "#api/rest/middlewares/db";
|
||||
import type { Context } from "#api/rest/types";
|
||||
import { createArticleResponseSchema, createArticleSchema } from "#api/schemas/articles";
|
||||
import { validateResponse } from "#api/utils/response";
|
||||
|
||||
const app = new OpenAPIHono<Context>();
|
||||
@@ -44,10 +44,7 @@ app.openapi(
|
||||
const input = c.req.valid("json");
|
||||
const result = await createArticle(db, input);
|
||||
|
||||
return c.json(
|
||||
validateResponse(result, createArticleResponseSchema) as { id: string; sourceId: string },
|
||||
201,
|
||||
);
|
||||
return c.json(validateResponse(result, createArticleResponseSchema), 201);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import { z } from "@hono/zod-openapi";
|
||||
|
||||
const metadataSchema = z.object({
|
||||
description: z.string().optional().openapi({
|
||||
description: "A brief description or summary of the article.",
|
||||
example: "This article discusses the latest advancements in AI technology.",
|
||||
}),
|
||||
image: z.url().optional().openapi({
|
||||
description: "The URL of the main image associated with the article.",
|
||||
example: "https://example.com/image.jpg",
|
||||
}),
|
||||
title: z.string().optional().openapi({
|
||||
description: "The title of the article for metadata purposes.",
|
||||
example: "The Rise of AI",
|
||||
}),
|
||||
});
|
||||
|
||||
export const createArticleSchema = z
|
||||
.object({
|
||||
body: z.string().min(1).openapi({
|
||||
description: "The main content of the article.",
|
||||
example: "This is the body of the article...",
|
||||
}),
|
||||
categories: z
|
||||
.array(z.string())
|
||||
.openapi({
|
||||
description: "The categories or tags associated with the article.",
|
||||
example: ["Technology", "AI"],
|
||||
})
|
||||
.optional()
|
||||
.default([]),
|
||||
hash: z.string().min(1).openapi({
|
||||
description: "The unique hash of the article link.",
|
||||
example: "d41d8cd98f00b204e9800998ecf8427e",
|
||||
}),
|
||||
link: z.url().openapi({
|
||||
description: "The URL of the article.",
|
||||
example: "https://example.com/article",
|
||||
}),
|
||||
metadata: metadataSchema.optional(),
|
||||
publishedAt: z
|
||||
.string()
|
||||
.refine((value) => !Number.isNaN(Date.parse(value)), {
|
||||
message: "Invalid date format",
|
||||
})
|
||||
.transform((value) => new Date(value))
|
||||
.openapi({
|
||||
description: "The publication date of the article in ISO 8601 format.",
|
||||
example: "2023-01-01T00:00:00Z",
|
||||
}),
|
||||
sourceId: z.string().openapi({
|
||||
description: "The unique identifier of the source from which the article was crawled.",
|
||||
example: "radiookapi.net",
|
||||
}),
|
||||
title: z.string().min(1).openapi({
|
||||
description: "The title of the article.",
|
||||
example: "The Rise of AI",
|
||||
}),
|
||||
})
|
||||
.openapi("CreateArticle");
|
||||
|
||||
export const createArticleResponseSchema = z
|
||||
.object({
|
||||
id: z.uuid().openapi({
|
||||
description: "The unique identifier of the article.",
|
||||
example: "b3b7c8e2-1f2a-4c3d-9e4f-5a6b7c8d9e0f",
|
||||
}),
|
||||
sourceId: z.uuid().openapi({
|
||||
description: "The unique identifier of the source associated with the article.",
|
||||
example: "a1a2b3c4-d5e6-7f8g-9h0i-j1k2l3m4n5o6",
|
||||
}),
|
||||
})
|
||||
.openapi("CreateArticleResponse");
|
||||
@@ -1,106 +0,0 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const idSchema = z.uuid().openapi({
|
||||
description: "The unique identifier of the source.",
|
||||
example: "b3e1c8f4-5d6a-4c9e-8f1e-2d3c4b5a6f7g",
|
||||
});
|
||||
|
||||
const biasSchema = z.enum(["neutral", "slightly", "partisan", "extreme"]).openapi({
|
||||
description: "The bias level of the source.",
|
||||
example: "neutral",
|
||||
});
|
||||
const reliabilitySchema = z
|
||||
.enum(["trusted", "reliable", "average", "low_trust", "unreliable"])
|
||||
.openapi({
|
||||
description: "The reliability level of the source.",
|
||||
example: "trusted",
|
||||
});
|
||||
|
||||
const transparencySchema = z.enum(["high", "medium", "low"]).openapi({
|
||||
description: "The transparency level of the source.",
|
||||
example: "high",
|
||||
});
|
||||
|
||||
const credibilitySchema = z
|
||||
.object({
|
||||
bias: biasSchema.default("neutral"),
|
||||
reliability: reliabilitySchema.default("average"),
|
||||
transparency: transparencySchema.default("medium"),
|
||||
})
|
||||
.openapi({
|
||||
description: "Credibility information about the source.",
|
||||
});
|
||||
|
||||
export const createSourceSchema = z.object({
|
||||
credibility: credibilitySchema.optional(),
|
||||
description: z.string().max(1024).optional().openapi({
|
||||
description: "A brief description of the source.",
|
||||
example: "Radio Okapi is a Congolese radio station that provides news and information.",
|
||||
}),
|
||||
displayName: z.string().min(1).max(255).optional().openapi({
|
||||
description: "The display name of the source.",
|
||||
example: "Radio Okapi",
|
||||
}),
|
||||
name: z.string().min(1).max(255).openapi({
|
||||
description: "The name of the source.",
|
||||
example: "radiookapi.com",
|
||||
}),
|
||||
url: z.url().openapi({
|
||||
description: "The URL of the source.",
|
||||
example: "https://techcrunch.com",
|
||||
}),
|
||||
});
|
||||
|
||||
export const getSourceSchema = z.object({
|
||||
id: idSchema,
|
||||
});
|
||||
|
||||
export const getSourcePublicationGraphSchema = z.object({
|
||||
days: z
|
||||
.number()
|
||||
.optional()
|
||||
.openapi({
|
||||
default: 60,
|
||||
description: "",
|
||||
example: 60,
|
||||
})
|
||||
.openapi({
|
||||
description: "The number of days to include in the publication graph.",
|
||||
}),
|
||||
id: idSchema,
|
||||
range: z
|
||||
.object({
|
||||
from: z.date().openapi({
|
||||
description: "The start date of the range.",
|
||||
}),
|
||||
to: z.date().openapi({
|
||||
description: "The end date of the range.",
|
||||
}),
|
||||
})
|
||||
.optional()
|
||||
.openapi({
|
||||
description: "The date range for the publication graph.",
|
||||
}),
|
||||
});
|
||||
|
||||
export const getSourceCategorySharesSchema = z.object({
|
||||
id: idSchema,
|
||||
limit: z.number().int().min(1).max(100).optional().openapi({
|
||||
default: 10,
|
||||
description: "The maximum number of categories to return.",
|
||||
example: 10,
|
||||
}),
|
||||
});
|
||||
|
||||
export const updateSourceSchema = z.object({
|
||||
credibility: credibilitySchema.optional(),
|
||||
description: createSourceSchema.shape.description,
|
||||
displayName: createSourceSchema.shape.displayName,
|
||||
id: idSchema,
|
||||
name: createSourceSchema.shape.name.optional(),
|
||||
});
|
||||
|
||||
export const createSourceResponseSchema = z.object({
|
||||
id: idSchema,
|
||||
...createSourceSchema.shape,
|
||||
});
|
||||
@@ -1,10 +1,34 @@
|
||||
import { createArticle } from "@basango/db/queries";
|
||||
import {
|
||||
createArticle,
|
||||
getArticles,
|
||||
getArticlesPublicationGraph,
|
||||
getArticlesSourceDistribution,
|
||||
} from "@basango/db/queries";
|
||||
import {
|
||||
createArticleSchema,
|
||||
getArticlesSchema,
|
||||
getDistributionsSchema,
|
||||
getPublicationsSchema,
|
||||
} from "@basango/domain/models";
|
||||
|
||||
import { createArticleSchema } from "#api/schemas/articles";
|
||||
import { createTRPCRouter, protectedProcedure } from "#api/trpc/init";
|
||||
|
||||
export const articlesRouter = createTRPCRouter({
|
||||
create: protectedProcedure.input(createArticleSchema).mutation(async ({ ctx, input }) => {
|
||||
return createArticle(ctx.db, input);
|
||||
}),
|
||||
|
||||
getPublications: protectedProcedure.input(getPublicationsSchema).query(async ({ ctx, input }) => {
|
||||
return getArticlesPublicationGraph(ctx.db, input);
|
||||
}),
|
||||
|
||||
getSourceDistribution: protectedProcedure
|
||||
.input(getDistributionsSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
return getArticlesSourceDistribution(ctx.db, input);
|
||||
}),
|
||||
|
||||
list: protectedProcedure.input(getArticlesSchema).query(async ({ ctx, input }) => {
|
||||
return getArticles(ctx.db, input);
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -6,14 +6,14 @@ import {
|
||||
getSources,
|
||||
updateSource,
|
||||
} from "@basango/db/queries";
|
||||
|
||||
import {
|
||||
createSourceSchema,
|
||||
getSourceCategorySharesSchema,
|
||||
getSourcePublicationGraphSchema,
|
||||
getCategorySharesSchema,
|
||||
getPublicationsSchema,
|
||||
getSourceSchema,
|
||||
updateSourceSchema,
|
||||
} from "#api/schemas/sources";
|
||||
} from "@basango/domain/models";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure } from "#api/trpc/init";
|
||||
|
||||
export const sourcesRouter = createTRPCRouter({
|
||||
@@ -21,24 +21,24 @@ export const sourcesRouter = createTRPCRouter({
|
||||
return createSource(ctx.db, input);
|
||||
}),
|
||||
|
||||
get: protectedProcedure.query(async ({ ctx }) => getSources(ctx.db)),
|
||||
|
||||
getById: protectedProcedure.input(getSourceSchema).query(async ({ ctx, input }) => {
|
||||
return getSourceById(ctx.db, input.id);
|
||||
}),
|
||||
|
||||
getCategoryShares: protectedProcedure
|
||||
.input(getSourceCategorySharesSchema)
|
||||
.input(getCategorySharesSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
return getSourceCategoryShares(ctx.db, input);
|
||||
}),
|
||||
|
||||
getPublicationGraph: protectedProcedure
|
||||
.input(getSourcePublicationGraphSchema)
|
||||
.input(getPublicationsSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
return getSourcePublicationGraph(ctx.db, input);
|
||||
}),
|
||||
|
||||
list: protectedProcedure.query(async ({ ctx }) => getSources(ctx.db)),
|
||||
|
||||
update: protectedProcedure.input(updateSourceSchema).mutation(async ({ ctx, input }) => {
|
||||
return updateSource(ctx.db, input);
|
||||
}),
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
export function parseInputValue(value?: string | null) {
|
||||
if (value === null) return null;
|
||||
return value ? JSON.parse(value) : undefined;
|
||||
}
|
||||
@@ -1,20 +1,19 @@
|
||||
import { logger } from "@basango/logger";
|
||||
import { z } from "zod";
|
||||
|
||||
export function validateResponse(data: unknown, schema: z.ZodSchema) {
|
||||
type ValidationSuccess<T> = z.infer<T>;
|
||||
|
||||
export function validateResponse<T extends z.ZodTypeAny>(
|
||||
data: unknown,
|
||||
schema: T,
|
||||
): ValidationSuccess<T> {
|
||||
const result = schema.safeParse(data);
|
||||
|
||||
if (!result.success) {
|
||||
const cause = z.treeifyError(result.error);
|
||||
logger.error({ cause }, "Response validation failed");
|
||||
|
||||
logger.error(cause);
|
||||
|
||||
return {
|
||||
data: null,
|
||||
details: cause,
|
||||
error: "Response validation failed",
|
||||
success: false,
|
||||
};
|
||||
throw new Error("Response validation failed");
|
||||
}
|
||||
|
||||
return result.data;
|
||||
|
||||
Reference in New Issue
Block a user