feat(api): health checks

This commit is contained in:
Théo LUDWIG 2024-08-12 18:19:43 +01:00
parent 207d3483ca
commit 376c0fd041
Signed by: theoludwig
GPG Key ID: ADFE5A563D718F3B
12 changed files with 90 additions and 10 deletions

View File

@ -34,8 +34,7 @@
- [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?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 - [ ] 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] Setup tests with database + add coverage - [x] Setup tests with database + add coverage
- [ ] Setup HTTP Requests logging in development (not needed in `test` mode) - [x] Setup Health checks
- [ ] Setup Health checks
- [x] Setup Rate limiting - [x] Setup Rate limiting
- [ ] Share VineJS validators between `website` and `api` - [ ] Share VineJS validators between `website` and `api`
- [ ] Implement Wikipedia Game Solver (`website`) - [ ] Implement Wikipedia Game Solver (`website`)

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,12 +3,11 @@ import type { HttpContext } from "@adonisjs/core/http"
import type { NextFn } from "@adonisjs/core/types/http" import type { NextFn } from "@adonisjs/core/types/http"
/** /**
* Auth middleware is used authenticate HTTP requests and deny * Auth middleware is used authenticate HTTP requests and deny access to unauthenticated users.
* access to unauthenticated users.
*/ */
export default class AuthMiddleware { export default class AuthMiddleware {
/** /**
* The URL to redirect to, when authentication fails * The URL to redirect to, when authentication fails.
*/ */
redirectTo = "/login" redirectTo = "/login"

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

@ -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

@ -1,2 +1,3 @@
import "#app/routes/get.js" import "#app/routes/get.js"
import "#app/routes/health/get.js"
import "#app/routes/wikipedia/index.js" import "#app/routes/wikipedia/index.js"

View File

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

View File

@ -51,6 +51,7 @@ test.group("GET /wikipedia/pages", (group) => {
const response = await client.get( const response = await client.get(
`/wikipedia/pages?${searchParams.toString()}`, `/wikipedia/pages?${searchParams.toString()}`,
) )
const responseBody = response.body()
// Assert - Then // Assert - Then
response.assertStatus(200) response.assertStatus(200)
@ -59,7 +60,7 @@ test.group("GET /wikipedia/pages", (group) => {
return page.toJSON() return page.toJSON()
}), }),
) )
assert.equal(response.body().length, limit) assert.equal(responseBody.length, limit)
}) })
test('should fails when "title" is not provided', async ({ client }) => { test('should fails when "title" is not provided', async ({ client }) => {

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. * 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. * 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 * The configuration settings used by the HTTP server

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

@ -45,6 +45,9 @@ router.use([
* Named middleware collection must be explicitly assigned to the routes or the routes group. * Named middleware collection must be explicitly assigned to the routes or the routes group.
*/ */
export const middleware = router.named({ export const middleware = router.named({
appKeySecurity: async () => {
return await import("#app/middleware/app_key_security_middleware.js")
},
auth: async () => { auth: async () => {
return await import("#app/middleware/auth_middleware.js") return await import("#app/middleware/auth_middleware.js")
}, },

View File

@ -4,11 +4,12 @@
* 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. * 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 app from "@adonisjs/core/services/app"
import limiter from "@adonisjs/limiter/services/main" import limiter from "@adonisjs/limiter/services/main"
export const throttle = limiter.define("global", () => { export const throttle = limiter.define("global", (context) => {
if (app.inTest) { if (app.inTest || context.request.header(APP_KEY_HEADER_NAME) === APP_KEY) {
return limiter.noLimit() return limiter.noLimit()
} }
return limiter.allowRequests(120).every("1 minute") return limiter.allowRequests(120).every("1 minute")