refactor: centralize configuration

This commit is contained in:
2025-11-23 19:54:32 +02:00
parent 57a8501c88
commit 72dfa53f80
78 changed files with 2252 additions and 1385 deletions
+29
View File
@@ -0,0 +1,29 @@
import z from "zod";
export const ApiConfigurationSchema = z.object({
cors: z.object({
allowedHeaders: z.array(z.string()).default([]),
allowMethods: z.array(z.string()).default([]),
exposeHeaders: z.array(z.string()).default([]),
maxAge: z.number().int().min(0).optional(),
origin: z
.array(z.string())
.optional()
.default(["http://localhost:3000", "http://127.0.0.1:3000", "https://dashboard.basango.io"]),
}),
security: z.object({
accessTokenTtl: z.string(),
audience: z.string(),
crawlerToken: z.string(),
issuer: z.string(),
jwtSecret: z.string(),
refreshTokenTtl: z.string(),
}),
server: z.object({
host: z.string().default("localhost"),
port: z.number().int().min(1).max(65535).default(3080),
version: z.string().default("1.0.0"),
}),
});
export type ApiConfiguration = z.infer<typeof ApiConfigurationSchema>;
+107
View File
@@ -0,0 +1,107 @@
import { z } from "zod";
import { SOURCE_KINDS } from "../constants";
import { PageRangeSchema, TimestampRangeSchema, UpdateDirectionSchema } from "../models";
export const SourceKindSchema = z.enum(SOURCE_KINDS);
export const SourceDateSchema = z.object({
format: z.string().default("yyyy-LL-dd HH:mm"),
});
const SourceOptionsSchema = z.object({
categories: z.array(z.string()).default([]),
requiresDetails: z.boolean().default(false),
requiresRateLimit: z.boolean().default(false),
sourceDate: SourceDateSchema,
sourceId: z.string(),
sourceKind: SourceKindSchema,
sourceUrl: z.url(),
supportsCategories: z.boolean().default(false),
});
export const HtmlSourceOptionsSchema = SourceOptionsSchema.extend({
paginationTemplate: z.string(),
sourceKind: z.literal("html"),
sourceSelectors: z.object({
articleBody: z.string(),
articleCategories: z.string().optional(),
articleDate: z.string(),
articleLink: z.string(),
articles: z.string(),
articleTitle: z.string(),
pagination: z.string().default("ul.pagination > li a"),
}),
});
export const WordPressSourceOptionsSchema = SourceOptionsSchema.extend({
sourceDate: SourceDateSchema.default(SourceDateSchema.parse({ format: "yyyy-LL-dd'T'HH:mm:ss" })),
sourceKind: z.literal("wordpress"),
});
export const CrawlerConfigurationSchema = z.object({
backend: z.object({
endpoint: z.url(),
token: z.string(),
}),
fetch: z.object({
async: z.object({
prefix: z.string().default("basango:crawler:queue"),
queues: z.object({
details: z.string().default("details"),
listing: z.string().default("listing"),
processing: z.string().default("processing"),
}),
redisUrl: z.string().default("redis://localhost:6379/0"),
ttl: z.object({
default: z.number().int().positive().default(600),
failure: z.number().int().nonnegative().default(3600),
result: z.number().int().nonnegative().default(3600),
}),
}),
client: z.object({
backoffInitial: z.number().nonnegative().default(1),
backoffMax: z.number().nonnegative().default(30),
backoffMultiplier: z.number().positive().default(2),
followRedirects: z.boolean().default(true),
maxRetries: z.number().int().nonnegative().default(3),
respectRetryAfter: z.boolean().default(true),
rotate: z.boolean().default(true),
timeout: z.number().positive().default(20),
userAgent: z.string().default("Basango/0.1 (+https://github.com/bernard-ng/basango)"),
verifySsl: z.boolean().default(true),
}),
crawler: z.object({
category: z.string().optional(),
dateRange: TimestampRangeSchema.optional(),
direction: UpdateDirectionSchema.default("forward"),
isUpdate: z.boolean().default(false),
maxWorkers: z.number().int().positive().default(5),
notify: z.boolean().default(false),
pageRange: PageRangeSchema.optional(),
source: z.union([HtmlSourceOptionsSchema, WordPressSourceOptionsSchema]).optional(),
useMultiThreading: z.boolean().default(false),
}),
}),
paths: z.object({
data: z.string(),
root: z.string(),
}),
sources: z.object({
html: z.array(HtmlSourceOptionsSchema).default([]),
wordpress: z.array(WordPressSourceOptionsSchema).default([]),
}),
});
// types
export type SourceKind = z.infer<typeof SourceKindSchema>;
export type SourceDate = z.infer<typeof SourceDateSchema>;
export type HtmlSourceOptions = z.infer<typeof HtmlSourceOptionsSchema>;
export type WordPressSourceOptions = z.infer<typeof WordPressSourceOptionsSchema>;
export type AnySourceOptions = HtmlSourceOptions | WordPressSourceOptions;
export type CrawlerConfiguration = z.infer<typeof CrawlerConfigurationSchema>;
export type CrawlerHttpOptions = CrawlerConfiguration["fetch"]["client"];
export type CrawlerFetchingOptions = CrawlerConfiguration["fetch"]["crawler"];
export type CrawlerAsyncOptions = CrawlerConfiguration["fetch"]["async"];
export type CrawlerBackendOptions = CrawlerConfiguration["backend"];
+15
View File
@@ -0,0 +1,15 @@
import z from "zod";
export const DatabaseConfigurationSchema = z.object({
legacy: z.object({
host: z.string().min(1),
name: z.string().min(1),
password: z.string().min(1),
port: z.number().optional(),
user: z.string().min(1),
}),
url: z.string().min(1),
});
// types
export type DatabaseConfiguration = z.infer<typeof DatabaseConfigurationSchema>;
+18
View File
@@ -0,0 +1,18 @@
import z from "zod";
import {
DEFAULT_AUTH_TAG_LENGTH,
DEFAULT_BCRYPT_SALT_ROUNDS,
DEFAULT_IV_LENGTH,
} from "../constants";
export const EncryptionConfigurationSchema = z.object({
algorithm: z.enum(["aes-128-gcm", "aes-192-gcm", "aes-256-gcm"]),
authTagLength: z.number().nonnegative().default(DEFAULT_AUTH_TAG_LENGTH),
bcryptSaltRounds: z.number().nonnegative().default(DEFAULT_BCRYPT_SALT_ROUNDS),
ivLength: z.number().nonnegative().default(DEFAULT_IV_LENGTH),
key: z.string(),
});
// types
export type EncryptionConfiguration = z.infer<typeof EncryptionConfigurationSchema>;
+72
View File
@@ -0,0 +1,72 @@
import path from "node:path";
import { defineConfig } from "@devscast/config";
import z from "zod";
import { ApiConfigurationSchema } from "./api";
import { CrawlerConfigurationSchema } from "./crawler";
import { DatabaseConfigurationSchema } from "./database";
import { EncryptionConfigurationSchema } from "./encryption";
import { LoggerConfigurationSchema } from "./logger";
import { SharedConfigurationSchema } from "./shared";
export * from "./api";
export * from "./crawler";
export * from "./database";
export * from "./encryption";
export * from "./logger";
export * from "./shared";
const root = path.resolve(__dirname, "../../../../");
const domain = path.join(root, "packages", "domain", "config");
export const { env, config } = defineConfig({
env: {
knownKeys: [
"NODE_ENV",
"BASANGO_API_HOST",
"BASANGO_API_PORT",
"BASANGO_API_ALLOWED_ORIGINS",
"BASANGO_API_KEY",
"BASANGO_API_CRAWLER_TOKEN",
"BASANGO_API_JWT_SECRET",
"BASANGO_DATABASE_URL",
"BASANGO_DATABASE_LEGACY_HOST",
"BASANGO_DATABASE_LEGACY_PASSWORD",
"BASANGO_DATABASE_LEGACY_NAME",
"BASANGO_DATABASE_LEGACY_USER",
"BASANGO_CRAWLER_ROOT_PATH",
"BASANGO_CRAWLER_DATA_PATH",
"BASANGO_CRAWLER_LOGS_PATH",
"BASANGO_CRAWLER_CONFIG_PATH",
"BASANGO_CRAWLER_UPDATE_DIRECTION",
"BASANGO_CRAWLER_FETCH_USER_AGENT",
"BASANGO_CRAWLER_FETCH_MAX_RETRIES",
"BASANGO_CRAWLER_FETCH_RESPECT_RETRY_AFTER",
"BASANGO_CRAWLER_ASYNC_REDIS_URL",
"BASANGO_CRAWLER_ASYNC_TTL_RESULT",
"BASANGO_CRAWLER_ASYNC_TTL_FAILURE",
"BASANGO_CRAWLER_ASYNC_QUEUE_LISTING",
"BASANGO_CRAWLER_ASYNC_QUEUE_DETAILS",
"BASANGO_CRAWLER_ASYNC_QUEUE_PROCESSING",
"BASANGO_ENCRYPTION_KEY",
] as const,
path: path.join(root, ".env"),
},
schema: z.object({
api: ApiConfigurationSchema,
crawler: CrawlerConfigurationSchema,
database: DatabaseConfigurationSchema,
encryption: EncryptionConfigurationSchema,
logger: LoggerConfigurationSchema,
shared: SharedConfigurationSchema,
}),
sources: [
path.join(domain, "api.json"),
path.join(domain, "crawler.json"),
path.join(domain, "database.json"),
path.join(domain, "encryption.json"),
path.join(domain, "logger.json"),
path.join(domain, "shared.json"),
],
});
+8
View File
@@ -0,0 +1,8 @@
import z from "zod";
export const LoggerConfigurationSchema = z.object({
level: z.string().default("info"),
});
// types
export type LoggerConfiguration = z.infer<typeof LoggerConfigurationSchema>;
+17
View File
@@ -0,0 +1,17 @@
import z from "zod";
export const SharedConfigurationSchema = z.object({
categorySharesLimit: z.number().int().min(1).default(10),
dateFormat: z.string(),
dateTimeFormat: z.string(),
name: z.string().default("Basango"),
pagination: z.object({
defaultLimit: z.number().int().min(1).max(100),
maxLimit: z.number().int().min(1).max(100),
page: z.number().int().min(1),
}),
publicationGraphDays: z.number().int().min(1),
timezone: z.string(),
});
export type SharedConfiguration = z.infer<typeof SharedConfigurationSchema>;
+1 -3
View File
@@ -1,10 +1,8 @@
// Domain-specific constants and types
export const BIAS = ["neutral", "slightly", "partisan", "extreme"] as const;
export const RELIABILITY = ["trusted", "reliable", "average", "low_trust", "unreliable"] as const;
export const TRANSPARENCY = ["high", "medium", "low"] as const;
export const SENTIMENT = ["positive", "neutral", "negative"] as const;
// Crawler-related constants and types
export const UPDATE_DIRECTIONS = ["forward", "backward"] as const;
export const SOURCE_KINDS = ["wordpress", "html"] as const;
@@ -32,5 +30,5 @@ export const DEFAULT_AUTH_TAG_LENGTH = 16;
export const DEFAULT_BCRYPT_SALT_ROUNDS = 12;
export const DEFAULT_TOKEN_AUDIENCE = "basango_dashboard";
export const DEFAULT_TOKEN_ISSUER = "basango_api";
export const DEFAULT_ACCESS_TOKEN_TTL = "15m";
export const DEFAULT_ACCESS_TOKEN_TTL = "35m";
export const DEFAULT_REFRESH_TOKEN_TTL = "7d";
-47
View File
@@ -1,47 +0,0 @@
import { z } from "zod";
import { SOURCE_KINDS } from "#domain/constants";
// schemas
export const SourceKindSchema = z.enum(SOURCE_KINDS);
export const SourceDateSchema = z.object({
format: z.string().default("yyyy-LL-dd HH:mm"),
});
const SourceConfigSchema = z.object({
categories: z.array(z.string()).default([]),
requiresDetails: z.boolean().default(false),
requiresRateLimit: z.boolean().default(false),
sourceDate: SourceDateSchema,
sourceId: z.string(),
sourceKind: SourceKindSchema,
sourceUrl: z.url(),
supportsCategories: z.boolean().default(false),
});
export const HtmlSourceConfigSchema = SourceConfigSchema.extend({
paginationTemplate: z.string(),
sourceKind: z.literal("html"),
sourceSelectors: z.object({
articleBody: z.string(),
articleCategories: z.string().optional(),
articleDate: z.string(),
articleLink: z.string(),
articles: z.string(),
articleTitle: z.string(),
pagination: z.string().default("ul.pagination > li a"),
}),
});
export const WordPressSourceConfigSchema = SourceConfigSchema.extend({
sourceDate: SourceDateSchema.default(SourceDateSchema.parse({ format: "yyyy-LL-dd'T'HH:mm:ss" })),
sourceKind: z.literal("wordpress"),
});
// types
export type SourceKind = z.infer<typeof SourceKindSchema>;
export type SourceDate = z.infer<typeof SourceDateSchema>;
export type HtmlSourceConfig = z.infer<typeof HtmlSourceConfigSchema>;
export type WordPressSourceConfig = z.infer<typeof WordPressSourceConfigSchema>;
export type AnySourceConfig = HtmlSourceConfig | WordPressSourceConfig;
-2
View File
@@ -1,2 +0,0 @@
export * from "./config";
export * from "./schemas";
+41 -161
View File
@@ -1,185 +1,65 @@
import { z } from "@hono/zod-openapi";
import { idSchema, sentimentSchema } from "#domain/models/shared";
import z from "zod";
import { idSchema, sentimentSchema } from "./shared";
import { sourceSchema } from "./sources";
// schemas
export const articleMetadataSchema = z.object({
author: z.string().optional().openapi({
description: "The author of the article.",
example: "John Doe",
}),
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",
}),
publishedAt: z.date().optional().openapi({
description: "The publication date of the article as a Date object.",
example: "2023-01-01T00:00:00Z",
}),
title: z.string().optional().openapi({
description: "The title of the article for metadata purposes.",
example: "The Rise of AI",
}),
updatedAt: z.date().optional().openapi({
description: "The last updated date of the article as a Date object.",
example: "2023-01-02T12:00:00Z",
}),
url: z.url().optional().openapi({
description: "The canonical URL of the article.",
example: "https://example.com/article",
}),
author: z.string().optional(),
description: z.string().optional(),
image: z.url().optional(),
publishedAt: z.date().optional(),
title: z.string().optional(),
updatedAt: z.date().optional(),
url: z.url().optional(),
});
export const tokenStatisticsSchema = z.object({
body: z.number().optional().default(0).openapi({
description: "The number of tokens in the article body.",
example: 250,
}),
categories: z.number().optional().default(0).openapi({
description: "The number of tokens in the article categories.",
example: 3,
}),
excerpt: z.number().optional().default(0).openapi({
description: "The number of tokens in the article excerpt.",
example: 50,
}),
title: z.number().optional().default(0).openapi({
description: "The number of tokens in the article title.",
example: 10,
}),
total: z.number().optional().default(0).openapi({
description: "The total number of tokens in the article.",
example: 313,
}),
body: z.number().optional().default(0),
categories: z.number().optional().default(0),
excerpt: z.number().optional().default(0),
title: z.number().optional().default(0),
total: z.number().optional().default(0),
});
export const articleSchema = 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"],
}),
createdAt: z.date().openapi({
description: "The date and time when the article was created in the system.",
example: "2023-01-01T12:00:00Z",
}),
excerpt: z.string().optional().openapi({
description: "A brief excerpt or summary of the article.",
example: "This article discusses the latest advancements in AI technology.",
}),
hash: z.string().min(1).openapi({
description: "The unique hash of the article link.",
example: "d41d8cd98f00b204e9800998ecf8427e",
}),
body: z.string().min(1),
categories: z.array(z.string()),
createdAt: z.date(),
excerpt: z.string().optional(),
hash: z.string().min(1),
id: idSchema,
image: z.url().optional().openapi({
description: "The URL of the main image associated with the article.",
example: "https://example.com/image.jpg",
}),
link: z.string().url().openapi({
description: "The URL of the article.",
example: "https://example.com/article",
}),
image: z.url().optional(),
link: z.url(),
metadata: articleMetadataSchema.optional(),
publishedAt: z.date().openapi({
description: "The publication date of the article as a Date object.",
example: "2023-01-01T00:00:00Z",
}),
readingTime: z.number().int().min(1).openapi({
description: "Estimated reading time of the article in minutes.",
example: 5,
}),
publishedAt: z.date(),
readingTime: z.number().int().min(1),
source: sourceSchema.optional(),
sourceId: z.union([z.uuid(), z.string().min(1)]).openapi({
description: "The unique identifier of the source from which the article was crawled.",
example: "b3e1c8f4-5d6a-4c9e-8f1e-2d3c4b5a6f7g",
}),
title: z.string().min(1).openapi({
description: "The title of the article.",
example: "The Rise of AI",
}),
sourceId: z.union([z.uuid(), z.string().min(1)]),
title: z.string().min(1),
tokenStatistics: tokenStatisticsSchema.optional(),
updatedAt: z.date().optional().openapi({
description: "The date and time when the article was last updated in the system.",
example: "2023-01-02T12:00:00Z",
}),
updatedAt: z.date().optional(),
});
// API
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.string().url().openapi({
description: "The URL of the article.",
example: "https://example.com/article",
}),
metadata: articleMetadataSchema.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 createArticleSchema = z.object({
body: z.string().min(1),
categories: z.array(z.string()).optional().default([]),
hash: z.string().min(1),
link: z.url(),
metadata: articleMetadataSchema.optional(),
publishedAt: z.coerce.date(),
sourceId: z.string(),
title: z.string().min(1),
});
export const createArticleResponseSchema = z
.object({ id: idSchema, sourceId: idSchema })
.openapi("CreateArticleResponse");
export const createArticleResponseSchema = z.object({ id: idSchema, sourceId: idSchema });
export const getArticlesSchema = z.object({
category: z.string().min(1).max(255).optional().openapi({
description: "Filter articles by a specific category.",
example: "Technology",
}),
cursor: z.string().nullable().optional().openapi({
description: "Optional cursor for fetching the next page of articles.",
}),
limit: z.number().int().min(1).max(100).optional().openapi({
default: 10,
description: "Maximum number of articles to return per page.",
example: 20,
}),
search: z.string().max(512).optional().openapi({
description: "Full-text search query applied to article titles and bodies.",
example: "gouvernement congolais",
}),
category: z.string().min(1).max(255).optional(),
cursor: z.string().nullable().optional(),
limit: z.number().int().min(1).max(100).optional(),
search: z.string().max(512).optional(),
sentiment: sentimentSchema.optional(),
sourceId: idSchema.optional(),
});
+4 -12
View File
@@ -1,18 +1,10 @@
import { z } from "@hono/zod-openapi";
import z from "zod";
export const loginSchema = z.object({
email: z.email().openapi({
description: "Email address used to authenticate the user.",
example: "user@example.com",
}),
password: z.string().min(8).openapi({
description: "Account password.",
example: "••••••••",
}),
email: z.email(),
password: z.string().min(8),
});
export const refreshSessionSchema = z.object({
refreshToken: z.string().min(1).openapi({
description: "Refresh token returned when logging in.",
}),
refreshToken: z.string().min(1),
});
@@ -1,6 +1,6 @@
import { z } from "zod";
import z from "zod";
import { UPDATE_DIRECTIONS } from "#domain/constants";
import { UPDATE_DIRECTIONS } from "../constants";
// schemas
export const UpdateDirectionSchema = z.enum(UPDATE_DIRECTIONS);
+1
View File
@@ -1,5 +1,6 @@
export * from "./articles";
export * from "./auth";
export * from "./crawler";
export * from "./reports";
export * from "./shared";
export * from "./sources";
+11 -24
View File
@@ -1,30 +1,17 @@
import { z } from "@hono/zod-openapi";
import z from "zod";
import { deltaSchema } from "#domain/models/shared";
import { deltaSchema } from "./shared";
export const overviewMetricSchema = z
.object({
delta: deltaSchema.openapi({
description: "Change measured over the last 30 days compared to the previous 30-day window.",
}),
total: z.number().int().nonnegative().openapi({
description: "Total count across the entire dataset.",
example: 12584,
}),
})
.openapi({
description: "Aggregated metric with total count and delta metadata.",
});
export const overviewMetricSchema = z.object({
delta: deltaSchema,
total: z.number().int().nonnegative(),
});
export const dashboardOverviewSchema = z
.object({
articles: overviewMetricSchema,
sources: overviewMetricSchema,
users: overviewMetricSchema,
})
.openapi({
description: "Dashboard overview metrics for key entities.",
});
export const dashboardOverviewSchema = z.object({
articles: overviewMetricSchema,
sources: overviewMetricSchema,
users: overviewMetricSchema,
});
export type OverviewMetric = z.infer<typeof overviewMetricSchema>;
export type DashboardOverview = z.infer<typeof dashboardOverviewSchema>;
+78 -278
View File
@@ -1,138 +1,50 @@
import { z } from "@hono/zod-openapi";
import { BIAS, RELIABILITY, SENTIMENT, TRANSPARENCY } from "#domain/constants";
import { BIAS, RELIABILITY, SENTIMENT, TRANSPARENCY } from "../constants";
// schemas
export const idSchema = z.uuid().openapi({
description: "The unique identifier of the resource.",
example: "b3e1c8f4-5d6a-4c9e-8f1e-2d3c4b5a6f7g",
export const idSchema = z.uuid();
export const dateRangeSchema = z.object({
end: z.coerce.date(),
start: z.coerce.date(),
});
export const dateRangeSchema = z
.object({
end: z.date().openapi({
description: "The end date of the range.",
example: "2023-01-30T23:59:59Z",
}),
start: z.date().openapi({
description: "The start date of the range.",
example: "2023-01-01T00:00:00Z",
}),
})
.openapi({
description: "Inclusive date range for publication metrics.",
});
export const limitSchema = z.number().int().min(1).max(100);
export const sentimentSchema = z.enum(SENTIMENT);
export const biasSchema = z.enum(BIAS);
export const reliabilitySchema = z.enum(RELIABILITY);
export const transparencySchema = z.enum(TRANSPARENCY);
export const limitSchema = z.number().int().min(1).max(100).openapi({
default: 10,
description: "The maximum number of items to return.",
example: 10,
export const credibilitySchema = z.object({
bias: biasSchema.default("neutral"),
reliability: reliabilitySchema.default("average"),
transparency: transparencySchema.default("medium"),
});
export const sentimentSchema = z.enum(SENTIMENT).openapi({
description: "Sentiment detected for the article.",
example: "positive",
export const deviceSchema = z.object({
client: z.string().optional(),
device: z.string().optional(),
isBot: z.boolean(),
operatingSystem: z.string().optional(),
});
export const biasSchema = z.enum(BIAS).openapi({
description: "The bias level of the source.",
example: "neutral",
export const geoLocationSchema = z.object({
accuracyRadius: z.number().optional(),
city: z.string().optional(),
country: z.string().optional(),
latitude: z.number().optional(),
longitude: z.number().optional(),
timeZone: z.string().optional(),
});
export const reliabilitySchema = z.enum(RELIABILITY).openapi({
description: "The reliability level of the source.",
example: "trusted",
export const distrubtionSchema = z.object({
count: z.number().int(),
id: idSchema,
name: z.string(),
percentage: z.number(),
});
export const transparencySchema = z.enum(TRANSPARENCY).openapi({
description: "The transparency level of the source.",
example: "high",
});
export const credibilitySchema = z
.object({
bias: biasSchema.default("neutral"),
reliability: reliabilitySchema.default("average"),
transparency: transparencySchema.default("medium"),
})
.openapi({
description: "Credibility information about the resource.",
});
export const deviceSchema = z
.object({
client: z.string().optional().openapi({
description: "The client software of the device.",
example: "Chrome 90",
}),
device: z.string().optional().openapi({
description: "The device model.",
example: "Dell XPS 13",
}),
isBot: z.boolean().openapi({
description: "Indicates if the device is a bot.",
example: false,
}),
operatingSystem: z.string().optional().openapi({
description: "The operating system of the device.",
example: "Windows 10",
}),
})
.openapi({
description: "Information about the user's device.",
});
export const geoLocationSchema = z
.object({
accuracyRadius: z.number().optional().openapi({
description: "The accuracy radius in kilometers.",
example: 50,
}),
city: z.string().optional().openapi({
description: "The city of the user.",
example: "San Francisco",
}),
country: z.string().optional().openapi({
description: "The country of the user.",
example: "United States",
}),
latitude: z.number().optional().openapi({
description: "The latitude of the user's location.",
example: 37.7749,
}),
longitude: z.number().optional().openapi({
description: "The longitude of the user's location.",
example: -122.4194,
}),
timeZone: z.string().optional().openapi({
description: "The time zone of the user.",
example: "America/Los_Angeles",
}),
})
.openapi({
description: "Geolocation information about the user.",
});
export const distrubtionSchema = z
.object({
count: z.number().int().openapi({
description: "The count of items in the distribution.",
example: 42,
}),
id: idSchema,
name: z.string().openapi({
description: "The name of the distribution.",
example: "Technology",
}),
percentage: z.number().openapi({
description: "The percentage of items in the distribution.",
example: 12.5,
}),
})
.openapi({
description: "Distribution information.",
});
export const getDistributionsSchema = z.object({
id: idSchema.optional(),
limit: limitSchema.optional(),
@@ -143,172 +55,60 @@ export const getPublicationsSchema = z.object({
range: dateRangeSchema.optional(),
});
export const distributionsSchema = z
.object({
items: z.array(distrubtionSchema).openapi({
description: "List of distributions.",
}),
total: z.number().int().openapi({
description: "Total number of distributions.",
example: 100,
}),
})
.openapi({
description: "Distributions data.",
});
export const distributionsSchema = z.object({
items: z.array(distrubtionSchema),
total: z.number().int(),
});
export const publicationSchema = z
.object({
count: z.number().int().openapi({
description: "The number of articles published on that date.",
example: 42,
}),
date: z.string().openapi({
description: "The date of the publication.",
example: "2023-01-15",
}),
})
.openapi({
description: "Publication metrics for a specific date.",
});
export const publicationSchema = z.object({
count: z.number().int(),
date: z.string(),
});
export const deltaSchema = z
.object({
delta: z.number().openapi({
description: "The absolute change in value.",
example: 10,
}),
percentage: z.number().openapi({
description: "The percentage change in value.",
example: 25.0,
}),
sign: z.enum(["+", "-"]).openapi({
description: "The sign of the change.",
example: "+",
}),
variant: z.enum(["increase", "decrease", "positive"]).openapi({
description: "The variant of the change.",
example: "increase",
}),
})
.openapi({
description: "Delta information representing change over time.",
});
export const deltaSchema = z.object({
delta: z.number(),
percentage: z.number(),
sign: z.enum(["+", "-"]),
variant: z.enum(["increase", "decrease", "positive"]),
});
export const publicationMetaSchema = z
.object({
current: z.number().openapi({
description: "The current total value.",
example: 150,
}),
delta: deltaSchema,
previous: z.number().openapi({
description: "The previous total value.",
example: 120,
}),
})
.openapi({
description: "Metadata for publication metrics.",
});
export const publicationMetaSchema = z.object({
current: z.number(),
delta: deltaSchema,
previous: z.number(),
});
export const publicationsSchema = z
.object({
items: z.array(publicationSchema).openapi({
description: "List of publication metrics for the source.",
}),
meta: publicationMetaSchema.optional(),
})
.openapi({
description: "Publication metrics for the source.",
});
export const publicationsSchema = z.object({
items: z.array(publicationSchema),
meta: publicationMetaSchema.optional(),
});
export const paginationCursorSchema = z
.object({
date: z.string().openapi({
description: "The date associated with the last item in the current page.",
example: "2023-01-15",
}),
id: z.string().openapi({
description: "The unique identifier of the last item in the current page.",
example: "b3e1c8f4-5d6a-4c9e-8f1e-2d3c4b5a6f7g",
}),
})
.openapi({
description: "Cursor information for pagination.",
});
export const paginationCursorSchema = z.object({
date: z.string(),
id: z.string(),
});
export const paginationRequestSchema = z
.object({
cursor: z.string().nullable().optional().openapi({
description: "The pagination cursor for cursor-based pagination.",
example:
"eyJkYXRlIjoiMjAyMy0wMS0xNSIsImlkIjoiYjNlMWM4ZjQtNWQ2YS00YzllLThmMWUtMmQzYzRiNWE2ZjdifQ==",
}),
limit: limitSchema.optional(),
page: z.number().int().min(1).optional().openapi({
description: "The page number to retrieve.",
example: 1,
}),
})
.openapi({
description: "Pagination request parameters.",
});
export const paginationRequestSchema = z.object({
cursor: z.string().nullable().optional(),
limit: limitSchema.optional(),
page: z.number().nonnegative().default(1).optional(),
});
export const paginationStateSchema = z
.object({
cursor: z.string().nullable().openapi({
description: "The current pagination cursor.",
example:
"eyJkYXRlIjoiMjAyMy0wMS0xNSIsImlkIjoiYjNlMWM4ZjQtNWQ2YS00YzllLThmMWUtMmQzYzRiNWE2ZjdifQ==",
}),
limit: z.number().int().openapi({
description: "The number of items per page.",
example: 10,
}),
offset: z.number().int().openapi({
description: "The offset for the current page.",
example: 0,
}),
page: z.number().int().openapi({
description: "The current page number.",
example: 1,
}),
payload: paginationCursorSchema.nullable().openapi({
description: "The decoded payload from the pagination cursor.",
}),
})
.openapi({
description: "Internal pagination state.",
});
export const paginationStateSchema = z.object({
cursor: z.string().nullable(),
limit: z.number().int(),
offset: z.number().int(),
page: z.number().int(),
payload: paginationCursorSchema.nullable(),
});
export const paginationMetaSchema = z
.object({
current: z.number().int().openapi({
description: "The current page number or offset.",
example: 1,
}),
cursor: z.string().nullable().openapi({
description: "The current pagination cursor.",
example:
"eyJkYXRlIjoiMjAyMy0wMS0xNSIsImlkIjoiYjNlMWM4ZjQtNWQ2YS00YzllLThmMWUtMmQzYzRiNWE2ZjdifQ==",
}),
hasNext: z.boolean().openapi({
description: "Indicates if there is a next page available.",
example: true,
}),
limit: z.number().int().openapi({
description: "The number of items per page.",
example: 10,
}),
nextCursor: z.string().nullable().openapi({
description: "The next pagination cursor, if available.",
example:
"eyJkYXRlIjoiMjAyMy0wMS0yMCIsImlkIjoiZDRmNWU2ZTAtNzY4Ny00Y2E3LTg5ZTItYjY0ZGI3Y2E3ZGIifQ==",
}),
})
.openapi({
description: "Pagination metadata.",
});
export const paginationMetaSchema = z.object({
current: z.number().int(),
cursor: z.string().nullable(),
hasNext: z.boolean(),
limit: z.number().int(),
nextCursor: z.string().nullable(),
});
// types
export type PaginatedResult<T> = {
+7 -27
View File
@@ -1,37 +1,17 @@
import { z } from "@hono/zod-openapi";
import z from "zod";
import {
credibilitySchema,
idSchema,
limitSchema,
publicationsSchema,
} from "#domain/models/shared";
import { credibilitySchema, idSchema, limitSchema, publicationsSchema } from "./shared";
// schemas
export const sourceSchema = z.object({
articles: z.number().int().min(0).optional().openapi({
description: "The total number of articles from this source.",
example: 1250,
}),
articles: z.number().int().min(0).optional(),
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",
}),
description: z.string().max(1024).optional(),
displayName: z.string().min(1).max(255).optional(),
id: idSchema,
name: z.string().min(1).max(255).openapi({
description: "The name of the source.",
example: "radiookapi.com",
}),
name: z.string().min(1).max(255),
publications: publicationsSchema.optional(),
url: z.url().max(255).openapi({
description: "The URL of the source.",
example: "https://techcrunch.com",
}),
url: z.url().max(255),
});
export const createSourceSchema = sourceSchema.pick({