Compare commits

...

6 Commits

Author SHA1 Message Date
63862b19c0
feat(api): implement GET /wikipedia/pages/[id]
Some checks failed
Chromatic / chromatic (push) Successful in 2m33s
CI / ci (push) Failing after 1m27s
CI / commitlint (push) Successful in 13s
2024-08-13 11:58:38 +01:00
376c0fd041
feat(api): health checks 2024-08-12 18:19:43 +01:00
207d3483ca
feat(api): rate limiting 2024-08-12 14:13:24 +01:00
0ce950c9c8
test(api): database tests 2024-08-12 13:21:34 +01:00
cdc8cf2b05
feat(api): implement GET /wikipedia/pages?title=search_title 2024-08-12 00:32:43 +01:00
02ee112de4
feat(api): create Lucid models and migrations for Wikipedia database dump + usage of PostgreSQL instead of MariaDB 2024-08-11 09:49:23 +01:00
64 changed files with 1419 additions and 618 deletions

View File

@ -5,4 +5,4 @@ DATABASE_USER=wikipedia_user
DATABASE_PASSWORD=password
DATABASE_NAME=wikipedia
DATABASE_HOST=127.0.0.1
DATABASE_PORT=3306
DATABASE_PORT=5432

10
.gitignore vendored
View File

@ -21,11 +21,13 @@ build/
*.pem
.turbo
tmp/
cache.json
# data
data/dump
data/sql/*
!data/sql/0000-tables-create.sql
!data/sql/0999-constraints.sql
data/sql-pages-inserts/*
!data/sql-pages-inserts/0000-pages.sh
data/sql-internal-links-inserts/*
!data/sql-internal-links-inserts/0000-internal-links.sh
# debug
npm-debug.log*

13
TODO.md
View File

@ -30,17 +30,22 @@
- [ ] Handle redirects
- [ ] Implement REST API (`api`) with JSON responses ([AdonisJS](https://adonisjs.com/)) to get shortest paths between 2 pages
- [x] Init AdonisJS project
- [ ] Create Lucid models and migrations for Wikipedia Database Dump: `pages` and `internal_links` tables
- [ ] Implement `GET /wikipedia/pages?title=Node.js` to search a page by title (not necessarily with the title sanitized, search with input by user to check if page exists)
- [ ] Implement `GET /wikipedia/pages/internal-links/paths?from=Node.js&to=Linux` to get all the possible paths between 2 pages with titles sanitized
- [x] Create Lucid models and migrations for Wikipedia Database Dump: `pages` and `internal_links` tables
- [x] Implement `GET /wikipedia/pages?title=Node.js` to search a page by title (not necessarily with the title sanitized, search with input by user to check if page exists)
- [x] Implement `GET /wikipedia/pages/[id]` to get a page and all its internal links with the pageId
- [ ] Implement `GET /wikipedia/internal-links/paths?fromPageId=id&toPageId=id` to get all the possible paths between 2 pages
- [x] Setup tests with database + add coverage
- [x] Setup Health checks
- [x] Setup Rate limiting
- [ ] Share VineJS validators between `website` and `api`
- [ ] Implement Wikipedia Game Solver (`website`)
- [x] Init Next.js project
- [ ] Try to use <https://www.npmjs.com/package/@tuyau/client> for API calls
- [ ] Hard code 2 pages to test if it works with `console.log` in the browser
- [ ] Implement a form with inputs, button to submit, and list all pages to go from one to another, or none if it is not possible
- [ ] Add images, links to the pages + good UI/UX
- [ ] Implement toast notifications for errors, warnings, and success messages
- [ ] Autocompletion page titles
- [ ] Implement toast notifications for errors, warnings, and success messages
- [ ] Implement CLI (`cli`)
- [ ] Init Clipanion project
- [ ] Implement `wikipedia-game-solver internal-links --from="Node.js" --to="Linux"` command to get all the possible paths between 2 pages.

10
apps/api/.c8rc.json Normal file
View File

@ -0,0 +1,10 @@
{
"reporter": ["text", "html", "json"],
"exclude": [
"src/adonisrc.ts",
"src/tests/**",
"src/database/**",
"src/config/**",
"src/bin/**"
]
}

View File

@ -9,4 +9,6 @@ DATABASE_USER=wikipedia_user
DATABASE_PASSWORD=password
DATABASE_NAME=wikipedia
DATABASE_HOST=127.0.0.1
DATABASE_PORT=3306
DATABASE_PORT=5432
LIMITER_STORE=database

View File

@ -10,7 +10,7 @@
"start": "node --import=tsx ./src/bin/server.ts",
"dev": "node --import=tsx --watch --watch-preserve-output ./src/bin/server.ts",
"ace": "node --import=tsx ./src/bin/console.ts",
"test": "node --import=tsx ./src/bin/test.ts",
"test": "c8 node --import=tsx ./src/bin/test.ts",
"lint:eslint": "eslint src --max-warnings 0 --report-unused-disable-directives",
"lint:typescript": "tsc --noEmit"
},
@ -19,11 +19,12 @@
"@adonisjs/core": "catalog:",
"@adonisjs/cors": "catalog:",
"@adonisjs/lucid": "catalog:",
"@adonisjs/limiter": "catalog:",
"@repo/utils": "workspace:*",
"@repo/wikipedia-game-solver": "workspace:*",
"@vinejs/vine": "catalog:",
"luxon": "catalog:",
"mysql2": "catalog:",
"pg": "catalog:",
"reflect-metadata": "catalog:",
"tsx": "catalog:",
"pino-pretty": "catalog:"
@ -39,6 +40,8 @@
"@total-typescript/ts-reset": "catalog:",
"@types/luxon": "catalog:",
"@types/node": "catalog:",
"better-sqlite3": "catalog:",
"c8": "catalog:",
"eslint": "catalog:",
"openapi-types": "catalog:",
"typescript": "catalog:"

View File

@ -34,6 +34,9 @@ export default defineConfig({
async () => {
return await import("@adonisjs/auth/auth_provider")
},
async () => {
return await import("@adonisjs/limiter/limiter_provider")
},
],
preloads: [
async () => {

View File

@ -13,9 +13,9 @@ export default class HttpExceptionHandler extends ExceptionHandler {
*/
public override async handle(
error: unknown,
ctx: HttpContext,
context: HttpContext,
): Promise<unknown> {
return await super.handle(error, ctx)
return await super.handle(error, context)
}
/**
@ -25,8 +25,8 @@ export default class HttpExceptionHandler extends ExceptionHandler {
*/
public override async report(
error: unknown,
ctx: HttpContext,
context: HttpContext,
): Promise<void> {
return await super.report(error, ctx)
return await super.report(error, context)
}
}

View File

@ -0,0 +1,12 @@
import { APP_KEY, APP_KEY_HEADER_NAME } from "#config/app.js"
import type { HttpContext } from "@adonisjs/core/http"
import type { NextFn } from "@adonisjs/core/types/http"
export default class AppKeySecurityMiddleware {
public async handle(context: HttpContext, next: NextFn): Promise<void> {
if (context.request.header(APP_KEY_HEADER_NAME) === APP_KEY) {
return next()
}
return context.response.unauthorized({ message: "Unauthorized access" })
}
}

View File

@ -3,23 +3,22 @@ import type { HttpContext } from "@adonisjs/core/http"
import type { NextFn } from "@adonisjs/core/types/http"
/**
* Auth middleware is used authenticate HTTP requests and deny
* access to unauthenticated users.
* Auth middleware is used authenticate HTTP requests and deny access to unauthenticated users.
*/
export default class AuthMiddleware {
/**
* The URL to redirect to, when authentication fails
* The URL to redirect to, when authentication fails.
*/
redirectTo = "/login"
public async handle(
ctx: HttpContext,
context: HttpContext,
next: NextFn,
options: {
guards?: Array<keyof Authenticators>
} = {},
): Promise<void> {
await ctx.auth.authenticateUsing(options.guards, {
await context.auth.authenticateUsing(options.guards, {
loginRoute: this.redirectTo,
})
return next()

View File

@ -5,13 +5,13 @@ import type { NextFn } from "@adonisjs/core/types/http"
/**
* The container bindings middleware binds classes to their request specific value using the container resolver.
*
* - We bind "HttpContext" class to the "ctx" object.
* - And bind "Logger" class to the "ctx.logger" object.
* - We bind "HttpContext" class to the "context" object.
* - And bind "Logger" class to the "context.logger" object.
*/
export default class ContainerBindingsMiddleware {
public async handle(ctx: HttpContext, next: NextFn): Promise<void> {
ctx.containerResolver.bindValue(HttpContext, ctx)
ctx.containerResolver.bindValue(Logger, ctx.logger)
public async handle(context: HttpContext, next: NextFn): Promise<void> {
context.containerResolver.bindValue(HttpContext, context)
context.containerResolver.bindValue(Logger, context.logger)
return next()
}

View File

@ -0,0 +1,30 @@
import { BaseModel, column, manyToMany } from "@adonisjs/lucid/orm"
import type { ManyToMany } from "@adonisjs/lucid/types/relations"
export default class Page extends BaseModel {
protected tableName = "pages"
@column({ columnName: "id", serializeAs: "id", isPrimary: true })
declare id: number
@column({
columnName: "title",
serializeAs: "title",
})
declare title: string
@manyToMany(
() => {
return Page
},
{
pivotTable: "internal_links",
localKey: "id",
relatedKey: "id",
pivotForeignKey: "from_page_id",
pivotRelatedForeignKey: "to_page_id",
serializeAs: "internalLinks",
},
)
declare internalLinks: ManyToMany<typeof Page>
}

View File

@ -25,7 +25,7 @@ export default class User extends compose(BaseModel, AuthFinder) {
columnName: "full_name",
serializeAs: "fullName",
})
declare fullName: string | null
declare fullName: string
@column({
columnName: "email",
@ -49,7 +49,7 @@ export default class User extends compose(BaseModel, AuthFinder) {
autoCreate: true,
autoUpdate: true,
})
declare updatedAt: DateTime | null
declare updatedAt: DateTime
static accessTokens = DbAccessTokensProvider.forModel(User)
}

View File

@ -1,7 +1,7 @@
import { test } from "@japa/runner"
test.group("GET /", () => {
test("should get hello world", async ({ client }) => {
test("should succeeds and get hello world", async ({ client }) => {
// Arrange - Given
// Act - When

View File

@ -0,0 +1,33 @@
import { APP_KEY, APP_KEY_HEADER_NAME } from "#config/app.js"
import { test } from "@japa/runner"
test.group("GET /health", () => {
test("should succeeds and get `isHealthy: true`", async ({
client,
assert,
}) => {
// Arrange - Given
// Act - When
const response = await client
.get("/health")
.header(APP_KEY_HEADER_NAME, APP_KEY)
const responseBody = response.body()
// Assert - Then
response.assertStatus(200)
assert.equal(responseBody.isHealthy, true)
})
test("should fails and unauthorized when the app key is not provided", async ({
client,
}) => {
// Arrange - Given
// Act - When
const response = await client.get("/health")
// Assert - Then
response.assertStatus(401)
})
})

View File

@ -0,0 +1,16 @@
import { healthChecks } from "#start/health.js"
import { middleware } from "#start/kernel.js"
import type { HttpContext } from "@adonisjs/core/http"
import router from "@adonisjs/core/services/router"
class Controller {
public async handle(context: HttpContext): Promise<void> {
const report = await healthChecks.run()
if (report.isHealthy) {
return context.response.ok(report)
}
return context.response.serviceUnavailable(report)
}
}
router.get("/health", [Controller]).use(middleware.appKeySecurity())

View File

@ -1 +1,4 @@
import "./get.js"
import "#app/routes/get.js"
import "#app/routes/health/get.js"
import "#app/routes/wikipedia/pages/[id]/get.js"
import "#app/routes/wikipedia/pages/get.js"

View File

@ -0,0 +1,52 @@
import { PageFactory } from "#database/factories/page_factory.js"
import testUtils from "@adonisjs/core/services/test_utils"
import db from "@adonisjs/lucid/services/db"
import { test } from "@japa/runner"
test.group("GET /wikipedia/pages/[id]", (group) => {
group.each.setup(async () => {
return await testUtils.db().truncate()
})
test("should succeeds and get the page with the given id, and get all its internal links", async ({
client,
}) => {
// Arrange - Given
const page = await PageFactory.create()
const pages = await PageFactory.createMany(10)
const internalLinksPages = pages.slice(0, 5)
await Promise.all(
internalLinksPages.map(async (internalLinkPage) => {
await db.table("internal_links").insert({
from_page_id: page.id,
to_page_id: internalLinkPage.id,
})
}),
)
// Act - When
const response = await client.get(`/wikipedia/pages/${page.id}`)
// Assert - Then
response.assertStatus(200)
response.assertBody({
...page.toJSON(),
internalLinks: internalLinksPages.map((page) => {
return page.toJSON()
}),
})
})
test("should fails with a 404 status code when the page with the given id does not exist", async ({
client,
}) => {
// Arrange - Given
const page = await PageFactory.create()
// Act - When
const response = await client.get(`/wikipedia/pages/${page.id + 1}`)
// Assert - Then
response.assertStatus(404)
})
})

View File

@ -0,0 +1,24 @@
import Page from "#app/models/page.js"
import { throttle } from "#start/limiter.js"
import type { HttpContext } from "@adonisjs/core/http"
import router from "@adonisjs/core/services/router"
import vine from "@vinejs/vine"
const requestValidator = vine.compile(
vine.object({
params: vine.object({
id: vine.number().withoutDecimals().positive(),
}),
}),
)
class Controller {
public async handle(context: HttpContext): Promise<Page> {
const payload = await context.request.validateUsing(requestValidator)
const page = await Page.findOrFail(payload.params.id)
await page.load("internalLinks")
return page
}
}
router.get("/wikipedia/pages/:id", [Controller]).use(throttle)

View File

@ -0,0 +1,93 @@
import Page from "#app/models/page.js"
import { PageFactory } from "#database/factories/page_factory.js"
import testUtils from "@adonisjs/core/services/test_utils"
import { test } from "@japa/runner"
test.group("GET /wikipedia/pages", (group) => {
group.each.setup(async () => {
return await testUtils.db().truncate()
})
test("should succeeds and get the page with the given title", async ({
client,
}) => {
// Arrange - Given
const page = await PageFactory.create()
await PageFactory.createMany(10)
// Act - When
const searchParams = new URLSearchParams({ title: page.title })
const response = await client.get(
`/wikipedia/pages?${searchParams.toString()}`,
)
// Assert - Then
response.assertStatus(200)
response.assertBody([page.toJSON()])
})
test("should succeeds and get the pages with title that starts with the title given and limit", async ({
client,
assert,
}) => {
// Arrange - Given
const limit = 4
const title = "No"
const pagesMatching = await Page.createMany([
{ title: "Node.js" },
{ title: "North_America" },
{ title: "NoSQL" },
{ title: "No" },
{ title: "Nobel_Prize" },
{ title: "Norway" },
])
await Page.createMany([{ title: "Linux" }, { title: "Abc" }])
// Act - When
const searchParams = new URLSearchParams({
title,
limit: limit.toString(),
})
const response = await client.get(
`/wikipedia/pages?${searchParams.toString()}`,
)
const responseBody = response.body()
// Assert - Then
response.assertStatus(200)
response.assertBody(
pagesMatching.slice(0, limit).map((page) => {
return page.toJSON()
}),
)
assert.equal(responseBody.length, limit)
})
test('should fails when "title" is not provided', async ({ client }) => {
// Act - When
const response = await client.get("/wikipedia/pages")
// Assert - Then
response.assertStatus(422)
})
test('should fails when "limit" is too high (more than 100)', async ({
client,
}) => {
// Arrange - Given
const title = "No"
const limit = 101
// Act - When
const searchParams = new URLSearchParams({
title,
limit: limit.toString(),
})
const response = await client.get(
`/wikipedia/pages?${searchParams.toString()}`,
)
// Assert - Then
response.assertStatus(422)
})
})

View File

@ -0,0 +1,37 @@
import Page from "#app/models/page.js"
import { throttle } from "#start/limiter.js"
import type { HttpContext } from "@adonisjs/core/http"
import router from "@adonisjs/core/services/router"
import { sanitizePageTitle } from "@repo/wikipedia-game-solver/wikipedia-utils"
import vine from "@vinejs/vine"
const requestValidator = vine.compile(
vine.object({
title: vine
.string()
.minLength(1)
.maxLength(255)
.transform((value) => {
return sanitizePageTitle(value)
}),
limit: vine
.number()
.parse((value) => {
return value ?? 5
})
.withoutDecimals()
.range([1, 100]),
}),
)
class Controller {
public async handle(context: HttpContext): Promise<Page[]> {
const payload = await context.request.validateUsing(requestValidator)
const pages = await Page.query()
.whereLike("title", `${payload.title}%`)
.limit(payload.limit)
return pages
}
}
router.get("/wikipedia/pages", [Controller]).use(throttle)

View File

@ -7,6 +7,8 @@
*/
process.env["NODE_ENV"] = "test"
process.env["PORT"] = "3333"
process.env["LIMITER_STORE"] = "memory"
import { Ignitor, prettyPrintError } from "@adonisjs/core"
import { configure, processCLIArgs, run } from "@japa/runner"

View File

@ -9,7 +9,9 @@ import app from "@adonisjs/core/services/app"
* The encryption module will fail to decrypt data if the key is lost or changed.
* Therefore it is recommended to keep the app key secure.
*/
export const appKey = new Secret(env.get("APP_KEY"))
export const APP_KEY_HEADER_NAME = "x-app-key"
export const APP_KEY = env.get("APP_KEY")
export const appKey = new Secret(APP_KEY)
/**
* The configuration settings used by the HTTP server

View File

@ -15,7 +15,7 @@ const bodyParserConfig = defineConfig({
},
/**
* Config for the JSON parser
* Config for the JSON parser.
*/
json: {
convertEmptyStringsToNull: true,

View File

@ -1,11 +1,14 @@
import env from "#start/env.js"
import app from "@adonisjs/core/services/app"
import { defineConfig } from "@adonisjs/lucid"
const databaseConfig = defineConfig({
connection: "mysql",
prettyPrintDebugQueries: !app.inProduction,
connection: app.inTest ? "sqlite" : "postgres",
connections: {
mysql: {
client: "mysql2",
postgres: {
debug: app.inDev,
client: "pg",
connection: {
host: env.get("DATABASE_HOST"),
port: env.get("DATABASE_PORT"),
@ -18,6 +21,17 @@ const databaseConfig = defineConfig({
paths: ["database/migrations"],
},
},
sqlite: {
client: "better-sqlite3",
connection: {
filename: ":memory:",
},
useNullAsDefault: true,
migrations: {
naturalSort: true,
paths: ["database/migrations"],
},
},
},
})

View File

@ -16,8 +16,7 @@ const hashConfig = defineConfig({
export default hashConfig
/**
* Inferring types for the list of hashers you have configured
* in your application.
* Inferring types for the list of hashers you have configured in your application.
*/
declare module "@adonisjs/core/types" {
export interface HashersList extends InferHashers<typeof hashConfig> {}

View File

@ -0,0 +1,26 @@
import env from "#start/env.js"
import { defineConfig, stores } from "@adonisjs/limiter"
const limiterConfig = defineConfig({
default: env.get("LIMITER_STORE"),
stores: {
/**
* Database store to save rate limiting data inside a database.
*/
database: stores.database({
tableName: "rate_limits",
clearExpiredByTimeout: true,
}),
/**
* Memory store could be used during testing.
*/
memory: stores.memory({}),
},
})
export default limiterConfig
declare module "@adonisjs/limiter/types" {
export interface LimitersList extends InferLimiters<typeof limiterConfig> {}
}

View File

@ -0,0 +1,14 @@
import Page from "#app/models/page.js"
import factory from "@adonisjs/lucid/factories"
import { sanitizePageTitle } from "@repo/wikipedia-game-solver/wikipedia-utils"
export const PageFactory = factory
.define(Page, async ({ faker }) => {
return {
title: sanitizePageTitle(faker.commerce.productName()),
}
})
.relation("internalLinks", () => {
return []
})
.build()

View File

@ -6,12 +6,12 @@ export default class CreateUsersTable extends BaseSchema {
public override async up(): Promise<void> {
void this.schema.createTable(this.tableName, (table) => {
table.increments("id").notNullable()
table.string("full_name").nullable()
table.string("full_name").notNullable()
table.string("email", 254).notNullable().unique()
table.string("password").notNullable()
table.timestamp("created_at").notNullable()
table.timestamp("updated_at").nullable()
table.timestamp("updated_at").notNullable()
})
}

View File

@ -10,9 +10,9 @@ export default class CreateAccessTokensTable extends BaseSchema {
.integer("tokenable_id")
.notNullable()
.unsigned()
.references("id")
.inTable("users")
.references("users.id")
.onDelete("CASCADE")
.onUpdate("CASCADE")
table.string("type").notNullable()
table.string("name").nullable()

View File

@ -0,0 +1,16 @@
import { BaseSchema } from "@adonisjs/lucid/schema"
export default class CreatePagesTable extends BaseSchema {
protected tableName = "pages"
public override async up(): Promise<void> {
void this.schema.createTable(this.tableName, (table) => {
table.increments("id").notNullable()
table.string("title", 255).notNullable().unique()
})
}
public override async down(): Promise<void> {
void this.schema.dropTable(this.tableName)
}
}

View File

@ -0,0 +1,29 @@
import { BaseSchema } from "@adonisjs/lucid/schema"
export default class CreateInternalLinksTable extends BaseSchema {
protected tableName = "internal_links"
public override async up(): Promise<void> {
void this.schema.createTable(this.tableName, (table) => {
table.primary(["from_page_id", "to_page_id"])
table
.integer("from_page_id")
.unsigned()
.notNullable()
.references("pages.id")
.onDelete("CASCADE")
.onUpdate("CASCADE")
table
.integer("to_page_id")
.unsigned()
.notNullable()
.references("pages.id")
.onDelete("CASCADE")
.onUpdate("CASCADE")
})
}
public override async down(): Promise<void> {
void this.schema.dropTable(this.tableName)
}
}

View File

@ -0,0 +1,17 @@
import { BaseSchema } from "@adonisjs/lucid/schema"
export default class CreateRateLimitsTable extends BaseSchema {
protected tableName = "rate_limits"
public override async up(): Promise<void> {
void this.schema.createTable(this.tableName, (table) => {
table.string("key", 255).notNullable().primary()
table.integer("points", 9).notNullable().defaultTo(0)
table.bigint("expire").unsigned()
})
}
public override async down(): Promise<void> {
void this.schema.dropTable(this.tableName)
}
}

View File

@ -19,11 +19,16 @@ export default await Env.create(new URL("../..", import.meta.url), {
] as const),
/**
* Variables for configuring database connection
* Variables for configuring database connection.
*/
DATABASE_HOST: Env.schema.string({ format: "host" }),
DATABASE_PORT: Env.schema.number(),
DATABASE_USER: Env.schema.string(),
DATABASE_PASSWORD: Env.schema.string(),
DATABASE_NAME: Env.schema.string(),
/**
* Variables for configuring the limiter package.
*/
LIMITER_STORE: Env.schema.enum(["database", "memory"] as const),
})

View File

@ -0,0 +1,13 @@
import {
DiskSpaceCheck,
HealthChecks,
MemoryHeapCheck,
} from "@adonisjs/core/health"
import { DbCheck } from "@adonisjs/lucid/database"
import db from "@adonisjs/lucid/services/db"
export const healthChecks = new HealthChecks().register([
new DiskSpaceCheck(),
new MemoryHeapCheck(),
new DbCheck(db.connection()),
])

View File

@ -8,17 +8,14 @@ import router from "@adonisjs/core/services/router"
import server from "@adonisjs/core/services/server"
/**
* The error handler is used to convert an exception
* to a HTTP response.
* The error handler is used to convert an exception to a HTTP response.
*/
server.errorHandler(async () => {
return await import("#app/exceptions/handler.js")
})
/**
* The server middleware stack runs middleware on all the HTTP
* requests, even if there is no route registered for
* the request URL.
* The server middleware stack runs middleware on all the HTTP requests, even if there is no route registered for the requested URL.
*/
server.use([
async () => {
@ -48,6 +45,9 @@ router.use([
* Named middleware collection must be explicitly assigned to the routes or the routes group.
*/
export const middleware = router.named({
appKeySecurity: async () => {
return await import("#app/middleware/app_key_security_middleware.js")
},
auth: async () => {
return await import("#app/middleware/auth_middleware.js")
},

View File

@ -0,0 +1,16 @@
/**
* Define HTTP limiters
*
* The "limiter.define" method creates an HTTP middleware to apply rate limits on a route or a group of routes. Feel free to define as many throttle middleware as needed.
*/
import { APP_KEY, APP_KEY_HEADER_NAME } from "#config/app.js"
import app from "@adonisjs/core/services/app"
import limiter from "@adonisjs/limiter/services/main"
export const throttle = limiter.define("global", (context) => {
if (app.inTest || context.request.header(APP_KEY_HEADER_NAME) === APP_KEY) {
return limiter.noLimit()
}
return limiter.allowRequests(120).every("1 minute")
})

View File

@ -26,7 +26,11 @@ export const plugins: Config["plugins"] = [
* The teardown functions are executer after all the tests.
*/
export const runnerHooks: Required<Pick<Config, "setup" | "teardown">> = {
setup: [],
setup: [
async () => {
return await testUtils.db().truncate()
},
],
teardown: [],
}

View File

@ -1,27 +1,20 @@
services:
wikipedia-solver-dev-database:
container_name: "wikipedia-solver-dev-database"
image: "mariadb:10.6.17"
image: "postgres:16.3"
restart: "unless-stopped"
env_file: ".env"
environment:
MARIADB_USER: ${DATABASE_USER}
MARIADB_PASSWORD: ${DATABASE_PASSWORD}
MARIADB_ROOT_PASSWORD: ${DATABASE_PASSWORD}
MARIADB_DATABASE: ${DATABASE_NAME}
POSTGRES_USER: ${DATABASE_USER}
POSTGRES_PASSWORD: ${DATABASE_PASSWORD}
POSTGRES_DB: ${DATABASE_NAME}
command: |
--innodb_buffer_pool_size=4G
--key-buffer-size=4G
--innodb_log_buffer_size=256M
--innodb_log_file_size=1G
--innodb_write_io_threads=16
--innodb_flush_log_at_trx_commit=0
--max_allowed_packet=1G
--max_wal_size=4GB
ports:
- "${DATABASE_PORT-3306}:${DATABASE_PORT-3306}"
- "${DATABASE_PORT-5432}:${DATABASE_PORT-5432}"
volumes:
- "wikipedia-solver-dev-mariadb-data:/var/lib/mysql"
# - "./sql:/docker-entrypoint-initdb.d/"
- "wikipedia-solver-dev-postgres-data:/var/lib/postgresql/data"
- "./data:/data/"
wikipedia-solver-dev-adminer:
container_name: "wikipedia-solver-dev-adminer"
@ -38,4 +31,4 @@ services:
- "./data/adminer/fonts/:/var/www/html/fonts"
volumes:
wikipedia-solver-dev-mariadb-data:
wikipedia-solver-dev-postgres-data:

View File

@ -27,27 +27,20 @@ services:
wikipedia-solver-database:
container_name: "wikipedia-solver-database"
image: "mariadb:10.6.17"
image: "postgres:16.3"
restart: "unless-stopped"
env_file: ".env"
environment:
MARIADB_USER: ${DATABASE_USER}
MARIADB_PASSWORD: ${DATABASE_PASSWORD}
MARIADB_ROOT_PASSWORD: ${DATABASE_PASSWORD}
MARIADB_DATABASE: ${DATABASE_NAME}
POSTGRES_USER: ${DATABASE_USER}
POSTGRES_PASSWORD: ${DATABASE_PASSWORD}
POSTGRES_DB: ${DATABASE_NAME}
command: |
--innodb_buffer_pool_size=4G
--key-buffer-size=4G
--innodb_log_buffer_size=256M
--innodb_log_file_size=1G
--innodb_write_io_threads=16
--innodb_flush_log_at_trx_commit=0
--max_allowed_packet=1G
--max_wal_size=4GB
ports:
- "${DATABASE_PORT-3306}:${DATABASE_PORT-3306}"
- "${DATABASE_PORT-5432}:${DATABASE_PORT-5432}"
volumes:
- "wikipedia-solver-mariadb-data:/var/lib/mysql"
# - "./sql:/docker-entrypoint-initdb.d/"
- "wikipedia-solver-postgres-data:/var/lib/postgresql/data"
- "./data:/data/"
volumes:
wikipedia-solver-mariadb-data:
wikipedia-solver-postgres-data:

View File

@ -1,3 +0,0 @@
DATABASE_USER=wikipedia_user
DATABASE_PASSWORD=password
DATABASE_NAME=wikipedia

View File

@ -2,7 +2,11 @@
```sh
./download-wikipedia-dump.sh
node --max-old-space-size=8096 database-wikipedia.js
node --max-old-space-size=8096 generate-sql-files.js
# Inside the Database container
docker exec -it wikipedia-solver-dev-database sh
/data/execute-sql.sh
```
## Utils
@ -11,13 +15,7 @@ Show the first 10 line of sql file: `head -n 10 ./dump/page.sql`
Show the first 10 characters of sql file: `head -c 10 ./dump/page.sql`
To inspect volume size used by database: `docker system df -v | grep 'wikipedia-solver-mariadb-data'`
To enter in the database container: `docker exec -it wikipedia-solver-database sh`
Then: `mariadb --password="${DATABASE_PASSWORD}" --user="${DATABASE_USER}"`
And `use wikipedia;`, for example: `SELECT * FROM pages LIMIT 10;` or to execute a SQL script: `source /docker-entrypoint-initdb.d/3-internal-links-inserts.sql;`.
To inspect volume size used by database: `docker system df -v`
## Remove a volume
@ -32,15 +30,22 @@ docker volume rm data_wikipedia-solver-mariadb-data
docker-compose down --volumes
```
## MySQL Related
## PostgreSQL Related
<https://stackoverflow.com/questions/43954631/issues-with-wikipedia-dump-table-pagelinks>
<https://stackoverflow.com/questions/12206600/how-to-speed-up-insertion-performance-in-postgresql>
MySQL any way to import a huge (32 GB) sql dump faster?: <https://stackoverflow.com/questions/40384864/importing-wikipedia-dump-to-mysql>
```sh
docker exec -it wikipedia-solver-dev-database sh
Import data.sql MySQL Docker Container: <https://stackoverflow.com/questions/43880026/import-data-sql-mysql-docker-container>
psql --username="${DATABASE_USER}" --dbname="${DATABASE_NAME}"
```
<https://dba.stackexchange.com/questions/83125/mysql-any-way-to-import-a-huge-32-gb-sql-dump-faster>
```sql
-- Execute script with inserts
\i /data/sql-pages-inserts/0001-pages-inserts.sql
/data/sql-internal-links-inserts/0001-internal-links.sh
```
## Dumps Links

View File

@ -1,39 +0,0 @@
services:
wikipedia-solver-database:
container_name: "wikipedia-solver-database"
image: "mariadb:10.6.17"
restart: "unless-stopped"
env_file: ".env"
environment:
MARIADB_USER: ${DATABASE_USER}
MARIADB_PASSWORD: ${DATABASE_PASSWORD}
MARIADB_ROOT_PASSWORD: ${DATABASE_PASSWORD}
MARIADB_DATABASE: ${DATABASE_NAME}
command: |
--innodb_buffer_pool_size=4G
--key-buffer-size=4G
--innodb_log_buffer_size=256M
--innodb_log_file_size=1G
--innodb_write_io_threads=16
--innodb_flush_log_at_trx_commit=0
--max_allowed_packet=1G
volumes:
- "wikipedia-solver-mariadb-data:/var/lib/mysql"
- "./sql:/docker-entrypoint-initdb.d/"
adminer:
container_name: "adminer"
image: "adminer:4.8.1"
restart: "unless-stopped"
ports:
- "8080:8080"
env_file: ".env"
environment:
ADMINER_DEFAULT_SERVER: "wikipedia-solver-database"
volumes:
- "./adminer/default-orange.css:/var/www/html/adminer.css"
- "./adminer/logo.png:/var/www/html/logo.png"
- "./adminer/fonts/:/var/www/html/fonts"
volumes:
wikipedia-solver-mariadb-data:

8
data/execute-sql.sh Executable file
View File

@ -0,0 +1,8 @@
#!/usr/bin/env bash
/data/sql/0000-sql-init.sh
/data/sql-pages-inserts/0000-pages.sh
/data/sql-internal-links-inserts/0000-internal-links.sh
/data/sql/0999-sql-end.sh

View File

@ -7,7 +7,6 @@ import {
} from "./utils.js"
const SQL_DUMP_PATH = path.join(process.cwd(), "dump")
const SQL_OUTPUT_PATH = path.join(process.cwd(), "sql")
const SQL_FILENAME_NUMBER_PAD = 4
/**
@ -52,9 +51,42 @@ const cleanPagesSQL = async () => {
let current = ""
let lastPercent = 0
let pagesFileCount = 1
const INSERT_INTO_START_OUTPUT = "INSERT INTO pages (id, title) VALUES "
const BATCH_SIZE = 1_000_000
/**
* @type {string[]}
*/
let batch = []
const flushBatch = async () => {
if (batch.length > 0) {
const batchString = batch.join(",")
const fileName = `${zeroPad(pagesFileCount, SQL_FILENAME_NUMBER_PAD)}-pages-inserts.sql`
const sqlOutputPath = path.join(
process.cwd(),
"sql-pages-inserts",
fileName,
)
await fs.promises.writeFile(
sqlOutputPath,
`${INSERT_INTO_START_OUTPUT}${batchString};`,
{
encoding: "utf-8",
},
)
console.log(`flushBatch - ${fileName}, batch.length: ${batch.length}`)
pagesFileCount += 1
batch = []
}
}
return await new Promise((resolve, reject) => {
sqlInputFileStream
.on("data", (dataInput) => {
.on("data", async (dataInput) => {
const bytesReadRatio = sqlInputFileStream.bytesRead / sqlInputStat.size
const bytesReadPercent = bytesReadRatio * 100
@ -98,13 +130,21 @@ const cleanPagesSQL = async () => {
if (namespace === "0" && !isRedirect) {
wikipediaPagesKeyId[id] = title
batch.push(`(${id},E${title})`)
}
}
if (batch.length >= BATCH_SIZE) {
sqlInputFileStream.pause()
await flushBatch()
sqlInputFileStream.resume()
}
})
.on("error", (error) => {
return reject(error)
})
.on("close", () => {
.on("close", async () => {
await flushBatch()
console.log("cleanPagesSQL - Bytes read (100%).")
return resolve(wikipediaPagesKeyId)
})
@ -113,30 +153,6 @@ const cleanPagesSQL = async () => {
const wikipediaPagesKeyId = await cleanPagesSQL()
const cleanPagesSQLWriteToFile = async () => {
console.log("cleanPagesSQLWriteToFile - Writing to file...")
const sqlOutputPath = path.join(
SQL_OUTPUT_PATH,
`${zeroPad(1, SQL_FILENAME_NUMBER_PAD)}-pages-inserts.sql`,
)
const INSERT_INTO_START_OUTPUT = "INSERT INTO pages (id, title) VALUES "
const wikipediaPagesString = Object.entries(wikipediaPagesKeyId)
.map(([id, title]) => {
return `(${id},${title})`
})
.join(",")
await fs.promises.writeFile(
sqlOutputPath,
`${INSERT_INTO_START_OUTPUT}${wikipediaPagesString};`,
{ encoding: "utf-8" },
)
console.log("cleanPagesSQLWriteToFile - Done.")
}
await cleanPagesSQLWriteToFile()
/**
* Function to clean the `pagelinks.sql` file by:
* - Removing all lines that don't start with `INSERT INTO...`.
@ -145,7 +161,7 @@ await cleanPagesSQLWriteToFile()
* @returns {Promise<void>}
*/
const cleanInternalLinksSQL = async () => {
let internalLinksFileCount = 2
let internalLinksFileCount = 1
const INSERT_INTO_START_OUTPUT =
"INSERT INTO internal_links (from_page_id, to_page_id) VALUES "
@ -174,7 +190,11 @@ const cleanInternalLinksSQL = async () => {
if (batch.length > 0) {
const batchString = batch.join(",")
const fileName = `${zeroPad(internalLinksFileCount, SQL_FILENAME_NUMBER_PAD)}-internal-links-inserts.sql`
const sqlOutputPath = path.join(SQL_OUTPUT_PATH, fileName)
const sqlOutputPath = path.join(
process.cwd(),
"sql-internal-links-inserts",
fileName,
)
await fs.promises.writeFile(
sqlOutputPath,
`${INSERT_INTO_START_OUTPUT}${batchString};`,

View File

@ -0,0 +1,6 @@
#!/usr/bin/env bash
for sqlInsert in /data/sql-internal-links-inserts/*.sql; do
echo "${sqlInsert}"
time psql --username="${DATABASE_USER}" --dbname="${DATABASE_NAME}" --file="${sqlInsert}"
done

View File

@ -0,0 +1,6 @@
#!/usr/bin/env bash
for sqlInsert in /data/sql-pages-inserts/*.sql; do
echo "${sqlInsert}"
time psql --username="${DATABASE_USER}" --dbname="${DATABASE_NAME}" --file="${sqlInsert}"
done

3
data/sql/0000-sql-init.sh Executable file
View File

@ -0,0 +1,3 @@
#!/usr/bin/env bash
time psql --username="${DATABASE_USER}" --dbname="${DATABASE_NAME}" --file="/data/sql/0000-sql-init.sql"

View File

@ -0,0 +1,2 @@
ALTER TABLE pages DISABLE TRIGGER ALL;
ALTER TABLE internal_links DISABLE TRIGGER ALL;

View File

@ -1,28 +0,0 @@
CREATE TABLE `pages` (
`id` INT(8) UNSIGNED NOT NULL AUTO_INCREMENT,
`title` VARBINARY(255) NOT NULL DEFAULT '',
-- `is_redirect` tinyint(1) unsigned NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY (`title`)
) ENGINE=MyISAM AUTO_INCREMENT=76684425 DEFAULT CHARSET=binary ROW_FORMAT=COMPRESSED;
-- VARBINARY usage instead of VARCHAR explanation: <https://stackoverflow.com/a/13397437>
-- > War on varchar. Changed all occurrences of varchar(N) and varchar(N) binary to varbinary(N). varchars cause problems ("Invalid mix of collations" errors) on MySQL databases with certain configs, most notably the default MySQL config.
CREATE TABLE `internal_links` (
-- `id` INT(8) UNSIGNED NOT NULL AUTO_INCREMENT,
`from_page_id` INT(8) UNSIGNED NOT NULL,
`to_page_id` INT(8) UNSIGNED NOT NULL,
-- PRIMARY KEY (`id`)
PRIMARY KEY (`from_page_id`, `to_page_id`),
FOREIGN KEY (`from_page_id`) REFERENCES `pages` (`id`) ON DELETE CASCADE,
FOREIGN KEY (`to_page_id`) REFERENCES `pages` (`id`) ON DELETE CASCADE
) ENGINE=MyISAM DEFAULT CHARSET=binary ROW_FORMAT=COMPRESSED;
SET @@session.unique_checks = 0;
SET @@session.foreign_key_checks = 0;
SET FOREIGN_KEY_CHECKS = 0;
SET UNIQUE_CHECKS = 0;

View File

@ -1,11 +0,0 @@
-- SET @@session.foreign_key_checks = 0;
-- SET FOREIGN_KEY_CHECKS = 0;
-- ALTER TABLE `internal_links` ADD CONSTRAINT fk_from_page_id FOREIGN KEY (`from_page_id`) REFERENCES `pages` (`id`);
-- ALTER TABLE `internal_links` ADD CONSTRAINT fk_to_page_id FOREIGN KEY (`to_page_id`) REFERENCES `pages` (`id`);
SET @@session.unique_checks = 1;
SET @@session.foreign_key_checks = 1;
SET FOREIGN_KEY_CHECKS = 1;
SET UNIQUE_CHECKS = 1;

3
data/sql/0999-sql-end.sh Executable file
View File

@ -0,0 +1,3 @@
#!/usr/bin/env bash
time psql --username="${DATABASE_USER}" --dbname="${DATABASE_NAME}" --file="/data/sql/0999-sql-end.sql"

View File

@ -0,0 +1,2 @@
ALTER TABLE pages ENABLE TRIGGER ALL;
ALTER TABLE internal_links ENABLE TRIGGER ALL;

View File

@ -26,7 +26,7 @@
"editorconfig-checker": "5.1.8",
"playwright": "catalog:",
"prettier": "3.3.3",
"prettier-plugin-tailwindcss": "0.6.5",
"prettier-plugin-tailwindcss": "0.6.6",
"replace-in-files-cli": "3.0.0",
"semantic-release": "23.1.1",
"turbo": "2.0.12",

View File

@ -7,3 +7,60 @@
export const capitalize = (string: string): string => {
return string.charAt(0).toUpperCase() + string.slice(1)
}
/**
* Trim any of the specified characters from the start and end of a string.
* @param string
* @param characters
* @returns
* @example trimAny("_____foo bar ", [" ", "_"]) // "foo bar"
*/
export const trimAny = (string: string, characters: string[]): string => {
let start = 0
let end = string.length
while (
start < end &&
(characters as Array<string | undefined>).includes(string[start])
) {
start += 1
}
while (
end > start &&
(characters as Array<string | undefined>).includes(string[end - 1])
) {
end -= 1
}
return start > 0 || end < string.length
? string.substring(start, end)
: string
}
/**
* Reduces consecutive occurrences of specified characters in a string to a single occurrence.
*
* @param input
* @param characters
* @returns
* @example reduceConsecutiveCharacters("Hello___there!!", ["_", "!"]) // "Hello_there!"
*/
export const reduceConsecutiveCharacters = (
input: string,
characters: string[],
): string => {
let result = ""
let previousCharacter = ""
for (const currentCharacter of input) {
if (characters.includes(currentCharacter)) {
if (currentCharacter !== previousCharacter) {
result += currentCharacter
}
} else {
result += currentCharacter
}
previousCharacter = currentCharacter
}
return result
}

View File

@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest"
import { capitalize } from "../strings.js"
import { capitalize, reduceConsecutiveCharacters, trimAny } from "../strings.js"
describe("capitalize", () => {
it("should capitalize the first letter of a string", () => {
@ -39,3 +39,111 @@ describe("capitalize", () => {
expect(output).toEqual(expected)
})
})
describe("trimAny", () => {
it("should trim any of the specified characters from the start and end of a string", () => {
// Arrange - Given
const input = "_____foo bar "
const characters = [" ", "_"]
// Act - When
const output = trimAny(input, characters)
// Assert - Then
const expected = "foo bar"
expect(output).toEqual(expected)
})
it("should trim any of the specified characters from the start and end of a string even if the start and end characters are different", () => {
// Arrange - Given
const input = "_ __ _foo bar _"
const characters = [" ", "_"]
// Act - When
const output = trimAny(input, characters)
// Assert - Then
const expected = "foo bar"
expect(output).toEqual(expected)
})
it("should return the same string when the input does not start or end with any of the specified characters", () => {
// Arrange - Given
const input = "foo bar"
const characters = [" ", "_"]
// Act - When
const output = trimAny(input, characters)
// Assert - Then
const expected = "foo bar"
expect(output).toEqual(expected)
})
it("should return an empty string when the input is an empty string", () => {
// Arrange - Given
const input = ""
const characters = [" ", "_"]
// Act - When
const output = trimAny(input, characters)
// Assert - Then
const expected = ""
expect(output).toEqual(expected)
})
it("should return an empty string when the input starts and ends with the specified characters", () => {
// Arrange - Given
const input = " _ "
const characters = [" ", "_"]
// Act - When
const output = trimAny(input, characters)
// Assert - Then
const expected = ""
expect(output).toEqual(expected)
})
})
describe("reduceConsecutiveCharacters", () => {
it("should reduce consecutive occurrences of specified characters in a string to a single occurrence", () => {
// Arrange - Given
const input = "Hello___there!!"
const characters = ["_", "!"]
// Act - When
const output = reduceConsecutiveCharacters(input, characters)
// Assert - Then
const expected = "Hello_there!"
expect(output).toEqual(expected)
})
it("should return the same string when there are no consecutive occurrences of specified characters", () => {
// Arrange - Given
const input = "Hello there!"
const characters = ["_", "!"]
// Act - When
const output = reduceConsecutiveCharacters(input, characters)
// Assert - Then
const expected = "Hello there!"
expect(output).toEqual(expected)
})
it("should return an empty string when the input is an empty string", () => {
// Arrange - Given
const input = ""
const characters = ["_", "!"]
// Act - When
const output = reduceConsecutiveCharacters(input, characters)
// Assert - Then
const expected = ""
expect(output).toEqual(expected)
})
})

View File

@ -5,7 +5,8 @@
"type": "module",
"exports": {
"./WikipediaClient": "./src/WikipediaClient.tsx",
"./wikipedia-api": "./src/wikipedia-api.ts"
"./wikipedia-api": "./src/wikipedia-api.ts",
"./wikipedia-utils": "./src/wikipedia-utils.ts"
},
"scripts": {
"lint:eslint": "eslint src --max-warnings 0 --report-unused-disable-directives",
@ -17,6 +18,7 @@
"@repo/config-tailwind": "workspace:*",
"@repo/i18n": "workspace:*",
"@repo/ui": "workspace:*",
"@repo/utils": "workspace:*",
"ky": "catalog:",
"next": "catalog:",
"next-intl": "catalog:",

View File

@ -4,11 +4,11 @@ import { Button } from "@repo/ui/Design/Button"
import { Link } from "@repo/ui/Design/Link"
import { Typography } from "@repo/ui/Design/Typography"
import { useState } from "react"
import { getWikipediaPageInternalLinks } from "./wikipedia-api"
import {
fromLocaleToWikipediaLocale,
getWikipediaLink,
getWikipediaPageInternalLinks,
} from "./wikipedia-api"
} from "./wikipedia-utils"
export const WikipediaClient: React.FC = () => {
const [isLoading, setIsLoading] = useState(false)

View File

@ -1,9 +0,0 @@
import { describe, expect, it } from "vitest"
import { sum } from "../wikipedia-api"
describe("sum", () => {
it("adds 1 + 2 to equal 3", () => {
expect(sum(1, 2)).toBe(3)
})
})

View File

@ -0,0 +1,135 @@
import { describe, expect, it } from "vitest"
import {
fromLocaleToWikipediaLocale,
fromSanitizedPageTitleToPageTitle,
getWikipediaLink,
sanitizePageTitle,
} from "../wikipedia-utils"
describe("fromLocaleToWikipediaLocale", () => {
it("should return the correct Wikipedia locale", () => {
// Arrange - Given
const input = "en-US"
// Act - When
const output = fromLocaleToWikipediaLocale(input)
// Assert - Then
const expected = "en"
expect(output).toEqual(expected)
})
})
describe("getWikipediaLink", () => {
it("should return the correct Wikipedia link for the given locale", () => {
// Arrange - Given
const input = "en"
// Act - When
const output = getWikipediaLink(input)
// Assert - Then
const expected = "https://en.wikipedia.org"
expect(output).toEqual(expected)
})
})
describe("sanitizePageTitle", () => {
it("should return the correct sanitized page title", () => {
// Arrange - Given
const input = "foo bar"
// Act - When
const output = sanitizePageTitle(input)
// Assert - Then
const expected = "Foo_bar"
expect(output).toEqual(expected)
})
it("should preserve the characters case", () => {
// Arrange - Given
const input = "Foo Bar"
// Act - When
const output = sanitizePageTitle(input)
// Assert - Then
const expected = "Foo_Bar"
expect(output).toEqual(expected)
})
it("should remove leading and trailing spaces/underscores (rule 1)", () => {
// Arrange - Given
const input = " Abc_def__"
// Act - When
const output = sanitizePageTitle(input)
// Assert - Then
const expected = "Abc_def"
expect(output).toEqual(expected)
})
it("should reduce consecutive spaces/underscores to a single one (rule 2)", () => {
// Arrange - Given
const input = "Abc def"
// Act - When
const output = sanitizePageTitle(input)
// Assert - Then
const expected = "Abc_def"
expect(output).toEqual(expected)
})
it("should replace spaces by underscores (rule 3)", () => {
// Arrange - Given
const input = "Abc def"
// Act - When
const output = sanitizePageTitle(input)
// Assert - Then
const expected = "Abc_def"
expect(output).toEqual(expected)
})
it("should capitalize the first character (rule 4)", () => {
// Arrange - Given
const input = "abc_def"
// Act - When
const output = sanitizePageTitle(input)
// Assert - Then
const expected = "Abc_def"
expect(output).toEqual(expected)
})
it("should have a maximum of 255 characters (rule 5)", () => {
// Arrange - Given
const input = "a".repeat(256)
// Act - When
const output = sanitizePageTitle(input)
// Assert - Then
const expected = "A" + "a".repeat(254)
expect(output).toEqual(expected)
})
})
describe("fromSanitizedPageTitleToPageTitle", () => {
it("should return the correct page title", () => {
// Arrange - Given
const input = "Foo_bar"
// Act - When
const output = fromSanitizedPageTitleToPageTitle(input)
// Assert - Then
const expected = "Foo bar"
expect(output).toEqual(expected)
})
})

View File

@ -1,33 +1,11 @@
import type { Locale } from "@repo/i18n/config"
import ky from "ky"
export const sum = (a: number, b: number): number => {
return a + b
}
import { getWikipediaLink, type WikipediaLocale } from "./wikipedia-utils"
/**
* @see https://www.mediawiki.org/wiki/Wikimedia_REST_API#Terms_and_conditions
* To avoid impacting other API users, limit your clients to no more than 200 requests/sec to this API overall. Many entry points additionally specify and enforce more restrictive rate limits (HTTP 429 error).
*/
export const WIKIPEDIA_LOCALES = ["en", "fr"] as const
export type WikipediaLocale = (typeof WIKIPEDIA_LOCALES)[number]
const WIKIPEDIA_LOCALES_MAP: Record<Locale, WikipediaLocale> = {
"en-US": "en",
"fr-FR": "fr",
}
export const fromLocaleToWikipediaLocale = (
locale: Locale,
): WikipediaLocale => {
return WIKIPEDIA_LOCALES_MAP[locale]
}
export const getWikipediaLink = (locale: WikipediaLocale): string => {
return `https://${locale}.wikipedia.org`
}
interface WikipediaQueryLinksResponse {
continue?: {
plcontinue: string

View File

@ -0,0 +1,70 @@
import type { Locale } from "@repo/i18n/config"
import {
capitalize,
reduceConsecutiveCharacters,
trimAny,
} from "@repo/utils/strings"
export const WIKIPEDIA_LOCALES = ["en", "fr"] as const
export type WikipediaLocale = (typeof WIKIPEDIA_LOCALES)[number]
const WIKIPEDIA_LOCALES_MAP: Record<Locale, WikipediaLocale> = {
"en-US": "en",
"fr-FR": "fr",
}
export const fromLocaleToWikipediaLocale = (
locale: Locale,
): WikipediaLocale => {
return WIKIPEDIA_LOCALES_MAP[locale]
}
export const getWikipediaLink = (locale: WikipediaLocale): string => {
return `https://${locale}.wikipedia.org`
}
/**
* Converts a page title to a sanitized version (also called "canonical form") that can be used in a URL.
*
* The sanitized page title is stored as text with the following restrictions:
* 1. leading and trailing spaces (` `) and underscores (`_`) are removed.
* 2. consecutive spaces/underscores are reduced to a single one.
* 3. spaces are replaced by underscores.
* 4. first character is capitalized.
* 5. maximum of 255 characters.
* @param pageTitle
* @see https://www.mediawiki.org/wiki/Manual:Page_title
* @see https://en.wikipedia.org/wiki/Wikipedia:Naming_conventions_(technical_restrictions)
* @returns
* @example sanitizePageTitle("foo bar") // "Foo_bar"
* @example sanitizePageTitle("Foo Bar") // "Foo_Bar"
*/
export const sanitizePageTitle = (pageTitle: string): string => {
const rule1 = trimAny(pageTitle, [" ", "_"])
const rule2 = reduceConsecutiveCharacters(rule1, [" ", "_"])
const rule3 = rule2.replaceAll(" ", "_")
const rule4 = capitalize(rule3)
const rule5 = rule4.slice(0, 255)
return rule5
}
/**
* Converts a sanitized page title to a page title.
*
* A page title is the title of a wiki page, which is a human-readable and unique identifier for a page.
*
* Underscores (`_`) are replaced by spaces (` `).
*
* A page title is not to be confused with a display title.
* A display title is the preferred title associated with a wiki page (stored separately), with less restrictions than a page title.
* @param sanitizedPageTitle
* @see https://www.mediawiki.org/wiki/Manual:Page_title
* @see https://www.mediawiki.org/wiki/Display_title
* @returns
* @example fromSanitizedPageTitleToPageTitle("Foo_bar") // "Foo bar"
*/
export const fromSanitizedPageTitleToPageTitle = (
sanitizedPageTitle: string,
): string => {
return sanitizedPageTitle.replaceAll("_", " ")
}

768
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -21,7 +21,7 @@ catalog:
# TypeScript
"typescript": "5.5.4"
"@total-typescript/ts-reset": "0.5.1"
"@types/node": "22.1.0"
"@types/node": "22.2.0"
"tsx": "4.17.0"
# AdonisJS
@ -29,7 +29,9 @@ catalog:
"@adonisjs/core": "6.12.1"
"@adonisjs/cors": "2.2.1"
"@adonisjs/lucid": "21.2.0"
"mysql2": "3.11.0"
"@adonisjs/limiter": "2.3.2"
"pg": "8.12.0"
"better-sqlite3": "11.1.2"
"@adonisjs/assembler": "7.7.0"
"@vinejs/vine": "2.1.0"
"luxon": "3.5.0"
@ -80,6 +82,7 @@ catalog:
"start-server-and-test": "2.0.5"
"@vitest/browser": "2.0.5"
"@vitest/coverage-istanbul": "2.0.5"
"c8": "10.1.2"
"@vitest/ui": "2.0.5"
"vitest": "2.0.5"
"@testing-library/react": "16.0.0"