21 Commits

Author SHA1 Message Date
594eccbc8b chore(changelog): update changelog for v0.1.0 2025-05-08 17:07:19 +00:00
3afe6bcbc3 chore(version): add initial version file
All checks were successful
Auto Changelog & Release / detect-version-change (push) Successful in 10s
Auto Changelog & Release / changelog-only (push) Has been skipped
Auto Changelog & Release / release (push) Successful in 2m21s
- Introduces a version file to track the project's version
- Sets the initial version to 0.1.0
2025-05-08 19:03:42 +02:00
bbf78cff17 feat(workflows): add automated changelog and release workflow
- Introduces a GitHub Actions workflow for automated changelog generation
  and release creation triggered by version changes or manual dispatch.
- Includes steps for detecting version file changes, generating changelogs
  using git-cliff, and publishing releases to Gitea.
2025-05-08 19:03:30 +02:00
8235680904 refactor(types): unify handler and middleware definitions
- Consolidates `Handler` and `Middleware` types under `Types` module
- Replaces `IHandler` and `IMiddleware` interfaces with typed functions
- Simplifies imports and improves code organization
- Enhances debugging with named handlers and middlewares
2025-05-08 19:03:18 +02:00
56633cd95b feat(vscode): customize activity bar and peacock colors
- Add custom colors for the activity bar to enhance UI aesthetics
- Set Peacock extension color for consistent workspace theming
2025-05-08 19:02:24 +02:00
5c03cdfb03 docs(gitea): add release automation guide and scripts
- Document automated release process with versioning and changelog generation
- Add scripts for retrieving release IDs and uploading assets to releases
- Provide best practices and debugging tips for maintaining consistency
2025-05-08 19:02:14 +02:00
661f83d1fd chore(config): add default git-cliff configuration
- Introduces a default configuration file for git-cliff
- Enables changelog generation with templates and commit parsing
- Configures commit grouping, tag patterns, and remote repository details
2025-05-08 19:01:55 +02:00
7b6eb2b574 feat(workflows): add upload assets template for releases
- Introduces a workflow to automate uploading release assets
- Triggers on published releases and uses custom scripts
- Enhances release management by linking artifacts to releases
2025-05-08 19:01:37 +02:00
f0838567b4 chore(gitignore): add .gitea/COMMIT_GPT.md to ignored files
- Adds .gitea/COMMIT_GPT.md to the .gitignore file to exclude it from version control.
2025-05-08 19:01:27 +02:00
b009b5763d feat(config): add project metadata and test watch task
- Introduce name and description fields for project metadata
- Add a test:watch task for running tests in watch mode
- Improve configuration clarity by adjusting formatting
2025-05-08 19:01:03 +02:00
b0c6901d7d fix(params): enforce non-undefined route parameter values
- Update `Params` type to disallow `undefined` values for clarity.
- Default route parameter values to empty strings if `null` or `undefined`.

Signed-off-by: Max P. <Mail@MPassarello.de>
2025-05-07 19:40:08 +02:00
6c4420d32f docs(httpkernel): enhance class and interface documentation
- Improve JSDoc comments for `HttpKernel` and `IHttpKernel` to clarify
  purpose, usage, and type parameters.
- Add detailed descriptions for methods, parameters, and generics.
- Refine explanations of middleware pipeline, error handling, and
  contextual typing.
- Enhance readability and consistency in public API documentation.

Signed-off-by: Max P. <Mail@MPassarello.de>
2025-05-07 16:45:37 +02:00
b7410b44dd refactor(core): enhance HttpKernel pipeline and matcher system with full context and error handling
BREAKING CHANGE: `parseQuery` utility removed; `IRouteMatcher` now includes query parsing; `RouteBuilder.middleware` and `handle` are now strictly typed per builder instance.

- Add `isHandler` and `isMiddleware` runtime type guards for validation in `HttpKernel`.
- Introduce `createEmptyContext` for constructing default context objects.
- Support custom HTTP error handlers (`404`, `500`) via `IHttpKernelConfig.httpErrorHandlers`.
- Default error handlers return meaningful HTTP status text (e.g., "Not Found").
- Replace legacy `parseQuery` logic with integrated query extraction via `createRouteMatcher`.

- Strongly type `RouteBuilder.middleware()` and `.handle()` methods without generic overrides.
- Simplify `HttpKernel.handle()` and `executePipeline()` through precise control flow and validation.
- Remove deprecated `registerRoute.ts` and `HttpKernelConfig.ts` in favor of colocated type exports.

- Add tests for integrated query parsing in `createRouteMatcher`.
- Improve error handling tests: middleware/handler validation, double `next()` call, thrown exceptions.
- Replace `assertRejects` with plain response code checks (via updated error handling).

- Removed `parseQuery.ts` and all related tests — query parsing is now built into route matching.
- `IRouteMatcher` signature changed to return `{ params, query }` instead of only `params`.
- `HttpKernelConfig` now uses `DeepPartial` and includes `httpErrorHandlers`.
- `RouteBuilder`'s generics are simplified for better DX and improved type safety.

This refactor improves clarity, test coverage, and runtime safety of the request lifecycle while reducing boilerplate and eliminating duplicated query handling logic.

Signed-off-by: Max P. <Mail@MPassarello.de>
2025-05-07 16:45:10 +02:00
9059bdda62 refactor(httpkernel): introduce configuration object for flexibility
- Replace individual constructor arguments with a configuration object.
- Add IHttpKernelConfig interface to standardize configuration structure.
- Refactor route builder and response decorator usage to use config.
- Simplify code and improve extensibility by consolidating parameters.

Signed-off-by: Max P. <Mail@MPassarello.de>
2025-05-07 13:56:56 +02:00
0990cacb22 chore(settings): add exportall configuration for barrel name and message
Signed-off-by: Max P. <Mail@MPassarello.de>
2025-05-07 13:56:39 +02:00
fd1c7f4170 chore(.gitignore): add git_log_diff.txt to ignore list
Signed-off-by: Max P. <Mail@MPassarello.de>
2025-05-07 12:42:16 +02:00
ba7aa79f56 feat(http): add error handling for invalid HTTP methods
- Introduce `InvalidHttpMethodError` for unrecognized HTTP methods.
- Enhance type safety in `HttpKernel` by using generic contexts.
- Update `ResponseDecorator` to accept context for enriched responses.

Signed-off-by: Max P. <Mail@MPassarello.de>
2025-05-07 12:35:41 +02:00
a236fa7c97 feat(http): enhance type safety and extend route context
- Refactor HttpKernel and related interfaces to support generic contexts.
- Add typed query parameters, route params, and state to IContext.
- Introduce HttpMethod type for stricter HTTP method validation.
- Update RouteBuilder and middleware to handle generic contexts.
- Improve test cases to verify compatibility with new types.

Signed-off-by: Max P. <Mail@MPassarello.de>
2025-05-07 12:29:49 +02:00
82a6877485 test(utils): rename and update import paths in test file
- Rename test file for better alignment with its purpose.
- Update relative import paths to reflect the new file location.

Signed-off-by: Max P. <Mail@MPassarello.de>
2025-05-07 12:29:10 +02:00
94525fce52 test(utils): add unit tests for parseQuery function
- Introduce comprehensive tests for the parseQuery utility.
- Validate handling of single and multi-value query parameters.
- Ensure empty query strings return an empty object.
- Confirm repeated keys are grouped into arrays.

Signed-off-by: Max P. <Mail@MPassarello.de>
2025-05-07 12:28:51 +02:00
cc734fa7b1 Update settings.json to include folderListener configuration for exportall
Signed-off-by: Max P. <Mail@MPassarello.de>
2025-05-07 12:28:09 +02:00
54 changed files with 1974 additions and 333 deletions

124
.gitea/HOWTO_RELEASE.md Normal file
View File

@@ -0,0 +1,124 @@
# 📦 HOWTO: Release erstellen mit Auto-Changelog-Workflow
Dieses Repository nutzt einen automatisierten CI/CD-Workflow zur **Versionsverwaltung, Changelog-Generierung und Release-Erstellung**.
Der gesamte Prozess ist deklarativ und läuft automatisch – ausgelöst durch Änderungen an einer Datei: `VERSION`.
---
## 🧭 Was passiert automatisch?
Sobald Änderungen in `main` landen, prüft der Workflow:
- 🔍 **Hat sich die Datei `VERSION` geändert?**
-**Nein** → es wird nur das `CHANGELOG.md` aktualisiert (unreleased Abschnitt)
-**Ja** → es wird:
- ein vollständiger Changelog für diese Version erzeugt
- ein Git-Tag `vX.Y.Z` erstellt
- ein Release in Gitea veröffentlicht (inkl. Beschreibung aus dem Changelog)
---
## ✅ Wie erzeuge ich ein Release?
### 1. Erhöhe die Version in der Datei `VERSION`
Beispiel:
```txt
1.2.3
```
> Diese Datei muss **als eigene Commit-Änderung** erfolgen – idealerweise als letzter Commit in einem PR.
> Die Commit-Nachricht sollte mit `chore(version)` beginnen, damit dieser nicht im Changelog auftaucht.
---
### 2. Mergen in `main`
Sobald `main` den Commit mit neuer `VERSION` enthält, wird automatisch:
- das `CHANGELOG.md` regeneriert und committed
- der neue Git-Tag erstellt (`v1.2.3`)
- ein Gitea Release mit genau diesem Changelog erzeugt
---
## 🛡️ Hinweis zu Tokens & Webhooks
Damit das Release auch korrekt weitere Workflows auslösen kann (z. B. über `on: release`), ist **ein Personal Access Token notwendig**.
### 🔐 Secret: `RELEASE_PUBLISH_TOKEN`
> Lege ein Repository-Secret mit diesem Namen an.
> Es sollte ein **Gitea Personal Access Token** mit folgenden Berechtigungen sein:
- `write:repo`
- `write:release`
- idealerweise: keine Ablaufzeit
Wird dieser Token **nicht** gesetzt, fällt der Workflow auf `ACTIONS_RUNTIME_TOKEN` zurück, aber:
- Release wird trotzdem erstellt
- **⚠️ andere Workflows, die auf `release.published` reagieren, könnten nicht getriggert werden**
---
## 🧪 Debugging-Tipps
- Stelle sicher, dass `VERSION` exakt **eine gültige neue semver-Version** enthält
- Achte auf den Commit-Log: Changelog-Commits sind mit `chore(changelog):` gekennzeichnet
- Verwende nur `main` als Trigger-Zweig
---
## 🧩 Erweiterung
In `upload-assets.yml` kannst du beliebige Build-Artefakte automatisch an das Release anhängen, sobald es veröffentlicht ist.
Dafür:
- liegt das Script `.gitea/scripts/get-release-id.sh`
- sowie `.gitea/scripts/upload-asset.sh` bereit
Mehr dazu in der Datei: `.gitea/workflows/upload-assets.yml`
---
## 🧘 Best Practice
- Changelog-Generierung nie manuell ausführen
- Nur `VERSION` ändern, um ein neues Release auszulösen
- Auf `CHANGELOG.md` nie direkt committen
- Release-Daten niemals per Hand in Gitea pflegen
📎 Alles wird versioniert, automatisiert und reproduzierbar erzeugt.
---
## 🧠 Commit-Gruppierung & Changelog-Erzeugung
Der Changelog wird auf Basis definierter **Commit-Gruppen** erzeugt.
Diese Regeln sind in `cliff.toml` unter `commit_parsers` konfiguriert.
| Prefix / Muster | Gruppe | Beschreibung |
|-------------------------------|---------------------------|--------------------------------------------------|
| `feat:` | 🚀 Features | Neue Funktionalität |
| `fix:` | 🐛 Bug Fixes | Fehlerbehebungen |
| `doc:` | 📚 Documentation | Änderungen an Doku, Readmes etc. |
| `perf:` | ⚡ Performance | Leistungsverbesserungen |
| `refactor:` | 🚜 Refactor | Reorganisation ohne Verhaltensänderung |
| `style:` | 🎨 Styling | Formatierung, Whitespaces, Code-Style |
| `test:` | 🧪 Testing | Neue oder angepasste Tests |
| `ci:` oder `chore:` (ohne Spezifizierung) | ⚙️ Miscellaneous Tasks | CI-Änderungen, Aufgaben, Wartung etc. |
| `chore(changelog)`, `chore(version)`, `chore(release): prepare for`, `chore(deps...)`, `chore(pr)`, `chore(pull)` | *(ignoriert)* | Diese Commits werden im Changelog **ausgelassen** |
| Commit-Body enthält `security` | 🛡️ Security | Sicherheitsrelevante Änderungen |
| `revert:` | ◀️ Revert | Rückgängig gemachte Commits |
| alles andere | 💼 Other | Fallback für nicht erkannte Formate |
### ✍️ Beispiel:
```bash
git commit -m "feat: add login endpoint"
git commit -m "fix: prevent crash on null input"
git commit -m "chore(version): bump to 1.2.3"
```
> Nur die ersten beiden erscheinen im Changelog – der dritte wird **automatisch übersprungen**.

View File

@@ -0,0 +1,21 @@
#!/usr/bin/env bash
set -euo pipefail
# Eingaben
TAG="$1"
TOKEN="${ACTIONS_RUNTIME_TOKEN:-<fallback_token>}"
REPO="${GITHUB_REPOSITORY:-owner/example}"
API="${GITHUB_API_URL:-https://gitea.example.tld/api/v1}"
OWNER=$(echo "$REPO" | cut -d/ -f1)
NAME=$(echo "$REPO" | cut -d/ -f2)
RESPONSE=$(curl -sf \
-H "Authorization: token $TOKEN" \
"$API/repos/$OWNER/$NAME/releases/tags/$TAG")
RELEASE_ID=$(echo "$RESPONSE" | jq -r '.id')
echo "Release-ID für $TAG ist: $RELEASE_ID"
# Für GitHub Actions als Umgebungsvariable
echo "GT_RELEASE_ID=$RELEASE_ID" >> "$GITHUB_ENV"

40
.gitea/scripts/upload-asset.sh Executable file
View File

@@ -0,0 +1,40 @@
#!/usr/bin/env bash
set -euo pipefail
# Eingabeparameter
FILE_PATH="$1" # z. B. ./dist/build.zip
CUSTOM_NAME="${2:-}" # optional: anderer Name unter dem das Asset gespeichert werden soll
RELEASE_ID="${GT_RELEASE_ID:-}" # aus Umgebung
# Validierung
if [[ -z "$RELEASE_ID" ]]; then
echo "❌ RELEASE_ID ist nicht gesetzt. Abbruch."
exit 1
fi
if [[ ! -f "$FILE_PATH" ]]; then
echo "❌ Datei '$FILE_PATH' existiert nicht. Abbruch."
exit 1
fi
# Default-Konfiguration
TOKEN="${ACTIONS_RUNTIME_TOKEN:-<fallback_token>}"
REPO="${GITHUB_REPOSITORY:-owner/example}"
API="${GITHUB_API_URL:-https://gitea.example.tld/api/v1}"
# Owner/Repo auflösen
OWNER=$(echo "$REPO" | cut -d/ -f1)
NAME=$(echo "$REPO" | cut -d/ -f2)
# Dateiname setzen
FILENAME="${CUSTOM_NAME:-$(basename "$FILE_PATH")}"
echo "🔼 Uploading '$FILE_PATH' as '$FILENAME' to release ID $RELEASE_ID"
# Upload
curl -sf -X POST \
-H "Authorization: token $TOKEN" \
-F "attachment=@$FILE_PATH" \
"$API/repos/$OWNER/$NAME/releases/$RELEASE_ID/assets?name=$FILENAME"
echo "✅ Upload abgeschlossen: $FILENAME"

View File

@@ -0,0 +1,211 @@
name: Auto Changelog & Release
on:
workflow_dispatch:
push:
branches:
- main
jobs:
detect-version-change:
runs-on: ubuntu-latest
outputs:
version_changed: ${{ steps.check.outputs.version_changed }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Check if VERSION file changed
id: check
run: |
echo "🔍 Vergleich mit github.event.before:"
echo "Before: ${{ github.event.before }}"
echo "After: ${{ github.sha }}"
echo "📄 Changed files between before and after:"
git diff --name-only ${{ github.event.before }} ${{ github.sha }} || echo "(diff failed)"
if git diff --name-only ${{ github.event.before }} ${{ github.sha }} | grep -q '^VERSION$'; then
echo "✅ VERSION file was changed between before and after"
echo "version_changed=true" >> $GITHUB_OUTPUT
else
echo "ℹ️ VERSION file not changed between before and after"
echo "version_changed=false" >> $GITHUB_OUTPUT
fi
changelog-only:
needs: detect-version-change
if: needs.detect-version-change.outputs.version_changed == 'false'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set Git Author
run: |
git config user.name "$CI_COMMIT_AUTHOR_NAME"
git config user.email "$CI_COMMIT_AUTHOR_EMAIL"
- name: Read CLIFF_VERSION from cliff.toml
id: cliff_version
run: |
echo "version=$(awk -F '=' '/^# CLIFF_VERSION=/ { gsub(/[" ]/, "", $2); print $2 }' cliff.toml)" >> $GITHUB_OUTPUT
- name: Restore git-cliff cache
id: restore-cliff
uses: https://git.0xmax42.io/actions/cache@v1
with:
key: cargo-cliff-${{ steps.cliff_version.outputs.version }}
paths: |
/root/.cargo/bin
- name: Install git-cliff
if: steps.restore-cliff.outputs.cache-hit != 'true'
run: |
cargo install git-cliff --version "${{ steps.cliff_version.outputs.version }}" --features gitea
- name: Generate unreleased changelog
run: git-cliff -c cliff.toml -o CHANGELOG.md
- name: Commit updated CHANGELOG.md
run: |
git add CHANGELOG.md
if git diff --cached --quiet; then
echo "No changes to commit"
else
git commit -m "chore(changelog): update unreleased changelog"
git push origin main
fi
release:
needs: detect-version-change
if: needs.detect-version-change.outputs.version_changed == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set Git Author
run: |
git config user.name "$CI_COMMIT_AUTHOR_NAME"
git config user.email "$CI_COMMIT_AUTHOR_EMAIL"
- name: Read VERSION
id: version
run: echo "value=$(cat VERSION)" >> $GITHUB_OUTPUT
- name: Read CLIFF_VERSION from cliff.toml
id: cliff_version
run: |
echo "version=$(awk -F '=' '/^# CLIFF_VERSION=/ { gsub(/[" ]/, "", $2); print $2 }' cliff.toml)" >> $GITHUB_OUTPUT
- name: Restore git-cliff cache
id: restore-cliff
uses: https://git.0xmax42.io/actions/cache@v1
with:
key: cargo-cliff-${{ steps.cliff_version.outputs.version }}
paths: |
/root/.cargo/bin
- name: Install git-cliff
if: steps.restore-cliff.outputs.cache-hit != 'true'
run: |
cargo install git-cliff --version "${{ steps.cliff_version.outputs.version }}" --features gitea
- name: Generate changelog for release and tag
id: generate-changelog
run: |
VERSION=${{ steps.version.outputs.value }}
git-cliff -c cliff.toml -t "v$VERSION" -o CHANGELOG.md
BODY=$(mktemp)
ESCAPED_VERSION=$(echo "$VERSION" | sed 's/\./\\./g')
awk -v ver="$ESCAPED_VERSION" '
$0 ~ "^## \\[" ver "\\]" {
print_flag=1
line = $0
sub(/^## /, "", line)
sub(/\\s*\\(.*\\)/, "", line) # entfernt z. B. "(...)" oder "(*)"
print line
next
}
$0 ~ "^## \\[" && $0 !~ "^## \\[" ver "\\]" {
print_flag=0
}
print_flag
' CHANGELOG.md > "$BODY"
echo "changelog_body_path=$BODY" >> $GITHUB_OUTPUT
- name: Commit updated CHANGELOG.md
run: |
git add CHANGELOG.md
if git diff --cached --quiet; then
echo "No changes to commit"
else
git commit -m "chore(changelog): update changelog for v${{ steps.version.outputs.value }}"
git push origin main
fi
- name: Create Git tag (if not exists)
run: |
VERSION=${{ steps.version.outputs.value }}
if git rev-parse "v$VERSION" >/dev/null 2>&1; then
echo "Tag v$VERSION already exists, skipping tag creation."
else
git tag -a "v$VERSION" -F "${{ steps.generate-changelog.outputs.changelog_body_path }}" --cleanup=verbatim
git push origin "v$VERSION"
fi
- name: Create Gitea release
env:
RELEASE_PUBLISH_TOKEN: ${{ secrets.RELEASE_PUBLISH_TOKEN }}
run: |
VERSION=${{ steps.version.outputs.value }}
BODY_FILE="${{ steps.generate-changelog.outputs.changelog_body_path }}"
OWNER=$(echo "$GITHUB_REPOSITORY" | cut -d/ -f1)
REPO=$(echo "$GITHUB_REPOSITORY" | cut -d/ -f2)
# Token-Auswahl
TOKEN="${RELEASE_PUBLISH_TOKEN:-$ACTIONS_RUNTIME_TOKEN}"
if [[ -z "${RELEASE_PUBLISH_TOKEN:-}" ]]; then
echo "::warning title=Limited Release Propagation::"
echo "RELEASE_PUBLISH_TOKEN is not set. Using ACTIONS_RUNTIME_TOKEN instead."
echo "⚠️ Release events may not trigger other workflows if created with the runtime token."
echo
fi
# Prüfe, ob der Release schon existiert
if curl -sf "$GITHUB_API_URL/repos/$OWNER/$REPO/releases/tags/v$VERSION" \
-H "Authorization: token $TOKEN" > /dev/null; then
echo "🔁 Release for tag v$VERSION already exists, skipping."
exit 0
fi
echo "🚀 Creating Gitea release for v$VERSION"
# Release-Beschreibung vorbereiten
RELEASE_BODY=$(tail -n +2 "$BODY_FILE" | jq -Rs .)
curl -X POST "$GITHUB_API_URL/repos/$OWNER/$REPO/releases" \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
-d @- <<EOF
{
"tag_name": "v$VERSION",
"target_commitish": "main",
"name": "Release v$VERSION",
"body": $RELEASE_BODY,
"draft": false,
"prerelease": false
}
EOF
echo "✅ Release for tag $VERSION created successfully."

View File

@@ -0,0 +1,47 @@
# ========================
# 📦 Upload Assets Template
# ========================
# Dieser Workflow wird automatisch ausgelöst, wenn ein Release
# in Gitea veröffentlicht wurde (event: release.published).
#
# Er dient dem Zweck, Release-Artefakte (wie z. B. Binary-Dateien,
# Changelogs oder Build-Zips) nachträglich mit dem Release zu verknüpfen.
#
# Voraussetzung: Zwei Shell-Skripte liegen im Projekt:
# - .gitea/scripts/get-release-id.sh → ermittelt Release-ID per Tag
# - .gitea/scripts/upload-asset.sh → lädt Datei als Release-Asset hoch
#
# --------------------------------------
name: Upload Assets
on:
release:
types: [published] # Nur bei Veröffentlichung eines Releases (nicht bei Entwürfen)
jobs:
upload-assets:
runs-on: ubuntu-latest
steps:
# 📥 Checke den Stand des Repos aus, exakt auf dem veröffentlichten Tag
# So ist garantiert, dass die Artefakte dem Zustand des Releases entsprechen.
- uses: actions/checkout@v4
with:
ref: ${{ github.event.release.tag_name }} # z. B. "v1.2.3"
fetch-depth: 0 # vollständige Git-Historie (für z. B. git-cliff, logs etc.)
# 🆔 Hole die Release-ID basierend auf dem Tag
# Die ID wird als Umgebungsvariable RELEASE_ID über $GITHUB_ENV verfügbar gemacht.
- name: Get Release ID from tag
run: .gitea/scripts/get-release-id.sh "${{ github.event.release.tag_name }}"
# 🔼 Upload eines Release-Assets
# Beispiel: Lade CHANGELOG.md als Datei mit abweichendem Namen "RELEASE-NOTES.md" hoch
#
# Du kannst beliebig viele Upload-Schritte hinzufügen oder in einer Schleife iterieren.
#
# Hinweis: RELEASE_ID wird automatisch verwendet, da get-release-id.sh sie exportiert.
#
# - name: Upload CHANGELOG.md as RELEASE-NOTES.md
# run: .gitea/scripts/upload-asset.sh ./CHANGELOG.md RELEASE-NOTES.md

2
.gitignore vendored
View File

@@ -5,3 +5,5 @@ logs/
cache/
out.py
output.txt
git_log_diff.txt
.gitea/COMMIT_GPT.md

17
.vscode/settings.json vendored
View File

@@ -10,4 +10,21 @@
"editor.defaultFormatter": "denoland.vscode-deno",
"editor.detectIndentation": false,
"editor.indentSize": "tabSize",
"exportall.config.folderListener": [
"/src/Interfaces",
"/src/Utils",
"/src/Types",
"/src/Errors"
],
"exportall.config.barrelName": "mod.ts",
"exportall.config.message": "deno-coverage-ignore-file",
"workbench.colorCustomizations": {
"activityBar.activeBackground": "#9ed8bc",
"activityBar.background": "#9ed8bc",
"activityBar.foreground": "#15202b",
"activityBar.inactiveForeground": "#15202b99",
"activityBarBadge.background": "#a177c8",
"activityBarBadge.foreground": "#15202b"
},
"peacock.color": "#7ac9a3",
}

43
CHANGELOG.md Normal file
View File

@@ -0,0 +1,43 @@
# Changelog
All notable changes to this project will be documented in this file.
## [0.1.0] - 2025-05-08
### 🚀 Features
- *(workflows)* Add automated changelog and release workflow - ([bbf78cf](https://git.0xmax42.io/maxp/http-kernel/commit/bbf78cff17be0cae651b8abf3e239103b26354bf))
- *(vscode)* Customize activity bar and peacock colors - ([56633cd](https://git.0xmax42.io/maxp/http-kernel/commit/56633cd95b37a8b2cfd8eb95982d07cd1f9b5126))
- *(workflows)* Add upload assets template for releases - ([7b6eb2b](https://git.0xmax42.io/maxp/http-kernel/commit/7b6eb2b57470198684a1dfa8b668351b8b9a91ae))
- *(config)* Add project metadata and test watch task - ([b009b57](https://git.0xmax42.io/maxp/http-kernel/commit/b009b5763d1824fc94fdc1e3d919fe2597158f84))
- *(http)* Add error handling for invalid HTTP methods - ([ba7aa79](https://git.0xmax42.io/maxp/http-kernel/commit/ba7aa79f56772213bf73b62bc6bf8810f3871127))
- *(http)* Enhance type safety and extend route context - ([a236fa7](https://git.0xmax42.io/maxp/http-kernel/commit/a236fa7c97ae49e6baf560d4ca92c6e83702b3ec))
### 🐛 Bug Fixes
- *(params)* Enforce non-undefined route parameter values - ([b0c6901](https://git.0xmax42.io/maxp/http-kernel/commit/b0c6901d7d272ec98b3d00ef2dd2848482892a25))
### 🚜 Refactor
- *(types)* Unify handler and middleware definitions - ([8235680](https://git.0xmax42.io/maxp/http-kernel/commit/8235680904c7f30f25b98b835d48376431108e91))
- *(core)* [**breaking**] Enhance HttpKernel pipeline and matcher system with full context and error handling - ([b7410b4](https://git.0xmax42.io/maxp/http-kernel/commit/b7410b44dd8720e46ee2871aa1727ce5039ebad4))
- *(httpkernel)* Introduce configuration object for flexibility - ([9059bdd](https://git.0xmax42.io/maxp/http-kernel/commit/9059bdda62081c8e775087cabe4c3406e42065a5))
### 📚 Documentation
- *(gitea)* Add release automation guide and scripts - ([5c03cdf](https://git.0xmax42.io/maxp/http-kernel/commit/5c03cdfb031adeb6ee5d0de0889477d6d1efafef))
- *(httpkernel)* Enhance class and interface documentation - ([6c4420d](https://git.0xmax42.io/maxp/http-kernel/commit/6c4420d32f8e7fe317f7c1b0b45de2dcf8565ef5))
### 🧪 Testing
- *(utils)* Rename and update import paths in test file - ([82a6877](https://git.0xmax42.io/maxp/http-kernel/commit/82a687748558f15c2023861a0cc3a33095c86731))
- *(utils)* Add unit tests for parseQuery function - ([94525fc](https://git.0xmax42.io/maxp/http-kernel/commit/94525fce5299f3417801f0152a475892e1edac30))
### ⚙️ Miscellaneous Tasks
- *(config)* Add default git-cliff configuration - ([661f83d](https://git.0xmax42.io/maxp/http-kernel/commit/661f83d1fd0101aa0d5d06b60f6eeb68efac6ceb))
- *(gitignore)* Add .gitea/COMMIT_GPT.md to ignored files - ([f083856](https://git.0xmax42.io/maxp/http-kernel/commit/f0838567b46822327fe739d8de099722e405dfa3))
- *(settings)* Add exportall configuration for barrel name and message - ([0990cac](https://git.0xmax42.io/maxp/http-kernel/commit/0990cacb225e1cbbbbb2a288501df7de9641294f))
- *(.gitignore)* Add git_log_diff.txt to ignore list - ([fd1c7f4](https://git.0xmax42.io/maxp/http-kernel/commit/fd1c7f4170ffffd55ab276090f8b90ee82b853fc))

1
VERSION Normal file
View File

@@ -0,0 +1 @@
0.1.0

104
cliff.toml Normal file
View File

@@ -0,0 +1,104 @@
# CLIFF_VERSION=2.8.0
# git-cliff ~ default configuration file
# https://git-cliff.org/docs/configuration
#
# Lines starting with "#" are comments.
# Configuration options are organized into tables and keys.
# See documentation for more information on available options.
[remote.gitea]
owner = "maxp"
repo = "http-kernel"
[changelog]
# postprocessors
postprocessors = [
{ pattern = '<GITEA_URL>', replace = "https://git.0xmax42.io" }, # replace gitea url
]
# template for the changelog header
header = """
# Changelog\n
All notable changes to this project will be documented in this file.\n
"""
# template for the changelog body
# https://keats.github.io/tera/docs/#introduction
body = """
{%- macro remote_url() -%}
<GITEA_URL>/{{ remote.gitea.owner }}/{{ remote.gitea.repo }}
{%- endmacro -%}
{% if version %}\
{% if previous.version %}\
## [{{ version | trim_start_matches(pat="v") }}]\
({{ self::remote_url() }}/compare/{{ previous.version }}..{{ version }}) - {{ timestamp | date(format="%Y-%m-%d") }}
{% else %}\
## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
{% endif %}\
{% else %}\
## [unreleased]
{% endif %}\
{% for group, commits in commits | group_by(attribute="group") %}
### {{ group | striptags | trim | upper_first }}
{% for commit in commits %}
- {% if commit.scope %}*({{ commit.scope }})* {% endif %}\
{% if commit.breaking %}[**breaking**] {% endif %}\
{{ commit.message | upper_first }} - \
([{{ commit.id | truncate(length=7, end="") }}]({{ self::remote_url() }}/commit/{{ commit.id }}))\
{% endfor %}
{% endfor %}\n
"""
# template for the changelog footer
footer = """
"""
# remove the leading and trailing s
trim = true
# render body even when there are no releases to process
# render_always = true
# output file path
# output = "test.md"
[git]
# parse the commits based on https://www.conventionalcommits.org
conventional_commits = true
# filter out the commits that are not conventional
filter_unconventional = true
# process each line of a commit as an individual commit
split_commits = false
# regex for preprocessing the commit messages
commit_preprocessors = [
# Replace issue numbers
#{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](<REPO>/issues/${2}))"},
# Check spelling of the commit with https://github.com/crate-ci/typos
# If the spelling is incorrect, it will be automatically fixed.
#{ pattern = '.*', replace_command = 'typos --write-changes -' },
]
# regex for parsing and grouping commits
commit_parsers = [
{ message = "^feat", group = "<!-- 0 -->🚀 Features" },
{ message = "^fix", group = "<!-- 1 -->🐛 Bug Fixes" },
{ message = "^doc", group = "<!-- 3 -->📚 Documentation" },
{ message = "^perf", group = "<!-- 4 -->⚡ Performance" },
{ message = "^refactor", group = "<!-- 2 -->🚜 Refactor" },
{ message = "^style", group = "<!-- 5 -->🎨 Styling" },
{ message = "^test", group = "<!-- 6 -->🧪 Testing" },
{ message = "^chore\\(changelog\\)", skip = true },
{ message = "^chore\\(version\\)", skip = true },
{ message = "^chore\\(release\\): prepare for", skip = true },
{ message = "^chore\\(deps.*\\)", skip = true },
{ message = "^chore\\(pr\\)", skip = true },
{ message = "^chore\\(pull\\)", skip = true },
{ message = "^chore|^ci", group = "<!-- 7 -->⚙️ Miscellaneous Tasks" },
{ body = ".*security", group = "<!-- 8 -->🛡️ Security" },
{ message = "^revert", group = "<!-- 9 -->◀️ Revert" },
{ message = ".*", group = "<!-- 10 -->💼 Other" },
]
# Regex to select git tags that represent releases.
tag_pattern = "v[0-9]+\\.[0-9]+\\.[0-9]+"
# filter out the commits that are not matched by commit parsers
filter_commits = false
# sort the tags topologically
topo_order = false
# sort the commits inside sections by oldest/newest order
sort_commits = "newest"

View File

@@ -1,8 +1,11 @@
{
"name": "@0xmax42/http-kernel",
"description": "A simple HTTP kernel for Deno",
"tasks": {
// "start": "deno run --allow-net --allow-env --unstable-kv --allow-read --allow-write --env-file src/main.ts -- --verbose",
// "watch": "deno run --watch --allow-net --allow-env --unstable-kv --allow-read --allow-write --env-file src/main.ts -- --verbose",
"test": "deno test --allow-net --allow-env --unstable-kv --allow-read --allow-write --coverage **/__tests__/*.test.ts"
"test": "deno test --allow-net --allow-env --unstable-kv --allow-read --allow-write --coverage **/__tests__/*.test.ts",
"test:watch": "deno test --watch --allow-net --allow-env --unstable-kv --allow-read --allow-write **/__tests__/*.test.ts"
},
"compilerOptions": {
"lib": [
@@ -24,6 +27,6 @@
"src/",
"main.ts"
]
},
}
//"importMap": "./import_map.json"
}

View File

@@ -0,0 +1,25 @@
/**
* Represents an error thrown when an incoming HTTP method
* is not among the recognized set of valid HTTP methods.
*
* This is typically used in routers or request dispatchers
* to enforce allowed methods and produce 405-like behavior.
*/
export class InvalidHttpMethodError extends Error {
/**
* The invalid method that triggered this error.
*/
public readonly method: unknown;
/**
* A fixed HTTP status code representing "Method Not Allowed".
*/
public readonly status: number = 405;
constructor(method: unknown) {
const label = typeof method === 'string' ? method : '[non-string]';
super(`Unsupported HTTP method: ${label}`);
this.name = 'InvalidHttpMethodError';
this.method = method;
}
}

3
src/Errors/mod.ts Normal file
View File

@@ -0,0 +1,3 @@
// deno-coverage-ignore-file
export { InvalidHttpMethodError } from './InvalidHttpMethodError.ts';

View File

@@ -1,56 +1,100 @@
import {
IContext,
IHandler,
IHttpKernel,
IHttpKernelConfig,
IInternalRoute,
IMiddleware,
IRouteBuilder,
IRouteBuilderFactory,
IRouteDefinition,
ResponseDecorator,
} from './Interfaces/mod.ts';
import {
DeepPartial,
Handler,
HTTP_404_NOT_FOUND,
HTTP_500_INTERNAL_SERVER_ERROR,
HttpStatusTextMap,
isHandler,
isMiddleware,
Middleware,
} from './Types/mod.ts';
import { RouteBuilder } from './RouteBuilder.ts';
import { createEmptyContext, normalizeError } from './Utils/mod.ts';
/**
* The central HTTP kernel responsible for managing route definitions,
* executing middleware chains, and dispatching HTTP requests to their handlers.
* The `HttpKernel` is the central routing engine that manages the full HTTP request lifecycle.
*
* This class supports a fluent API for route registration and allows the injection
* of custom response decorators and route builder factories for maximum flexibility and testability.
* It enables:
* - Dynamic and static route registration via a fluent API
* - Execution of typed middleware chains and final route handlers
* - Injection of response decorators and factory overrides
* - Fine-grained error handling via typed status-code-based handlers
*
* The kernel is designed with generics for flexible context typing, strong type safety,
* and a clear extension point for advanced routing, DI, or tracing logic.
*
* @typeParam TContext - The global context type used for all requests handled by this kernel.
*/
export class HttpKernel implements IHttpKernel {
/**
* The list of internally registered routes, each with method, matcher, middleware, and handler.
*/
private routes: IInternalRoute[] = [];
export class HttpKernel<TContext extends IContext = IContext>
implements IHttpKernel<TContext> {
private cfg: IHttpKernelConfig<TContext>;
/**
* Creates a new instance of the `HttpKernel`.
* The list of registered route definitions, including method, matcher,
* middleware pipeline, and final handler.
*/
private routes: IInternalRoute<TContext>[] = [];
/**
* Initializes the `HttpKernel` with optional configuration overrides.
*
* @param decorateResponse - An optional response decorator function that is applied to all responses
* after the middleware/handler pipeline. Defaults to identity (no modification).
* @param routeBuilderFactory - Optional factory for creating route builders. Defaults to using `RouteBuilder`.
* Default components such as the route builder factory, response decorator,
* and 404/500 error handlers can be replaced by injecting a partial config.
* Any omitted values fall back to sensible defaults.
*
* @param config - Partial kernel configuration. Missing fields are filled with defaults.
*/
public constructor(
private readonly decorateResponse: ResponseDecorator = (res) => res,
private readonly routeBuilderFactory: IRouteBuilderFactory =
RouteBuilder,
) {}
config?: DeepPartial<IHttpKernelConfig<TContext>>,
) {
this.cfg = {
decorateResponse: (res) => res,
routeBuilderFactory: RouteBuilder,
httpErrorHandlers: {
[HTTP_404_NOT_FOUND]: () =>
new Response(HttpStatusTextMap[HTTP_404_NOT_FOUND], {
status: HTTP_404_NOT_FOUND,
}),
[HTTP_500_INTERNAL_SERVER_ERROR]: () =>
new Response(
HttpStatusTextMap[HTTP_500_INTERNAL_SERVER_ERROR],
{
status: HTTP_500_INTERNAL_SERVER_ERROR,
},
),
...(config?.httpErrorHandlers ?? {}),
},
...config,
} as IHttpKernelConfig<TContext>;
/**
* @inheritdoc
*/
public route(definition: IRouteDefinition): IRouteBuilder {
return new this.routeBuilderFactory(
this.registerRoute.bind(this),
definition,
);
this.handle = this.handle.bind(this);
this.registerRoute = this.registerRoute.bind(this);
}
/**
* @inheritdoc
*/
public handle = async (request: Request): Promise<Response> => {
public route<_TContext extends IContext = TContext>(
definition: IRouteDefinition,
): IRouteBuilder<_TContext> {
return new this.cfg.routeBuilderFactory(
this.registerRoute,
definition,
) as IRouteBuilder<_TContext>;
}
/**
* @inheritdoc
*/
public async handle(request: Request): Promise<Response> {
const url = new URL(request.url);
const method = request.method.toUpperCase();
@@ -58,11 +102,12 @@ export class HttpKernel implements IHttpKernel {
if (route.method !== method) continue;
const match = route.matcher(url, request);
if (match) {
const ctx: IContext = {
const ctx: TContext = {
req: request,
params: match.params,
query: match.query,
state: {},
};
} as TContext;
return await this.executePipeline(
ctx,
route.middlewares,
@@ -71,51 +116,84 @@ export class HttpKernel implements IHttpKernel {
}
}
return new Response('Not Found', { status: 404 });
};
/**
* Registers a finalized route by pushing it into the internal route list.
*
* This method is typically called by the route builder after `.handle()` is invoked.
*
* @param route - The fully constructed route including matcher, middlewares, and handler.
*/
private registerRoute(route: IInternalRoute): void {
this.routes.push(route);
return this.cfg.httpErrorHandlers[HTTP_404_NOT_FOUND](
createEmptyContext<TContext>(request),
);
}
/**
* Executes the middleware pipeline and final handler for a given request context.
* Finalizes and registers a route within the kernel.
*
* This function recursively invokes middleware in the order they were registered,
* ending with the route's final handler. If a middleware returns a response directly
* without calling `next()`, the pipeline is short-circuited.
* This method is invoked internally by the route builder once
* `.handle()` is called. It appends the route to the internal list.
*
* The final response is passed through the `decorateResponse` function before being returned.
* @param route - A fully constructed internal route object.
*/
private registerRoute<_TContext extends IContext = TContext>(
route: IInternalRoute<_TContext>,
): void {
this.routes.push(route as unknown as IInternalRoute<TContext>);
}
/**
* Executes the middleware and handler pipeline for a matched route.
*
* @param ctx - The request context containing the request, parameters, and shared state.
* @param middleware - The ordered list of middleware to apply before the handler.
* @param handler - The final request handler to invoke at the end of the pipeline.
* @returns The final HTTP response after middleware and decoration.
* This function:
* - Enforces linear middleware execution with `next()` tracking
* - Validates middleware and handler types at runtime
* - Applies the optional response decorator post-processing
* - Handles all runtime errors via the configured 500 handler
*
* @param ctx - The active request context passed to middleware and handler.
* @param middleware - Ordered middleware functions for this route.
* @param handler - The final handler responsible for generating a response.
* @returns The final HTTP `Response`, possibly decorated.
*/
private async executePipeline(
ctx: IContext,
middleware: IMiddleware[],
handler: IHandler,
ctx: TContext,
middleware: Middleware<TContext>[],
handler: Handler<TContext>,
): Promise<Response> {
let i = -1;
const dispatch = async (index: number): Promise<Response> => {
if (index <= i) throw new Error('next() called multiple times');
i = index;
const fn: IMiddleware | IHandler = index < middleware.length
? middleware[index]
: handler;
if (!fn) return new Response('Internal error', { status: 500 });
return index < middleware.length
? await fn(ctx, () => dispatch(index + 1))
: await (fn as IHandler)(ctx);
const handleInternalError = (ctx: TContext, err?: unknown) =>
this.cfg.httpErrorHandlers[HTTP_500_INTERNAL_SERVER_ERROR](
ctx,
normalizeError(err),
);
let lastIndex = -1;
const dispatch = async (currentIndex: number): Promise<Response> => {
if (currentIndex <= lastIndex) {
throw new Error('Middleware called `next()` multiple times');
}
lastIndex = currentIndex;
const isWithinMiddleware = currentIndex < middleware.length;
const fn = isWithinMiddleware ? middleware[currentIndex] : handler;
if (isWithinMiddleware) {
if (!isMiddleware(fn)) {
throw new Error(
'Expected middleware function, but received invalid value',
);
}
return await fn(ctx, () => dispatch(currentIndex + 1));
}
if (!isHandler(fn)) {
throw new Error(
'Expected request handler, but received invalid value',
);
}
return await fn(ctx);
};
return this.decorateResponse(await dispatch(0));
try {
const response = await dispatch(0);
return this.cfg.decorateResponse(response, ctx);
} catch (e) {
return handleInternalError(ctx, e);
}
}
}

View File

@@ -1,11 +1,22 @@
import { Params, Query, State } from '../Types/mod.ts';
/**
* Represents the per-request context passed through the middleware pipeline and to the final handler.
* Represents the complete context for a single HTTP request,
* passed through the middleware pipeline and to the final route handler.
*
* This context object encapsulates the original HTTP request,
* the path parameters extracted from the matched route,
* and a mutable state object for sharing information across middlewares and handlers.
* This context object encapsulates all relevant runtime data for a request,
* including the original request, path parameters, query parameters,
* and a shared, mutable application state.
*
* @template TState Structured per-request state shared across middlewares and handlers.
* @template TParams Parsed URL path parameters, typically derived from route templates.
* @template TQuery Parsed query string parameters, preserving multi-value semantics.
*/
export interface IContext {
export interface IContext<
TState extends State = State,
TParams extends Params = Params,
TQuery extends Query = Query,
> {
/**
* The original HTTP request object as received by Deno.
* Contains all standard fields like headers, method, body, etc.
@@ -18,14 +29,25 @@ export interface IContext {
*
* These parameters are considered read-only and are set by the router.
*/
params: Record<string, string>;
params: TParams;
/**
* A shared, mutable object used to pass arbitrary data between middlewares and handlers.
* Query parameters extracted from the request URL's search string.
*
* Use this field to attach validated user info, auth state, logging context, etc.
* Values may occur multiple times (e.g., `?tag=ts&tag=deno`), and are therefore
* represented as either a string or an array of strings, depending on occurrence.
*
* Each key should be well-named to avoid collisions across layers.
* Use this field to access filters, flags, pagination info, or similar modifiers.
*/
state: Record<string, unknown>;
query: TQuery;
/**
* A typed, mutable object used to pass structured data between middlewares and handlers.
*
* This object is ideal for sharing validated input, user identity, trace information,
* or other contextual state throughout the request lifecycle.
*
* Type-safe access to fields is ensured by the generic `TState` type.
*/
state: TState;
}

View File

@@ -1,16 +0,0 @@
import { IContext } from './IContext.ts';
/**
* Represents a final request handler responsible for generating a response.
*
* The handler is the last step in the middleware pipeline and must return
* a valid HTTP `Response`. It has access to all data injected into the
* request context, including path parameters and any state added by middleware.
*/
export interface IHandler {
/**
* @param ctx - The complete request context, including parameters and middleware state.
* @returns A promise resolving to an HTTP `Response`.
*/
(ctx: IContext): Promise<Response>;
}

View File

@@ -0,0 +1,40 @@
import { IContext } from '../Interfaces/mod.ts';
import { HttpErrorHandler, validHttpErrorCodes } from '../Types/mod.ts';
/**
* A mapping of HTTP status codes to their corresponding error handlers.
*
* This interface defines required handlers for common critical status codes (404 and 500)
* and allows optional handlers for all other known error codes defined in `validHttpErrorCodes`.
*
* This hybrid approach ensures predictable handling for key failure cases,
* while remaining flexible for less common codes.
*
* @template TContext - The context type used in all error handlers.
*
* @example
* ```ts
* const errorHandlers: IHttpErrorHandlers = {
* 404: (ctx) => new Response("Not Found", { status: 404 }),
* 500: (ctx, err) => {
* console.error(err);
* return new Response("Internal Server Error", { status: 500 });
* },
* 429: (ctx) => new Response("Too Many Requests", { status: 429 }),
* };
* ```
*/
export interface IHttpErrorHandlers<TContext extends IContext = IContext>
extends
Partial<
Record<
Exclude<typeof validHttpErrorCodes[number], 404 | 500>,
HttpErrorHandler<TContext>
>
> {
/** Required error handler for HTTP 404 (Not Found). */
404: HttpErrorHandler<TContext>;
/** Required error handler for HTTP 500 (Internal Server Error). */
500: HttpErrorHandler<TContext>;
}

View File

@@ -1,33 +1,49 @@
import { IContext } from './IContext.ts';
import { IRouteBuilder } from './IRouteBuilder.ts';
import { IRouteDefinition } from './IRouteDefinition.ts';
/**
* Defines the core interface for the HTTP kernel, responsible for route registration,
* middleware orchestration, and request dispatching.
* The `IHttpKernel` interface defines the public API for a type-safe, middleware-driven HTTP dispatching system.
*
* Implementations of this interface are responsible for:
* - Registering routes with optional per-route context typing
* - Handling incoming requests by matching and dispatching to appropriate handlers
* - Managing the complete middleware pipeline and final response generation
*
* The kernel operates on a customizable `IContext` type to support strongly typed request parameters, state,
* and query values across the entire routing lifecycle.
*
* @typeParam TContext - The default context type used for all routes unless overridden per-route.
*/
export interface IHttpKernel {
export interface IHttpKernel<TContext extends IContext = IContext> {
/**
* Registers a new route with a static path pattern or a dynamic matcher.
* Registers a new HTTP route (static or dynamic) and returns a route builder for middleware/handler chaining.
*
* This method accepts both conventional route definitions (with path templates)
* and advanced matcher-based routes for flexible URL structures.
* This method supports contextual polymorphism via the `_TContext` type parameter, enabling fine-grained
* typing of route-specific `params`, `query`, and `state` values. The route is not registered until
* `.handle()` is called on the returned builder.
*
* Returns a route builder that allows chaining middleware and assigning a handler.
* @typeParam _TContext - An optional override for the context type specific to this route.
* Falls back to the global `TContext` of the kernel if omitted.
*
* @param definition - A static or dynamic route definition, including the HTTP method
* and either a path pattern or custom matcher function.
* @returns A builder interface to attach middleware and define the handler.
* @param definition - A route definition specifying the HTTP method and path or custom matcher.
* @returns A fluent builder interface to define middleware and attach a final handler.
*/
route(definition: IRouteDefinition): IRouteBuilder;
route<_TContext extends IContext = TContext>(
definition: IRouteDefinition,
): IRouteBuilder<_TContext>;
/**
* Handles an incoming HTTP request by matching it against registered routes,
* executing any associated middleware in order, and invoking the final route handler.
* Handles an incoming HTTP request and produces a `Response`.
*
* This method serves as the main entry point to integrate with `Deno.serve`.
* The kernel matches the request against all registered routes by method and matcher,
* constructs a typed context, and executes the middleware/handler pipeline.
* If no route matches, a 404 error handler is invoked.
*
* This method is designed to be passed directly to `Deno.serve()` or similar server frameworks.
*
* @param request - The incoming HTTP request object.
* @returns A promise resolving to the final HTTP response.
* @returns A `Promise` resolving to a complete HTTP response.
*/
handle(request: Request): Promise<Response>;
}

View File

@@ -0,0 +1,10 @@
import { ResponseDecorator } from '../Types/mod.ts';
import { IContext } from './IContext.ts';
import { IHttpErrorHandlers } from './IHttpErrorHandlers.ts';
import { IRouteBuilderFactory } from './IRouteBuilder.ts';
export interface IHttpKernelConfig<TContext extends IContext = IContext> {
decorateResponse: ResponseDecorator<TContext>;
routeBuilderFactory: IRouteBuilderFactory;
httpErrorHandlers: IHttpErrorHandlers<TContext>;
}

View File

@@ -1,5 +1,5 @@
import { IHandler } from './IHandler.ts';
import { IMiddleware } from './IMiddleware.ts';
import { Handler, HttpMethod, Middleware } from '../Types/mod.ts';
import { IContext, IRouteMatcher } from './mod.ts';
/**
* Represents an internally registered route within the HttpKernel.
@@ -7,12 +7,12 @@ import { IMiddleware } from './IMiddleware.ts';
* Contains all data required to match an incoming request and dispatch it
* through the associated middleware chain and final handler.
*/
export interface IInternalRoute {
export interface IInternalRoute<TContext extends IContext = IContext> {
/**
* The HTTP method (e.g. 'GET', 'POST') that this route responds to.
* The method should always be in uppercase.
*/
method: string;
method: HttpMethod;
/**
* A matcher function used to determine whether this route matches a given request.
@@ -25,18 +25,15 @@ export interface IInternalRoute {
* @param req - The original Request object.
* @returns An object with extracted path parameters, or `null` if not matched.
*/
matcher: (
url: URL,
req: Request,
) => null | { params: Record<string, string> };
matcher: IRouteMatcher;
/**
* An ordered list of middleware functions to be executed before the handler.
*/
middlewares: IMiddleware[];
middlewares: Middleware<TContext>[];
/**
* The final handler that generates the HTTP response after all middleware has run.
*/
handler: IHandler;
handler: Handler<TContext>;
}

View File

@@ -1,20 +0,0 @@
import { IContext } from './IContext.ts';
/**
* Represents a middleware function in the HTTP request pipeline.
*
* Middleware can perform tasks such as logging, authentication, validation,
* or response transformation. It receives the current request context and
* a `next()` function to delegate control to the next middleware or final handler.
*
* To stop the request pipeline, a middleware can return a `Response` directly
* without calling `next()`.
*/
export interface IMiddleware {
/**
* @param ctx - The request context, containing the request, path parameters, and shared state.
* @param next - A function that continues the middleware pipeline. Returns the final `Response`.
* @returns A promise resolving to an HTTP `Response`.
*/
(ctx: IContext, next: () => Promise<Response>): Promise<Response>;
}

View File

@@ -1,21 +1,21 @@
import { IHandler } from './IHandler.ts';
import { Handler, Middleware } from '../Types/mod.ts';
import { IInternalRoute } from './IInternalRoute.ts';
import { IMiddleware } from './IMiddleware.ts';
import { IRouteDefinition } from './IRouteDefinition.ts';
import { IContext } from './mod.ts';
export interface IRouteBuilderFactory {
export interface IRouteBuilderFactory<TContext extends IContext = IContext> {
new (
registerRoute: (route: IInternalRoute) => void,
registerRoute: (route: IInternalRoute<TContext>) => void,
def: IRouteDefinition,
mws?: IMiddleware[],
): IRouteBuilder;
mws?: Middleware<TContext>[],
): IRouteBuilder<TContext>;
}
/**
* Provides a fluent API to build a single route configuration by chaining
* middleware and setting the final request handler.
*/
export interface IRouteBuilder {
export interface IRouteBuilder<TContext extends IContext = IContext> {
/**
* Adds a middleware to the current route.
* Middleware will be executed in the order of registration.
@@ -23,7 +23,9 @@ export interface IRouteBuilder {
* @param mw - A middleware function.
* @returns The route builder for further chaining.
*/
middleware(mw: IMiddleware): IRouteBuilder;
middleware(
mw: Middleware<TContext>,
): IRouteBuilder<TContext>;
/**
* Sets the final request handler for the route.
@@ -31,5 +33,7 @@ export interface IRouteBuilder {
*
* @param handler - The function to execute when this route is matched.
*/
handle(handler: IHandler): void;
handle(
handler: Handler<TContext>,
): void;
}

View File

@@ -1,3 +1,4 @@
import { HttpMethod, isHttpMethod } from '../Types/mod.ts';
import { IRouteMatcher } from './IRouteMatcher.ts';
/**
@@ -10,7 +11,7 @@ export interface IStaticRouteDefinition {
/**
* The HTTP method this route should match (e.g. "GET", "POST").
*/
method: string;
method: HttpMethod;
/**
* A static path pattern for the route, which may include named parameters
@@ -29,7 +30,7 @@ export interface IDynamicRouteDefinition {
/**
* The HTTP method this route should match (e.g. "GET", "POST").
*/
method: string;
method: HttpMethod;
/**
* A custom matcher function that receives the parsed URL and raw request.
@@ -44,3 +45,47 @@ export interface IDynamicRouteDefinition {
* or a dynamic route with a custom matcher function for advanced matching logic.
*/
export type IRouteDefinition = IStaticRouteDefinition | IDynamicRouteDefinition;
/**
* Type guard to check whether a route definition is a valid static route definition.
*
* Ensures that the object:
* - has a `method` property of type `HttpMethod`
* - has a `path` property of type `string`
* - does NOT have a `matcher` function (to avoid ambiguous mixed types)
*/
export function isStaticRouteDefinition(
def: IRouteDefinition,
): def is IStaticRouteDefinition {
return (
def &&
typeof def === 'object' &&
'method' in def &&
isHttpMethod(def.method) &&
'path' in def &&
typeof (def as { path?: unknown }).path === 'string' &&
!('matcher' in def)
);
}
/**
* Type guard to check whether a route definition is a valid dynamic route definition.
*
* Ensures that the object:
* - has a `method` property of type `HttpMethod`
* - has a `matcher` property of type `function`
* - does NOT have a `path` property (to avoid ambiguous mixed types)
*/
export function isDynamicRouteDefinition(
def: IRouteDefinition,
): def is IDynamicRouteDefinition {
return (
def &&
typeof def === 'object' &&
'method' in def &&
isHttpMethod(def.method) &&
'matcher' in def &&
typeof (def as { matcher?: unknown }).matcher === 'function' &&
!('path' in def)
);
}

View File

@@ -0,0 +1,6 @@
import { Params, Query } from '../Types/mod.ts';
export interface IRouteMatch {
params?: Params;
query?: Query;
}

View File

@@ -1,4 +1,6 @@
import { Params } from '../Types/mod.ts';
import { IRouteDefinition } from './IRouteDefinition.ts';
import { IRouteMatch } from './IRouteMatch.ts';
/**
* Defines a route matcher function that evaluates whether a route applies to a given request.
@@ -14,7 +16,7 @@ export interface IRouteMatcher {
* @param req - The raw Request object (may be used for context or headers).
* @returns An object containing path parameters if matched, or `null` if not matched.
*/
(url: URL, req: Request): null | { params: Record<string, string> };
(url: URL, req: Request): null | IRouteMatch;
}
/**

View File

@@ -0,0 +1,43 @@
import { assertEquals } from 'https://deno.land/std@0.204.0/assert/mod.ts';
import {
IRouteDefinition,
isDynamicRouteDefinition,
isStaticRouteDefinition,
} from '../IRouteDefinition.ts';
Deno.test('isStaticRouteDefinition returns true for static route', () => {
const staticDef: IRouteDefinition = {
method: 'GET',
path: '/users/:id',
};
assertEquals(isStaticRouteDefinition(staticDef), true);
assertEquals(isDynamicRouteDefinition(staticDef), false);
});
Deno.test('isDynamicRouteDefinition returns true for dynamic route', () => {
const dynamicDef: IRouteDefinition = {
method: 'POST',
matcher: (_url, _req) => ({ params: {} }),
};
assertEquals(isDynamicRouteDefinition(dynamicDef), true);
assertEquals(isStaticRouteDefinition(dynamicDef), false);
});
Deno.test('isStaticRouteDefinition returns false for invalid object', () => {
const invalidDef = {
method: 'GET',
} as unknown as IRouteDefinition;
assertEquals(isStaticRouteDefinition(invalidDef), false);
});
Deno.test('isDynamicRouteDefinition returns false for object with no matcher', () => {
const def = {
method: 'DELETE',
path: '/something',
};
assertEquals(isDynamicRouteDefinition(def as IRouteDefinition), false);
});

View File

@@ -1,13 +1,19 @@
// deno-coverage-ignore-file
export type { IContext } from './IContext.ts';
export type { IMiddleware } from './IMiddleware.ts';
export type { IHandler } from './IHandler.ts';
export type { IHttpErrorHandlers } from './IHttpErrorHandlers.ts';
export type { IHttpKernel } from './IHttpKernel.ts';
export type { IHttpKernelConfig } from './IHttpKernelConfig.ts';
export type { IInternalRoute } from './IInternalRoute.ts';
export type { IRouteBuilder, IRouteBuilderFactory } from './IRouteBuilder.ts';
export {
isDynamicRouteDefinition,
isStaticRouteDefinition,
} from './IRouteDefinition.ts';
export type {
IDynamicRouteDefinition,
IRouteDefinition,
IStaticRouteDefinition,
} from './IRouteDefinition.ts';
export type { IInternalRoute } from './IInternalRoute.ts';
export type { IRouteMatcher } from './IRouteMatcher.ts';
export type { ResponseDecorator } from './ResponseDecorator.ts';
export type { IRouteMatch } from './IRouteMatch.ts';
export type { IRouteMatcher, IRouteMatcherFactory } from './IRouteMatcher.ts';

View File

@@ -1,12 +1,7 @@
import { IRouteMatcherFactory } from './Interfaces/IRouteMatcher.ts';
import {
IHandler,
IInternalRoute,
IMiddleware,
IRouteBuilder,
IRouteDefinition,
} from './Interfaces/mod.ts';
import { createRouteMatcher } from './Utils.ts';
import { IContext, IRouteBuilder, IRouteDefinition } from './Interfaces/mod.ts';
import { Handler, Middleware, RegisterRoute } from './Types/mod.ts';
import { createRouteMatcher } from './Utils/createRouteMatcher.ts';
/**
* Provides a fluent builder interface for defining a single route,
@@ -14,7 +9,8 @@ import { createRouteMatcher } from './Utils.ts';
*
* This builder is stateless and immutable; each chained call returns a new instance.
*/
export class RouteBuilder implements IRouteBuilder {
export class RouteBuilder<TContext extends IContext = IContext>
implements IRouteBuilder<TContext> {
/**
* Constructs a new instance of the route builder.
*
@@ -23,9 +19,9 @@ export class RouteBuilder implements IRouteBuilder {
* @param mws - The list of middleware functions collected so far (default: empty).
*/
constructor(
private readonly registerRoute: (route: IInternalRoute) => void,
private readonly registerRoute: RegisterRoute<TContext>,
private readonly def: IRouteDefinition,
private readonly mws: IMiddleware[] = [],
private readonly mws: Middleware<TContext>[] = [],
private readonly matcherFactory: IRouteMatcherFactory =
createRouteMatcher,
) {}
@@ -39,11 +35,14 @@ export class RouteBuilder implements IRouteBuilder {
* @param mw - A middleware function to be executed before the handler.
* @returns A new `RouteBuilder` instance for continued chaining.
*/
middleware(mw: IMiddleware): IRouteBuilder {
return new RouteBuilder(this.registerRoute, this.def, [
...this.mws,
mw,
]);
middleware(
mw: Middleware<TContext>,
): IRouteBuilder<TContext> {
return new RouteBuilder<TContext>(
this.registerRoute,
this.def,
[...this.mws, mw],
);
}
/**
@@ -54,13 +53,15 @@ export class RouteBuilder implements IRouteBuilder {
*
* @param handler - The final request handler for this route.
*/
handle(handler: IHandler): void {
handle(
handler: Handler<TContext>,
): void {
const matcher = this.matcherFactory(this.def);
this.registerRoute({
method: this.def.method.toUpperCase(),
method: this.def.method,
matcher,
middlewares: this.mws,
handler,
handler: handler,
});
}
}

4
src/Types/DeepPartial.ts Normal file
View File

@@ -0,0 +1,4 @@
export type DeepPartial<T> = {
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]>
: T[P];
};

57
src/Types/Handler.ts Normal file
View File

@@ -0,0 +1,57 @@
import { IContext } from '../Interfaces/mod.ts';
/**
* Represents a final request handler responsible for producing an HTTP response.
*
* The handler is the terminal stage of the middleware pipeline and is responsible
* for processing the incoming request and generating the final `Response`.
*
* It receives the fully-typed request context, which includes the original request,
* parsed route parameters, query parameters, and any shared state populated by prior middleware.
*
* @template TContext The specific context type for this handler, including typed `state`, `params`, and `query`.
*/
type Handler<TContext extends IContext = IContext> = (
ctx: TContext,
) => Promise<Response>;
/**
* Represents a handler function with an associated name.
*
* This is useful for debugging, logging, or when you need to reference
* the handler by name in your application.
*
* @template TContext The specific context type for this handler, including typed `state`, `params`, and `query`.
*/
type NamedHandler<TContext extends IContext = IContext> =
& Handler<TContext>
& { name?: string };
export type { NamedHandler as Handler };
/**
* Type guard to determine whether a given value is a valid `IHandler` function.
*
* This function checks whether the input is a function and whether it returns
* a `Promise<Response>` when called. Due to TypeScript's structural typing and
* the lack of runtime type information, only minimal runtime validation is possible.
*
* @param value - The value to test.
* @returns `true` if the value is a function that appears to conform to `IHandler`.
*
* @example
* ```ts
* const candidate = async (ctx: IContext) => new Response("ok");
* if (isHandler(candidate)) {
* // candidate is now typed as IHandler<IContext>
* }
* ```
*/
export function isHandler<TContext extends IContext = IContext>(
value: unknown,
): value is Handler<TContext> {
return (
typeof value === 'function' &&
value.length === 1 // ctx
);
}

View File

@@ -0,0 +1,28 @@
import { IContext } from '../Interfaces/mod.ts';
/**
* Defines a handler function for errors that occur during the execution
* of middleware or route handlers within the HTTP kernel.
*
* This function receives both the request context and the thrown error,
* and is responsible for producing an appropriate HTTP `Response`.
*
* Typical use cases include:
* - Mapping known error types to specific HTTP status codes.
* - Generating structured error responses (e.g. JSON error payloads).
* - Logging errors centrally with request metadata.
*
* The handler may return the response synchronously or asynchronously.
*
* @template TContext - The specific request context type, allowing typed access to route parameters,
* query parameters, and per-request state when formatting error responses.
*
* @param context - The active request context at the time the error occurred.
* @param error - The exception or error that was thrown during request processing.
*
* @returns A `Response` object or a `Promise` resolving to one, to be sent to the client.
*/
export type HttpErrorHandler<TContext extends IContext = IContext> = (
context?: Partial<TContext>,
error?: Error,
) => Promise<Response> | Response;

52
src/Types/HttpMethod.ts Normal file
View File

@@ -0,0 +1,52 @@
/**
* A constant list of all supported HTTP methods according to RFC 7231 and RFC 5789.
*
* This array serves both as a runtime value list for validation
* and as the basis for deriving the `HttpMethod` union type.
*
* Note: The list is immutable and should not be modified at runtime.
*/
export const validHttpMethods = [
'GET',
'POST',
'PUT',
'PATCH',
'DELETE',
'HEAD',
'OPTIONS',
] as const;
/**
* A union type representing all valid HTTP methods recognized by this application.
*
* This type is derived directly from the `validHttpMethods` constant,
* ensuring type safety and consistency between type system and runtime checks.
*
* Example:
* ```ts
* const method: HttpMethod = 'POST'; // ✅ valid
* const method: HttpMethod = 'FOO'; // ❌ Type error
* ```
*/
export type HttpMethod = typeof validHttpMethods[number];
/**
* Type guard to verify whether a given value is a valid HTTP method.
*
* This function checks both the type and content of the value
* and is suitable for runtime validation of inputs (e.g., from HTTP requests).
*
* Example:
* ```ts
* if (isHttpMethod(input)) {
* // input is now typed as HttpMethod
* }
* ```
*
* @param value - The value to test (typically a string from a request).
* @returns `true` if the value is a valid `HttpMethod`, otherwise `false`.
*/
export function isHttpMethod(value: unknown): value is HttpMethod {
return typeof value === 'string' &&
validHttpMethods.includes(value as HttpMethod);
}

189
src/Types/HttpStatusCode.ts Normal file
View File

@@ -0,0 +1,189 @@
// Informational responses
/** Indicates that the request was received and the client can continue. */
export const HTTP_100_CONTINUE = 100;
/** The server is switching protocols as requested by the client. */
export const HTTP_101_SWITCHING_PROTOCOLS = 101;
/** The server has received and is processing the request, but no response is available yet. */
export const HTTP_102_PROCESSING = 102;
// Successful responses
/** The request has succeeded. */
export const HTTP_200_OK = 200;
/** The request has succeeded and a new resource has been created as a result. */
export const HTTP_201_CREATED = 201;
/** The request has been accepted for processing, but the processing is not complete. */
export const HTTP_202_ACCEPTED = 202;
/** The server has successfully fulfilled the request and there is no content to send. */
export const HTTP_204_NO_CONTENT = 204;
// Redirection messages
/** The resource has been moved permanently to a new URI. */
export const HTTP_301_MOVED_PERMANENTLY = 301;
/** The resource resides temporarily under a different URI. */
export const HTTP_302_FOUND = 302;
/** Indicates that the resource has not been modified since the last request. */
export const HTTP_304_NOT_MODIFIED = 304;
// Client error responses
/** The server could not understand the request due to invalid syntax. */
export const HTTP_400_BAD_REQUEST = 400;
/** The request requires user authentication. */
export const HTTP_401_UNAUTHORIZED = 401;
/** The server understood the request but refuses to authorize it. */
export const HTTP_403_FORBIDDEN = 403;
/** The server cannot find the requested resource. */
export const HTTP_404_NOT_FOUND = 404;
/** The request method is known by the server but is not supported by the target resource. */
export const HTTP_405_METHOD_NOT_ALLOWED = 405;
/** The request could not be completed due to a conflict with the current state of the resource. */
export const HTTP_409_CONFLICT = 409;
/** The server understands the content type but was unable to process the contained instructions. */
export const HTTP_422_UNPROCESSABLE_ENTITY = 422;
/** The user has sent too many requests in a given amount of time. */
export const HTTP_429_TOO_MANY_REQUESTS = 429;
// Server error responses
/** The server encountered an unexpected condition that prevented it from fulfilling the request. */
export const HTTP_500_INTERNAL_SERVER_ERROR = 500;
/** The server does not support the functionality required to fulfill the request. */
export const HTTP_501_NOT_IMPLEMENTED = 501;
/** The server, while acting as a gateway or proxy, received an invalid response from the upstream server. */
export const HTTP_502_BAD_GATEWAY = 502;
/** The server is not ready to handle the request, often due to maintenance or overload. */
export const HTTP_503_SERVICE_UNAVAILABLE = 503;
/** The server is acting as a gateway and cannot get a response in time. */
export const HTTP_504_GATEWAY_TIMEOUT = 504;
/**
* A constant list of supported HTTP status codes used by this application.
*
* These constants are grouped by category and used to construct the union type `HttpStatusCode`.
*/
export const validHttpStatusCodes = [
// Informational
HTTP_100_CONTINUE,
HTTP_101_SWITCHING_PROTOCOLS,
HTTP_102_PROCESSING,
// Successful
HTTP_200_OK,
HTTP_201_CREATED,
HTTP_202_ACCEPTED,
HTTP_204_NO_CONTENT,
// Redirection
HTTP_301_MOVED_PERMANENTLY,
HTTP_302_FOUND,
HTTP_304_NOT_MODIFIED,
// Client Errors
HTTP_400_BAD_REQUEST,
HTTP_401_UNAUTHORIZED,
HTTP_403_FORBIDDEN,
HTTP_404_NOT_FOUND,
HTTP_405_METHOD_NOT_ALLOWED,
HTTP_409_CONFLICT,
HTTP_422_UNPROCESSABLE_ENTITY,
HTTP_429_TOO_MANY_REQUESTS,
// Server Errors
HTTP_500_INTERNAL_SERVER_ERROR,
HTTP_501_NOT_IMPLEMENTED,
HTTP_502_BAD_GATEWAY,
HTTP_503_SERVICE_UNAVAILABLE,
HTTP_504_GATEWAY_TIMEOUT,
] as const;
/**
* A constant list of HTTP error codes that are commonly used in the application.
*/
export const validHttpErrorCodes = [
// Client Errors
HTTP_400_BAD_REQUEST,
HTTP_401_UNAUTHORIZED,
HTTP_403_FORBIDDEN,
HTTP_404_NOT_FOUND,
HTTP_405_METHOD_NOT_ALLOWED,
HTTP_409_CONFLICT,
HTTP_422_UNPROCESSABLE_ENTITY,
HTTP_429_TOO_MANY_REQUESTS,
// Server Errors
HTTP_500_INTERNAL_SERVER_ERROR,
HTTP_501_NOT_IMPLEMENTED,
HTTP_502_BAD_GATEWAY,
HTTP_503_SERVICE_UNAVAILABLE,
HTTP_504_GATEWAY_TIMEOUT,
] as const;
/**
* Maps each supported HTTP status code to its standard status message.
*
* Useful for logging, diagnostics, or building custom error responses.
*/
export const HttpStatusTextMap: Record<
typeof validHttpStatusCodes[number],
string
> = {
[HTTP_100_CONTINUE]: 'Continue',
[HTTP_101_SWITCHING_PROTOCOLS]: 'Switching Protocols',
[HTTP_102_PROCESSING]: 'Processing',
[HTTP_200_OK]: 'OK',
[HTTP_201_CREATED]: 'Created',
[HTTP_202_ACCEPTED]: 'Accepted',
[HTTP_204_NO_CONTENT]: 'No Content',
[HTTP_301_MOVED_PERMANENTLY]: 'Moved Permanently',
[HTTP_302_FOUND]: 'Found',
[HTTP_304_NOT_MODIFIED]: 'Not Modified',
[HTTP_400_BAD_REQUEST]: 'Bad Request',
[HTTP_401_UNAUTHORIZED]: 'Unauthorized',
[HTTP_403_FORBIDDEN]: 'Forbidden',
[HTTP_404_NOT_FOUND]: 'Not Found',
[HTTP_405_METHOD_NOT_ALLOWED]: 'Method Not Allowed',
[HTTP_409_CONFLICT]: 'Conflict',
[HTTP_422_UNPROCESSABLE_ENTITY]: 'Unprocessable Entity',
[HTTP_429_TOO_MANY_REQUESTS]: 'Too Many Requests',
[HTTP_500_INTERNAL_SERVER_ERROR]: 'Internal Server Error',
[HTTP_501_NOT_IMPLEMENTED]: 'Not Implemented',
[HTTP_502_BAD_GATEWAY]: 'Bad Gateway',
[HTTP_503_SERVICE_UNAVAILABLE]: 'Service Unavailable',
[HTTP_504_GATEWAY_TIMEOUT]: 'Gateway Timeout',
};
/**
* A union type representing commonly used HTTP status codes.
*
* This type ensures consistency between runtime and type-level status code handling.
*
* Example:
* ```ts
* const status: HttpStatusCode = 404; // ✅ valid
* const status: HttpStatusCode = 418; // ❌ Type error (unless added to list)
* ```
*/
export type HttpStatusCode = typeof validHttpStatusCodes[number];
/**
* Type guard to check whether a given value is a valid HTTP status code.
*
* This is useful for validating numeric values received from external input,
* ensuring they conform to known HTTP semantics.
*
* Example:
* ```ts
* if (isHttpStatusCode(value)) {
* // value is now typed as HttpStatusCode
* }
* ```
*
* @param value - The numeric value to check.
* @returns `true` if the value is a recognized HTTP status code, otherwise `false`.
*/
export function isHttpStatusCode(value: unknown): value is HttpStatusCode {
return typeof value === 'number' &&
validHttpStatusCodes.includes(value as HttpStatusCode);
}

51
src/Types/Middleware.ts Normal file
View File

@@ -0,0 +1,51 @@
import { IContext } from '../Interfaces/IContext.ts';
/**
* Represents a middleware function in the HTTP request pipeline.
*
* Middleware is a core mechanism to intercept, observe, or modify the request lifecycle.
* It can be used for tasks such as logging, authentication, input validation,
* metrics collection, or response transformation.
*
* Each middleware receives a fully-typed request context and a `next()` function
* to invoke the next stage of the pipeline. Middleware may choose to short-circuit
* the pipeline by returning a `Response` early.
*
* @template TContext The specific context type for this middleware, including state, params, and query information.
*/
type Middleware<TContext extends IContext = IContext> = (
ctx: TContext,
next: () => Promise<Response>,
) => Promise<Response>;
/**
* Represents a middleware function with an associated name.
*
* This is useful for debugging, logging, or when you need to reference
* the middleware by name in your application.
*
* @template TContext The specific context type for this middleware, including state, params, and query information.
*/
type NamedMiddleware<TContext extends IContext = IContext> =
& Middleware<TContext>
& { name?: string };
export type { NamedMiddleware as Middleware };
/**
* Type guard to verify whether a given value is a valid `IMiddleware` function.
*
* This guard checks whether the input is a function that accepts exactly two arguments.
* Note: This is a structural check and cannot fully guarantee the semantics of a middleware.
*
* @param value - The value to test.
* @returns `true` if the value is structurally a valid middleware function.
*/
export function isMiddleware<TContext extends IContext = IContext>(
value: unknown,
): value is Middleware<TContext> {
return (
typeof value === 'function' &&
value.length === 2 // ctx, next
);
}

10
src/Types/Params.ts Normal file
View File

@@ -0,0 +1,10 @@
/**
* Represents route parameters parsed from dynamic segments in the URL path.
*
* This type is typically derived from route definitions with placeholders,
* such as `/users/:id`, which would yield `{ id: "123" }`.
*
* All values are strings and should be considered read-only, as they are
* extracted by the router and should not be modified by application code.
*/
export type Params = Record<string, string>;

12
src/Types/Query.ts Normal file
View File

@@ -0,0 +1,12 @@
/**
* Represents the parsed query parameters from the request URL.
*
* Query parameters originate from the URL search string (e.g. `?filter=active&tags=ts&tags=deno`)
* and may contain single or multiple values per key.
*
* All values are expressed as strings or arrays of strings, depending on how often
* the key occurs. This structure preserves the raw semantics of the query.
*
* For normalized single-value access, prefer custom DTOs or wrapper utilities.
*/
export type Query = Record<string, string | string[]>;

View File

@@ -0,0 +1,16 @@
import { IContext } from '../Interfaces/IContext.ts';
import { IInternalRoute } from '../Interfaces/mod.ts';
/**
* A type alias for the internal route registration function used by the `HttpKernel`.
*
* This function accepts a fully constructed internal route, including method, matcher,
* middleware chain, and final handler, and registers it for dispatching.
*
* Typically passed into `RouteBuilder` instances to enable fluent API chaining.
*
* @template TContext The context type associated with the route being registered.
*/
export type RegisterRoute<TContext extends IContext = IContext> = (
route: IInternalRoute<TContext>,
) => void;

View File

@@ -1,3 +1,5 @@
import { IContext } from '../Interfaces/mod.ts';
/**
* A function that modifies or enriches an outgoing HTTP response before it is returned to the client.
*
@@ -22,4 +24,7 @@
* };
* ```
*/
export type ResponseDecorator = (res: Response) => Response;
export type ResponseDecorator<TContext extends IContext = IContext> = (
res: Response,
ctx: TContext,
) => Response;

9
src/Types/State.ts Normal file
View File

@@ -0,0 +1,9 @@
/**
* Represents the per-request state object shared across the middleware pipeline.
*
* This type defines the base structure for custom state definitions,
* which can be extended with concrete fields like user data, request metadata, etc.
*
* Custom `TState` types must extend this base to ensure compatibility.
*/
export type State = Record<string, unknown>;

View File

@@ -0,0 +1,40 @@
import { assertEquals } from 'https://deno.land/std/assert/mod.ts';
import { isHttpMethod, validHttpMethods } from '../HttpMethod.ts';
Deno.test('isHttpMethod: returns true for all valid methods', () => {
for (const method of validHttpMethods) {
const result = isHttpMethod(method);
assertEquals(result, true, `Expected "${method}" to be valid`);
}
});
Deno.test('isHttpMethod: returns false for lowercase or unknown strings', () => {
const invalid = [
'get',
'post',
'FETCH',
'TRACE',
'CONNECT',
'INVALID',
'',
' ',
];
for (const method of invalid) {
const result = isHttpMethod(method);
assertEquals(result, false, `Expected "${method}" to be invalid`);
}
});
Deno.test('isHttpMethod: returns false for non-string inputs', () => {
const invalidInputs = [null, undefined, 123, {}, [], true, Symbol('GET')];
for (const input of invalidInputs) {
const result = isHttpMethod(input);
assertEquals(
result,
false,
`Expected non-string input to be invalid: ${String(input)}`,
);
}
});

View File

@@ -0,0 +1,35 @@
// src/Types/__tests__/HttpStatusCode.test.ts
import { assertEquals } from 'https://deno.land/std@0.204.0/assert/mod.ts';
import { isHttpStatusCode, validHttpStatusCodes } from '../HttpStatusCode.ts';
Deno.test('isHttpStatusCode: returns true for all valid status codes', () => {
for (const code of validHttpStatusCodes) {
assertEquals(
isHttpStatusCode(code),
true,
`Expected ${code} to be valid`,
);
}
});
Deno.test('isHttpStatusCode: returns false for invalid status codes', () => {
const invalidInputs = [99, 600, 1234, -1, 0, 999];
for (const val of invalidInputs) {
assertEquals(
isHttpStatusCode(val),
false,
`Expected ${val} to be invalid`,
);
}
});
Deno.test('isHttpStatusCode: returns false for non-numeric values', () => {
const invalid = ['200', null, undefined, {}, [], true];
for (const val of invalid) {
assertEquals(
isHttpStatusCode(val),
false,
`Expected ${val} to be invalid`,
);
}
});

45
src/Types/mod.ts Normal file
View File

@@ -0,0 +1,45 @@
// deno-coverage-ignore-file
export type { DeepPartial } from './DeepPartial.ts';
export { isHandler } from './Handler.ts';
export type { Handler } from './Handler.ts';
export type { HttpErrorHandler } from './HttpErrorHandler.ts';
export { isHttpMethod, validHttpMethods } from './HttpMethod.ts';
export type { HttpMethod } from './HttpMethod.ts';
export {
HTTP_100_CONTINUE,
HTTP_101_SWITCHING_PROTOCOLS,
HTTP_102_PROCESSING,
HTTP_200_OK,
HTTP_201_CREATED,
HTTP_202_ACCEPTED,
HTTP_204_NO_CONTENT,
HTTP_301_MOVED_PERMANENTLY,
HTTP_302_FOUND,
HTTP_304_NOT_MODIFIED,
HTTP_400_BAD_REQUEST,
HTTP_401_UNAUTHORIZED,
HTTP_403_FORBIDDEN,
HTTP_404_NOT_FOUND,
HTTP_405_METHOD_NOT_ALLOWED,
HTTP_409_CONFLICT,
HTTP_422_UNPROCESSABLE_ENTITY,
HTTP_429_TOO_MANY_REQUESTS,
HTTP_500_INTERNAL_SERVER_ERROR,
HTTP_501_NOT_IMPLEMENTED,
HTTP_502_BAD_GATEWAY,
HTTP_503_SERVICE_UNAVAILABLE,
HTTP_504_GATEWAY_TIMEOUT,
HttpStatusTextMap,
isHttpStatusCode,
validHttpErrorCodes,
validHttpStatusCodes,
} from './HttpStatusCode.ts';
export type { HttpStatusCode } from './HttpStatusCode.ts';
export { isMiddleware } from './Middleware.ts';
export type { Middleware } from './Middleware.ts';
export type { Params } from './Params.ts';
export type { Query } from './Query.ts';
export type { RegisterRoute } from './RegisterRoute.ts';
export type { ResponseDecorator } from './ResponseDecorator.ts';
export type { State } from './State.ts';

View File

@@ -1,61 +0,0 @@
import { IRouteDefinition, IRouteMatcher } from './Interfaces/mod.ts';
/**
* Creates a matcher function from a given route definition.
*
* This utility supports both static path-based route definitions (e.g. `/users/:id`)
* and custom matcher functions for dynamic routing scenarios.
*
* ### Static Path Example
* For a definition like:
* ```ts
* { method: "GET", path: "/users/:id" }
* ```
* the returned matcher function will:
* - match requests to `/users/123`
* - extract `{ id: "123" }` as `params`
*
* ### Dynamic Matcher Example
* If the `IRouteDefinition` includes a `matcher` function, it will be used as-is.
*
* @param def - The route definition to convert into a matcher function.
* Can be static (`path`) or dynamic (`matcher`).
*
* @returns A matcher function that receives a `URL` and `Request` and returns:
* - `{ params: Record<string, string> }` if the route matches
* - `null` if the route does not match the request
*
* @example
* ```ts
* const matcher = createRouteMatcher({ method: "GET", path: "/repo/:owner/:name" });
* const result = matcher(new URL("http://localhost/repo/foo/bar"), req);
* // result: { params: { owner: "foo", name: "bar" } }
* ```
*/
export function createRouteMatcher(
def: IRouteDefinition,
): IRouteMatcher {
if ('matcher' in def) {
return def.matcher;
} else {
const pattern = def.path;
const keys: string[] = [];
const regex = new RegExp(
'^' +
pattern.replace(/:[^\/]+/g, (m) => {
keys.push(m.substring(1));
return '([^/]+)';
}) +
'$',
);
return (url: URL) => {
const match = url.pathname.match(regex);
if (!match) return null;
const params: Record<string, string> = {};
for (let i = 0; i < keys.length; i++) {
params[keys[i]] = decodeURIComponent(match[i + 1]);
}
return { params };
};
}
}

View File

@@ -0,0 +1,28 @@
import { assertEquals } from 'https://deno.land/std/assert/mod.ts';
import { createEmptyContext } from '../createEmptyContext.ts';
import { IContext } from '../../Interfaces/mod.ts';
Deno.test('createEmptyContext: returns default-initialized context', () => {
const request = new Request('http://localhost');
const ctx = createEmptyContext(request);
assertEquals(ctx.req, request);
assertEquals(ctx.params, {});
assertEquals(ctx.query, {});
assertEquals(ctx.state, {});
});
Deno.test('createEmptyContext: preserves generic type compatibility', () => {
interface MyContext
extends
IContext<{ userId: string }, { id: string }, { verbose: string }> {}
const req = new Request('http://localhost');
const ctx = createEmptyContext<MyContext>(req);
// All properties exist and are empty
assertEquals(ctx.params, {} as MyContext['params']);
assertEquals(ctx.query, {} as MyContext['query']);
assertEquals(ctx.state, {} as MyContext['state']);
assertEquals(ctx.req, req);
});

View File

@@ -0,0 +1,118 @@
import {
assert,
assertEquals,
assertStrictEquals,
} from 'https://deno.land/std/assert/mod.ts';
import { IRouteDefinition } from '../../Interfaces/mod.ts';
import { createRouteMatcher } from '../../mod.ts';
// Dummy request
const dummyRequest = new Request('http://localhost');
Deno.test('createRouteMatcher: static route matches and extracts params', () => {
const def: IRouteDefinition = { method: 'GET', path: '/users/:id' };
const matcher = createRouteMatcher(def);
const result = matcher(new URL('http://localhost/users/42'), dummyRequest);
assert(result);
assertEquals(result.params, { id: '42' });
});
Deno.test('createRouteMatcher: static route with multiple params', () => {
const def: IRouteDefinition = { method: 'GET', path: '/repo/:owner/:name' };
const matcher = createRouteMatcher(def);
const result = matcher(
new URL('http://localhost/repo/max/wiki'),
dummyRequest,
);
assert(result);
assertEquals(result.params, { owner: 'max', name: 'wiki' });
});
Deno.test('createRouteMatcher: static route does not match wrong path', () => {
const def: IRouteDefinition = { method: 'GET', path: '/users/:id' };
const matcher = createRouteMatcher(def);
const result = matcher(new URL('http://localhost/posts/42'), dummyRequest);
assertStrictEquals(result, null);
});
Deno.test('createRouteMatcher: uses custom matcher if provided', () => {
const def: IRouteDefinition = {
method: 'GET',
matcher: (url) => url.pathname === '/ping' ? { params: {} } : null,
};
const matcher = createRouteMatcher(def);
const result = matcher(new URL('http://localhost/ping'), dummyRequest);
assert(result);
assertEquals(result.params, {});
});
Deno.test('createRouteMatcher: extracts single query param', () => {
const def: IRouteDefinition = { method: 'GET', path: '/search' };
const matcher = createRouteMatcher(def);
const url = new URL('http://localhost/search?q=deno');
const result = matcher(url, dummyRequest);
assert(result);
assertEquals(result.params, {}); // no path params
assertEquals(result.query, { q: 'deno' }); // single key → string
});
Deno.test('createRouteMatcher: duplicate query keys become array', () => {
const def: IRouteDefinition = { method: 'GET', path: '/tags' };
const matcher = createRouteMatcher(def);
const url = new URL('http://localhost/tags?tag=js&tag=ts&tag=deno');
const result = matcher(url, dummyRequest);
assert(result);
assertEquals(result.params, {});
assertEquals(result.query, { tag: ['js', 'ts', 'deno'] }); // multi → string[]
});
Deno.test('createRouteMatcher: mix of single and duplicate keys', () => {
const def: IRouteDefinition = { method: 'GET', path: '/filter/:type' };
const matcher = createRouteMatcher(def);
const url = new URL('http://localhost/filter/repo?lang=ts&lang=js&page=2');
const result = matcher(url, dummyRequest);
assert(result);
assertEquals(result.params, { type: 'repo' });
assertEquals(result.query, {
lang: ['ts', 'js'], // duplicated
page: '2', // single
});
});
Deno.test('createRouteMatcher: no query parameters returns empty object', () => {
const def: IRouteDefinition = { method: 'GET', path: '/info' };
const matcher = createRouteMatcher(def);
const url = new URL('http://localhost/info');
const result = matcher(url, dummyRequest);
assert(result);
assertEquals(result.params, {});
assertEquals(result.query, {}); // empty
});
Deno.test('createRouteMatcher: retains array order of duplicate keys', () => {
const def: IRouteDefinition = { method: 'GET', path: '/order' };
const matcher = createRouteMatcher(def);
const url = new URL(
'http://localhost/order?item=first&item=second&item=third',
);
const result = matcher(url, dummyRequest);
assert(result);
assertEquals(result.query?.item, ['first', 'second', 'third']);
});

View File

@@ -0,0 +1,35 @@
import {
assertEquals,
assertInstanceOf,
} from 'https://deno.land/std/assert/mod.ts';
import { normalizeError } from '../normalizeError.ts';
Deno.test('normalizeError: preserves Error instances', () => {
const original = new Error('original');
const result = normalizeError(original);
assertInstanceOf(result, Error);
assertEquals(result, original);
});
Deno.test('normalizeError: converts string to Error', () => {
const result = normalizeError('something went wrong');
assertInstanceOf(result, Error);
assertEquals(result.message, 'something went wrong');
});
Deno.test('normalizeError: converts number to Error', () => {
const result = normalizeError(404);
assertInstanceOf(result, Error);
assertEquals(result.message, '404');
});
Deno.test('normalizeError: converts plain object to Error', () => {
const input = { error: true, msg: 'Invalid' };
const result = normalizeError(input);
assertInstanceOf(result, Error);
assertEquals(result.message, JSON.stringify(input));
});

View File

@@ -0,0 +1,30 @@
import { IContext } from '../Interfaces/mod.ts';
import { Params, Query, State } from '../Types/mod.ts';
/**
* Creates an empty request context suitable for fallback handlers (e.g., 404 or 500 errors).
*
* This function is primarily intended for cases where no route matched, but a context-compatible
* object is still needed to invoke a generic error handler. All context fields are initialized
* to their default empty values (`{}` for params, query, and state).
*
* @template TContext - The expected context type, typically extending `IContext`.
* @param req - The original HTTP request object from `Deno.serve()`.
* @returns A minimal context object compatible with `TContext`.
*
* @example
* ```ts
* const ctx = createEmptyContext<MyContext>(request);
* return httpErrorHandlers[404](ctx);
* ```
*/
export function createEmptyContext<TContext extends IContext = IContext>(
req: Request,
): TContext {
return {
req,
params: {} as Params,
query: {} as Query,
state: {} as State,
} as TContext;
}

View File

@@ -0,0 +1,54 @@
// createRouteMatcher.ts
import {
IRouteDefinition,
IRouteMatch,
IRouteMatcher,
isDynamicRouteDefinition,
} from '../Interfaces/mod.ts';
import { Params, Query } from '../Types/mod.ts';
/**
* Transforms a route definition into a matcher using Deno's URLPattern API.
*
* @param def - Static path pattern or custom matcher.
* @returns IRouteMatcher that returns `{ params, query }` or `null`.
*/
export function createRouteMatcher(
def: IRouteDefinition,
): IRouteMatcher {
// 1. Allow users to provide their own matcher
if (isDynamicRouteDefinition(def)) {
return def.matcher;
}
// 2. Build URLPattern; supports :id, *wildcards, regex groups, etc.
const pattern = new URLPattern({ pathname: def.path });
// 3. The actual matcher closure
return (url: URL): IRouteMatch | null => {
const result = pattern.exec(url);
// 3a. Path did not match
if (!result) return null;
// 3b. Extract route params
const params: Params = {};
for (const [key, value] of Object.entries(result.pathname.groups)) {
if (value) {
params[key] = value;
}
}
// 3c. Extract query parameters – keep duplicates as arrays
const query: Query = {};
for (const key of url.searchParams.keys()) {
const values = url.searchParams.getAll(key); // → string[]
query[key] = values.length === 1
? values[0] // single → "foo"
: values; // multi → ["foo","bar"]
}
return { params, query };
};
}

5
src/Utils/mod.ts Normal file
View File

@@ -0,0 +1,5 @@
// deno-coverage-ignore-file
export { createEmptyContext } from './createEmptyContext.ts';
export { createRouteMatcher } from './createRouteMatcher.ts';
export { normalizeError } from './normalizeError.ts';

View File

@@ -0,0 +1,32 @@
/**
* Normalizes any thrown value to a proper `Error` instance.
*
* This is useful when handling unknown thrown values that may be:
* - strings (e.g. `throw "oops"`)
* - numbers (e.g. `throw 404`)
* - objects that are not instances of `Error`
*
* Ensures that downstream error handling logic always receives a consistent `Error` object.
*
* @param unknownError - Any value that might have been thrown.
* @returns A valid `Error` instance wrapping the original input.
*
* @example
* ```ts
* try {
* throw "something went wrong";
* } catch (e) {
* const err = normalizeError(e);
* console.error(err.message); // "something went wrong"
* }
* ```
*/
export function normalizeError(unknownError: unknown): Error {
return unknownError instanceof Error
? unknownError
: new Error(
typeof unknownError === 'string'
? unknownError
: JSON.stringify(unknownError),
);
}

View File

@@ -1,7 +1,4 @@
import {
assertEquals,
assertRejects,
} from 'https://deno.land/std@0.204.0/assert/mod.ts';
import { assertEquals } from 'https://deno.land/std@0.204.0/assert/mod.ts';
import { HttpKernel } from '../HttpKernel.ts';
import { IRouteDefinition } from '../Interfaces/mod.ts';
@@ -11,7 +8,7 @@ Deno.test('HttpKernel: matches static route and executes handler', async () => {
const def: IRouteDefinition = { method: 'GET', path: '/hello' };
let called = false;
kernel.route(def).handle(() => {
kernel.route(def).handle((_ctx) => {
called = true;
return Promise.resolve(new Response('OK', { status: 200 }));
});
@@ -31,7 +28,7 @@ Deno.test('HttpKernel: supports dynamic matcher', async () => {
matcher: (url) => url.pathname === '/dyn' ? { params: {} } : null,
};
kernel.route(def).handle(() =>
kernel.route(def).handle((_ctx) =>
Promise.resolve(new Response('Dyn', { status: 200 }))
);
@@ -45,15 +42,15 @@ Deno.test('HttpKernel: calls middleware in order and passes to handler', async (
const calls: string[] = [];
kernel.route({ method: 'GET', path: '/test' })
.middleware(async (ctx, next) => {
.middleware(async (_ctx, next) => {
calls.push('mw1');
return await next();
})
.middleware(async (ctx, next) => {
.middleware(async (_ctx, next) => {
calls.push('mw2');
return await next();
})
.handle(() => {
.handle((_ctx) => {
calls.push('handler');
return Promise.resolve(new Response('done'));
});
@@ -70,15 +67,15 @@ Deno.test('HttpKernel: middleware short-circuits pipeline', async () => {
const calls: string[] = [];
kernel.route({ method: 'GET', path: '/stop' })
.middleware(() => {
.middleware((_ctx, _next) => {
calls.push('mw1');
return Promise.resolve(new Response('blocked', { status: 403 }));
})
.middleware(() => {
.middleware((_ctx, _next) => {
calls.push('mw2');
return Promise.resolve(new Response('should-not-call'));
})
.handle(() => {
.handle((_ctx) => {
calls.push('handler');
return Promise.resolve(new Response('ok'));
});
@@ -91,6 +88,32 @@ Deno.test('HttpKernel: middleware short-circuits pipeline', async () => {
assertEquals(calls, ['mw1']);
});
Deno.test('HttpKernel: invalid middleware or handler signature triggers 500', async () => {
const kernel = new HttpKernel();
// Middleware with wrong signature (missing ctx, next)
kernel.route({ method: 'GET', path: '/bad-mw' })
// @ts-expect-error invalid middleware
.middleware(() => new Response('invalid'))
.handle((_ctx) => Promise.resolve(new Response('ok')));
const res1 = await kernel.handle(new Request('http://localhost/bad-mw'));
assertEquals(res1.status, 500);
assertEquals(await res1.text(), 'Internal Server Error');
// Handler with wrong signature (no ctx)
kernel.route({ method: 'GET', path: '/bad-handler' })
.middleware(async (_ctx, next) => await next())
// @ts-expect-error invalid handler
.handle(() => new Response('invalid'));
const res2 = await kernel.handle(
new Request('http://localhost/bad-handler'),
);
assertEquals(res2.status, 500);
assertEquals(await res2.text(), 'Internal Server Error');
});
Deno.test('HttpKernel: 404 for unmatched route', async () => {
const kernel = new HttpKernel();
const res = await kernel.handle(new Request('http://localhost/nothing'));
@@ -113,18 +136,16 @@ Deno.test('HttpKernel: throws on next() called twice', async () => {
const kernel = new HttpKernel();
kernel.route({ method: 'GET', path: '/bad' })
.middleware(async (ctx, next) => {
.middleware(async (_ctx, next) => {
await next();
await next(); // ❌
return new Response('should never reach');
})
.handle(() => Promise.resolve(new Response('OK')));
.handle((_ctx) => Promise.resolve(new Response('OK')));
await assertRejects(
() => kernel.handle(new Request('http://localhost/bad')),
Error,
'next() called multiple times',
);
const res = await kernel.handle(new Request('http://localhost/bad'));
assertEquals(res.status, 500);
assertEquals(await res.text(), 'Internal Server Error');
});
Deno.test('HttpKernel: handler throws → error propagates', async () => {
@@ -135,11 +156,9 @@ Deno.test('HttpKernel: handler throws → error propagates', async () => {
throw new Error('fail!');
});
await assertRejects(
() => kernel.handle(new Request('http://localhost/throw')),
Error,
'fail!',
);
const res = await kernel.handle(new Request('http://localhost/throw'));
assertEquals(res.status, 500);
assertEquals(await res.text(), 'Internal Server Error');
});
Deno.test('HttpKernel: returns 500 if no handler or middleware defined', async () => {
@@ -157,5 +176,5 @@ Deno.test('HttpKernel: returns 500 if no handler or middleware defined', async (
const res = await kernel.handle(new Request('http://localhost/fail'));
assertEquals(res.status, 500);
assertEquals(await res.text(), 'Internal error');
assertEquals(await res.text(), 'Internal Server Error');
});

View File

@@ -4,18 +4,15 @@ import {
assertNotEquals,
assertThrows,
} from 'https://deno.land/std@0.204.0/assert/mod.ts';
import {
IHandler,
IInternalRoute,
IMiddleware,
IRouteDefinition,
} from '../Interfaces/mod.ts';
import { IInternalRoute, IRouteDefinition } from '../Interfaces/mod.ts';
import { RouteBuilder } from '../mod.ts';
import { Handler, Middleware } from '../Types/mod.ts';
// Dummy objects
const dummyHandler: IHandler = async () => new Response('ok');
const dummyMiddleware: IMiddleware = async (_, next) => await next();
const dummyDef: IRouteDefinition = { method: 'get', path: '/hello' };
// deno-lint-ignore require-await
const dummyHandler: Handler = async () => new Response('ok');
const dummyMiddleware: Middleware = async (_, next) => await next();
const dummyDef: IRouteDefinition = { method: 'GET', path: '/hello' };
const dummyMatcher = () => ({ params: {} });
Deno.test('middleware: single middleware is registered correctly', () => {
@@ -39,8 +36,8 @@ Deno.test('middleware: middleware is chained immutably', () => {
});
Deno.test('middleware: preserves order of middleware', () => {
const mw1: IMiddleware = async (_, next) => await next();
const mw2: IMiddleware = async (_, next) => await next();
const mw1: Middleware = async (_, next) => await next();
const mw2: Middleware = async (_, next) => await next();
let result: IInternalRoute | null = null as IInternalRoute | null;
@@ -57,7 +54,7 @@ Deno.test('middleware: preserves order of middleware', () => {
Deno.test('handle: uppercases method', () => {
let result: IInternalRoute | null = null as IInternalRoute | null;
new RouteBuilder((r) => result = r, { method: 'post', path: '/x' })
new RouteBuilder((r) => result = r, { method: 'POST', path: '/x' })
.handle(dummyHandler);
assertEquals(result?.method, 'POST');
@@ -74,8 +71,18 @@ Deno.test('handle: works with no middleware', async () => {
const request = new Request('http://localhost');
const res1 = await route?.handler({ req: request, params: {}, state: {} });
const res2 = await dummyHandler({ req: request, params: {}, state: {} });
const res1 = await route?.handler({
req: request,
params: {},
state: {},
query: {},
});
const res2 = await dummyHandler({
req: request,
params: {},
state: {},
query: {},
});
assertEquals(res1?.status, res2?.status);
assertEquals(await res1?.text(), await res2?.text());

View File

@@ -1,54 +0,0 @@
import {
assert,
assertEquals,
assertStrictEquals,
} from 'https://deno.land/std/assert/mod.ts';
import { IRouteDefinition } from '../Interfaces/mod.ts';
import { createRouteMatcher } from '../mod.ts';
// Dummy request
const dummyRequest = new Request('http://localhost');
Deno.test('createRouteMatcher: static route matches and extracts params', () => {
const def: IRouteDefinition = { method: 'GET', path: '/users/:id' };
const matcher = createRouteMatcher(def);
const result = matcher(new URL('http://localhost/users/42'), dummyRequest);
assert(result);
assertEquals(result.params, { id: '42' });
});
Deno.test('createRouteMatcher: static route with multiple params', () => {
const def: IRouteDefinition = { method: 'GET', path: '/repo/:owner/:name' };
const matcher = createRouteMatcher(def);
const result = matcher(
new URL('http://localhost/repo/max/wiki'),
dummyRequest,
);
assert(result);
assertEquals(result.params, { owner: 'max', name: 'wiki' });
});
Deno.test('createRouteMatcher: static route does not match wrong path', () => {
const def: IRouteDefinition = { method: 'GET', path: '/users/:id' };
const matcher = createRouteMatcher(def);
const result = matcher(new URL('http://localhost/posts/42'), dummyRequest);
assertStrictEquals(result, null);
});
Deno.test('createRouteMatcher: uses custom matcher if provided', () => {
const def: IRouteDefinition = {
method: 'GET',
matcher: (url) => url.pathname === '/ping' ? { params: {} } : null,
};
const matcher = createRouteMatcher(def);
const result = matcher(new URL('http://localhost/ping'), dummyRequest);
assert(result);
assertEquals(result.params, {});
});

View File

@@ -1,4 +1,4 @@
// deno-coverage-ignore-file
export { HttpKernel } from './HttpKernel.ts';
export { RouteBuilder } from './RouteBuilder.ts';
export { createRouteMatcher } from './Utils.ts';
export { createRouteMatcher } from './Utils/createRouteMatcher.ts';