branch(main): Initial commit.
This commit is contained in:
commit
3e104ded8d
62 changed files with 2612 additions and 0 deletions
32
.forgejo/workflows/publish.yml
Normal file
32
.forgejo/workflows/publish.yml
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
name: Build and publish
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ["v*"]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "25"
|
||||
- run: ./gradlew test
|
||||
publish:
|
||||
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||
needs: verify
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "25"
|
||||
- run: ./gradlew publish
|
||||
env:
|
||||
ORG_GRADLE_PROJECT_forgejoUsername: ${{ secrets.FORGEJO_USERNAME }}
|
||||
ORG_GRADLE_PROJECT_forgejoPassword: ${{ secrets.FORGEJO_TOKEN }}
|
||||
12
.gitattributes
vendored
Normal file
12
.gitattributes
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
#
|
||||
# https://help.github.com/articles/dealing-with-line-endings/
|
||||
#
|
||||
# Linux start script should use lf
|
||||
/gradlew text eol=lf
|
||||
|
||||
# These are Windows script files and should use crlf
|
||||
*.bat text eol=crlf
|
||||
|
||||
# Binary files should be left untouched
|
||||
*.jar binary
|
||||
|
||||
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
# Ignore Gradle project-specific cache directory
|
||||
.gradle
|
||||
|
||||
# Ignore Gradle build output directory
|
||||
build
|
||||
|
||||
# Ignore Kotlin plugin data
|
||||
.kotlin
|
||||
25
activitypub/build.gradle.kts
Normal file
25
activitypub/build.gradle.kts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
plugins {
|
||||
alias(libs.plugins.kotlin.jvm)
|
||||
`java-library`
|
||||
`maven-publish`
|
||||
id("com.ncorti.ktfmt.gradle") version "0.27.0"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(project(":lib"))
|
||||
implementation(libs.jackson.databind)
|
||||
}
|
||||
|
||||
java {
|
||||
toolchain { languageVersion = JavaLanguageVersion.of(25) }
|
||||
withSourcesJar()
|
||||
}
|
||||
|
||||
publishing {
|
||||
publications {
|
||||
create<MavenPublication>("maven") {
|
||||
from(components["java"])
|
||||
artifactId = "nestj-activitypub"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
package rip.crit.nestj.activitypub
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import java.time.Instant
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import rip.crit.nestj.http.request.Request
|
||||
import rip.crit.nestj.http.response.HTTPResponse
|
||||
import rip.crit.nestj.http.response.Response
|
||||
import rip.crit.nestj.http.server.Server
|
||||
|
||||
const val ACTIVITYPUB_CONTENT_TYPE = "application/activity+json"
|
||||
|
||||
data class ActivityPubActor(val handle: String, val name: String, val summary: String = "")
|
||||
|
||||
fun interface ActivityDelivery {
|
||||
fun deliver(inbox: String, activity: JsonNode)
|
||||
}
|
||||
|
||||
/**
|
||||
* A small ActivityPub HTTP surface. It serves actor documents and ordered collections, accepts JSON
|
||||
* activities at local inboxes, and keeps remote delivery behind an explicit adapter.
|
||||
*/
|
||||
class ActivityPubService(
|
||||
private val baseUrl: String,
|
||||
private val delivery: ActivityDelivery = ActivityDelivery { _, _ -> },
|
||||
private val mapper: ObjectMapper = ObjectMapper(),
|
||||
) {
|
||||
private val actors = ConcurrentHashMap<String, ActivityPubActor>()
|
||||
private val inboxes = ConcurrentHashMap<String, MutableList<JsonNode>>()
|
||||
private val outboxes = ConcurrentHashMap<String, MutableList<JsonNode>>()
|
||||
|
||||
fun register(actor: ActivityPubActor): ActivityPubService = apply {
|
||||
require(actor.handle.matches(Regex("[A-Za-z0-9_.-]+"))) { "Invalid ActivityPub handle" }
|
||||
actors[actor.handle] = actor
|
||||
}
|
||||
|
||||
fun install(server: Server): ActivityPubService = apply {
|
||||
server.get("/ap/actors/:handle") { request -> actorResponse(request) }
|
||||
server.get("/ap/actors/:handle/outbox") { request -> collectionResponse(request, outboxes) }
|
||||
server.get("/ap/actors/:handle/inbox") { request -> collectionResponse(request, inboxes) }
|
||||
server.post("/ap/actors/:handle/inbox") { request -> receive(request) }
|
||||
}
|
||||
|
||||
fun publish(
|
||||
handle: String,
|
||||
type: String,
|
||||
obj: Any,
|
||||
recipients: Iterable<String> = emptyList(),
|
||||
): JsonNode {
|
||||
require(actors.containsKey(handle)) { "Unknown actor: $handle" }
|
||||
val activity =
|
||||
mapper.createObjectNode().apply {
|
||||
put("@context", "https://www.w3.org/ns/activitystreams")
|
||||
put("id", "$baseUrl/ap/activities/${java.util.UUID.randomUUID()}")
|
||||
put("type", type)
|
||||
put("actor", actorUrl(handle))
|
||||
set<JsonNode>("object", mapper.valueToTree(obj))
|
||||
put("published", Instant.now().toString())
|
||||
}
|
||||
outboxes.computeIfAbsent(handle) { mutableListOf() }.add(0, activity)
|
||||
recipients.forEach { delivery.deliver(it, activity) }
|
||||
return activity
|
||||
}
|
||||
|
||||
private fun actorResponse(request: Request): Response {
|
||||
val handle = request.getPathParams()["handle"] ?: return notFound(request)
|
||||
val actor = actors[handle] ?: return notFound(request)
|
||||
val body =
|
||||
mapper.createObjectNode().apply {
|
||||
put("@context", "https://www.w3.org/ns/activitystreams")
|
||||
put("id", actorUrl(handle))
|
||||
put("type", "Person")
|
||||
put("preferredUsername", actor.handle)
|
||||
put("name", actor.name)
|
||||
put("summary", actor.summary)
|
||||
put("inbox", "${actorUrl(handle)}/inbox")
|
||||
put("outbox", "${actorUrl(handle)}/outbox")
|
||||
}
|
||||
return json(request, body, 200)
|
||||
}
|
||||
|
||||
private fun collectionResponse(request: Request, source: Map<String, List<JsonNode>>): Response {
|
||||
val handle = request.getPathParams()["handle"] ?: return notFound(request)
|
||||
if (!actors.containsKey(handle)) return notFound(request)
|
||||
val entries = source[handle].orEmpty()
|
||||
return json(
|
||||
request,
|
||||
mapper.createObjectNode().apply {
|
||||
put("@context", "https://www.w3.org/ns/activitystreams")
|
||||
put("type", "OrderedCollection")
|
||||
put("totalItems", entries.size)
|
||||
set<JsonNode>("orderedItems", mapper.valueToTree(entries))
|
||||
},
|
||||
200,
|
||||
)
|
||||
}
|
||||
|
||||
private fun receive(request: Request): Response {
|
||||
val handle = request.getPathParams()["handle"] ?: return notFound(request)
|
||||
if (!actors.containsKey(handle)) return notFound(request)
|
||||
if (
|
||||
!request.getHeaders().entries.any {
|
||||
it.key.equals("content-type", true) && it.value.contains("json")
|
||||
}
|
||||
)
|
||||
return HTTPResponse("Expected ActivityPub JSON", 415, request)
|
||||
val activity =
|
||||
try {
|
||||
mapper.readTree(request.getBody())
|
||||
} catch (_: Exception) {
|
||||
return HTTPResponse("Invalid JSON", 400, request)
|
||||
}
|
||||
if (activity.path("type").asText().isBlank())
|
||||
return HTTPResponse("Activity type is required", 400, request)
|
||||
inboxes.computeIfAbsent(handle) { mutableListOf() }.add(0, activity)
|
||||
return HTTPResponse(ByteArray(0), 202, request)
|
||||
}
|
||||
|
||||
private fun actorUrl(handle: String) = "$baseUrl/ap/actors/$handle"
|
||||
|
||||
private fun json(request: Request, value: JsonNode, status: Int) =
|
||||
HTTPResponse(
|
||||
mapper.writeValueAsBytes(value),
|
||||
hashMapOf("Content-Type" to ACTIVITYPUB_CONTENT_TYPE),
|
||||
status,
|
||||
ACTIVITYPUB_CONTENT_TYPE,
|
||||
request,
|
||||
)
|
||||
|
||||
private fun notFound(request: Request) = HTTPResponse("Actor not found", 404, request)
|
||||
}
|
||||
22
build.gradle.kts
Normal file
22
build.gradle.kts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import org.gradle.api.artifacts.repositories.PasswordCredentials
|
||||
import org.gradle.api.publish.PublishingExtension
|
||||
|
||||
allprojects {
|
||||
group = providers.gradleProperty("group").orElse("rip.crit.nestj").get()
|
||||
version = providers.gradleProperty("version").orElse("0.1.0-SNAPSHOT").get()
|
||||
repositories { mavenCentral() }
|
||||
}
|
||||
|
||||
subprojects {
|
||||
plugins.withId("maven-publish") {
|
||||
extensions.configure<PublishingExtension> {
|
||||
repositories {
|
||||
maven {
|
||||
name = "Forgejo"
|
||||
url = uri("https://ai.crit.rip/api/packages/jprims/maven")
|
||||
credentials(PasswordCredentials::class)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
14
bun.lock
Normal file
14
bun.lock
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"ws": "^8.21.3",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="],
|
||||
}
|
||||
}
|
||||
34
client/typescript/.gitignore
vendored
Normal file
34
client/typescript/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# dependencies (bun install)
|
||||
node_modules
|
||||
|
||||
# output
|
||||
out
|
||||
dist
|
||||
*.tgz
|
||||
|
||||
# code coverage
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# logs
|
||||
logs
|
||||
_.log
|
||||
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# caches
|
||||
.eslintcache
|
||||
.cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# IntelliJ based IDEs
|
||||
.idea
|
||||
|
||||
# Finder (MacOS) folder config
|
||||
.DS_Store
|
||||
14
client/typescript/README.md
Normal file
14
client/typescript/README.md
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
# NestJ TypeScript WebSocket client
|
||||
|
||||
The connector uses the plain WebSocket protocol, not Socket.IO.
|
||||
|
||||
```ts
|
||||
import { createWebSocketClient } from "./index.js";
|
||||
const socket = createWebSocketClient("ws://localhost:8080/chat");
|
||||
socket.onMessage(console.log);
|
||||
await socket.connect();
|
||||
socket.send("hello");
|
||||
```
|
||||
|
||||
Browser builds use the platform `WebSocket`. Node.js projects can import
|
||||
`createNodeWebSocketClient` from `node.ts`, which uses `ws`.
|
||||
1
client/typescript/browser.ts
Normal file
1
client/typescript/browser.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { createWebSocketClient, NestjWebSocketClient } from "./index.js";
|
||||
26
client/typescript/bun.lock
Normal file
26
client/typescript/bun.lock
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "typescript",
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@types/bun": ["@types/bun@1.4.2", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="],
|
||||
|
||||
"@types/node": ["@types/node@22.20.2", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-xlvWf4Vs9n1PEVYwP1n4vvG07M6y8WgvJ2t0vbrWTmijsIHp1cS+uJ2kMIRdY3nHZK0nCYKrPeD171+SzF4/zw=="],
|
||||
|
||||
"bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
}
|
||||
}
|
||||
111
client/typescript/index.ts
Normal file
111
client/typescript/index.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
export type SocketState = "connecting" | "open" | "closing" | "closed";
|
||||
|
||||
export interface WebSocketClient {
|
||||
readonly state: SocketState;
|
||||
connect(): Promise<void>;
|
||||
send(message: string): void;
|
||||
close(code?: number, reason?: string): void;
|
||||
onOpen(listener: () => void): () => void;
|
||||
onMessage(listener: (message: string) => void): () => void;
|
||||
onClose(listener: (code: number, reason: string) => void): () => void;
|
||||
onError(listener: (error: Event | Error) => void): () => void;
|
||||
}
|
||||
|
||||
type ListenerMap = {
|
||||
open: Set<() => void>;
|
||||
message: Set<(message: string) => void>;
|
||||
close: Set<(code: number, reason: string) => void>;
|
||||
error: Set<(error: Event | Error) => void>;
|
||||
};
|
||||
export type WebSocketConstructor = new (
|
||||
url: string,
|
||||
protocols?: string | string[],
|
||||
) => WebSocket;
|
||||
|
||||
export class NestjWebSocketClient implements WebSocketClient {
|
||||
private socket?: WebSocket;
|
||||
private listeners: ListenerMap = {
|
||||
open: new Set(),
|
||||
message: new Set(),
|
||||
close: new Set(),
|
||||
error: new Set(),
|
||||
};
|
||||
private currentState: SocketState = "closed";
|
||||
get state(): SocketState {
|
||||
return this.currentState;
|
||||
}
|
||||
|
||||
constructor(
|
||||
private readonly url: string,
|
||||
private readonly protocols?: string | string[],
|
||||
private readonly socketConstructor: WebSocketConstructor = WebSocket,
|
||||
) {}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
if (
|
||||
this.socket &&
|
||||
(this.currentState === "open" || this.currentState === "connecting")
|
||||
) return;
|
||||
this.currentState = "connecting";
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const socket = new this.socketConstructor(this.url, this.protocols);
|
||||
this.socket = socket;
|
||||
socket.addEventListener("open", () => {
|
||||
this.currentState = "open";
|
||||
this.listeners.open.forEach((f) => f());
|
||||
resolve();
|
||||
}, { once: true });
|
||||
socket.addEventListener(
|
||||
"message",
|
||||
(event) =>
|
||||
this.listeners.message.forEach((f) =>
|
||||
f(String(event.data))
|
||||
),
|
||||
);
|
||||
socket.addEventListener("close", (event) => {
|
||||
this.currentState = "closed";
|
||||
this.listeners.close.forEach((f) =>
|
||||
f(event.code, event.reason)
|
||||
);
|
||||
});
|
||||
socket.addEventListener("error", (event) => {
|
||||
this.listeners.error.forEach((f) => f(event));
|
||||
reject(new Error("WebSocket connection failed"));
|
||||
}, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
send(message: string): void {
|
||||
if (this.currentState !== "open") {
|
||||
throw new Error("WebSocket is not open");
|
||||
}
|
||||
this.socket!.send(message);
|
||||
}
|
||||
close(code = 1000, reason = ""): void {
|
||||
this.currentState = "closing";
|
||||
this.socket?.close(code, reason);
|
||||
}
|
||||
onOpen(listener: () => void) {
|
||||
return this.subscribe(this.listeners.open, listener);
|
||||
}
|
||||
onMessage(listener: (message: string) => void) {
|
||||
return this.subscribe(this.listeners.message, listener);
|
||||
}
|
||||
onClose(listener: (code: number, reason: string) => void) {
|
||||
return this.subscribe(this.listeners.close, listener);
|
||||
}
|
||||
onError(listener: (error: Event | Error) => void) {
|
||||
return this.subscribe(this.listeners.error, listener);
|
||||
}
|
||||
private subscribe<T>(listeners: Set<T>, listener: T): () => void {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
}
|
||||
|
||||
export function createWebSocketClient(
|
||||
url: string,
|
||||
protocols?: string | string[],
|
||||
): WebSocketClient {
|
||||
return new NestjWebSocketClient(url, protocols);
|
||||
}
|
||||
13
client/typescript/node.ts
Normal file
13
client/typescript/node.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import WebSocket from "ws";
|
||||
import { NestjWebSocketClient, type WebSocketClient } from "./index.js";
|
||||
|
||||
export function createNodeWebSocketClient(
|
||||
url: string,
|
||||
protocols?: string | string[],
|
||||
): WebSocketClient {
|
||||
return new NestjWebSocketClient(
|
||||
url,
|
||||
protocols,
|
||||
WebSocket as unknown as import("./index.js").WebSocketConstructor,
|
||||
);
|
||||
}
|
||||
15
client/typescript/package.json
Normal file
15
client/typescript/package.json
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"name": "typescript",
|
||||
"module": "index.ts",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5"
|
||||
},
|
||||
"dependencies": {
|
||||
"ws": "^8.21.3"
|
||||
}
|
||||
}
|
||||
30
client/typescript/tsconfig.json
Normal file
30
client/typescript/tsconfig.json
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
// Environment setup & latest features
|
||||
"lib": ["ESNext", "DOM"],
|
||||
"target": "ESNext",
|
||||
"module": "Preserve",
|
||||
"moduleDetection": "force",
|
||||
"jsx": "react-jsx",
|
||||
"allowJs": true,
|
||||
"types": ["bun"],
|
||||
|
||||
// Bundler mode
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
|
||||
// Best practices
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
|
||||
// Some stricter flags (disabled by default)
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noPropertyAccessFromIndexSignature": false
|
||||
}
|
||||
}
|
||||
4
client/typescript/ws.d.ts
vendored
Normal file
4
client/typescript/ws.d.ts
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
declare module "ws" {
|
||||
const WebSocket: new (url: string, protocols?: string | string[]) => globalThis.WebSocket;
|
||||
export default WebSocket;
|
||||
}
|
||||
8
gradle.properties
Normal file
8
gradle.properties
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
# This file was generated by the Gradle 'init' task.
|
||||
# https://docs.gradle.org/current/userguide/build_environment.html#sec:gradle_configuration_properties
|
||||
|
||||
org.gradle.configuration-cache=true
|
||||
org.gradle.parallel=true
|
||||
org.gradle.caching=true
|
||||
group=rip.crit
|
||||
version=0.1.0-SNAPSHOT
|
||||
25
gradle/libs.versions.toml
Normal file
25
gradle/libs.versions.toml
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
# This file was generated by the Gradle 'init' task.
|
||||
# https://docs.gradle.org/current/userguide/version_catalogs.html#sec::toml-dependencies-format
|
||||
|
||||
[versions]
|
||||
commons-math3 = "3.6.1"
|
||||
guava = "33.5.0-jre"
|
||||
netty-socketio = "2.0.14"
|
||||
sqlite = "3.53.4.0"
|
||||
postgres = "42.7.8"
|
||||
servlet = "6.1.0"
|
||||
vaadin = "25.2.6"
|
||||
jackson = "2.20.1"
|
||||
|
||||
[libraries]
|
||||
commons-math3 = { module = "org.apache.commons:commons-math3", version.ref = "commons-math3" }
|
||||
guava = { module = "com.google.guava:guava", version.ref = "guava" }
|
||||
netty-socketio = { module = "com.corundumstudio.socketio:netty-socketio", version.ref = "netty-socketio" }
|
||||
sqlite-jdbc = { module = "org.xerial:sqlite-jdbc", version.ref = "sqlite" }
|
||||
postgres-jdbc = { module = "org.postgresql:postgresql", version.ref = "postgres" }
|
||||
jakarta-servlet = { module = "jakarta.servlet:jakarta.servlet-api", version.ref = "servlet" }
|
||||
vaadin-core = { module = "com.vaadin:vaadin-core", version.ref = "vaadin" }
|
||||
jackson-databind = { module = "com.fasterxml.jackson.core:jackson-databind", version.ref = "jackson" }
|
||||
|
||||
[plugins]
|
||||
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version = "2.4.0" }
|
||||
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
9
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
9
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip
|
||||
networkTimeout=10000
|
||||
retries=0
|
||||
retryBackOffMs=500
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
248
gradlew
vendored
Executable file
248
gradlew
vendored
Executable file
|
|
@ -0,0 +1,248 @@
|
|||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# gradlew start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh gradlew
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
82
gradlew.bat
vendored
Normal file
82
gradlew.bat
vendored
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem gradlew startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables, and ensure extensions are enabled
|
||||
setlocal EnableExtensions
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
"%COMSPEC%" /c exit 1
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
"%COMSPEC%" /c exit 1
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
|
||||
|
||||
@rem Execute gradlew
|
||||
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
|
||||
@rem which allows us to clear the local environment before executing the java command
|
||||
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
|
||||
|
||||
:exitWithErrorLevel
|
||||
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
|
||||
"%COMSPEC%" /c exit %ERRORLEVEL%
|
||||
10
justfile
Normal file
10
justfile
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
format:
|
||||
find . -name "build.gradle*" -exec dirname {} \; | sort -u | xargs -I {} ./gradlew -p {} ktfmtFormat
|
||||
|
||||
build:
|
||||
./gradlew build
|
||||
|
||||
publish version="0.1.0-SNAPSHOT":
|
||||
git add .
|
||||
git commit -m "Update version to ${version}"
|
||||
git push -f origin main
|
||||
59
lib/build.gradle.kts
Normal file
59
lib/build.gradle.kts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
/*
|
||||
* This file was generated by the Gradle 'init' task.
|
||||
*
|
||||
* This generated file contains a sample Kotlin library project to get you started.
|
||||
* For more details on building Java & JVM projects, please refer to https://docs.gradle.org/9.7.1/userguide/building_java_projects.html in the Gradle documentation.
|
||||
* This project uses @Incubating APIs which are subject to change.
|
||||
*/
|
||||
|
||||
plugins {
|
||||
// Apply the org.jetbrains.kotlin.jvm Plugin to add support for Kotlin.
|
||||
alias(libs.plugins.kotlin.jvm)
|
||||
|
||||
// Apply the java-library plugin for API and implementation separation.
|
||||
`java-library`
|
||||
`maven-publish`
|
||||
|
||||
id("com.ncorti.ktfmt.gradle") version "0.27.0"
|
||||
}
|
||||
|
||||
repositories {
|
||||
// Use Maven Central for resolving dependencies.
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// This dependency is exported to consumers, that is to say found on their compile classpath.
|
||||
api(libs.commons.math3)
|
||||
|
||||
// This dependency is used internally, and not exposed to consumers on their own compile
|
||||
// classpath.
|
||||
implementation(libs.guava)
|
||||
implementation(kotlin("reflect"))
|
||||
}
|
||||
|
||||
testing {
|
||||
suites {
|
||||
// Configure the built-in test suite
|
||||
val test =
|
||||
named<JvmTestSuite>("test") {
|
||||
// Use Kotlin Test test framework
|
||||
useKotlinTest("2.4.0")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply a specific Java toolchain to ease working on different environments.
|
||||
java {
|
||||
toolchain { languageVersion = JavaLanguageVersion.of(25) }
|
||||
withSourcesJar()
|
||||
}
|
||||
|
||||
publishing {
|
||||
publications {
|
||||
create<MavenPublication>("maven") {
|
||||
from(components["java"])
|
||||
artifactId = "nestj-core"
|
||||
}
|
||||
}
|
||||
}
|
||||
97
lib/src/main/kotlin/rip/crit/nestj/Application.kt
Normal file
97
lib/src/main/kotlin/rip/crit/nestj/Application.kt
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
package rip.crit.nestj
|
||||
|
||||
import java.util.ServiceLoader
|
||||
import kotlin.reflect.KClass
|
||||
import kotlin.reflect.KFunction
|
||||
import kotlin.reflect.KParameter
|
||||
import kotlin.reflect.full.findAnnotation
|
||||
import kotlin.reflect.full.memberFunctions
|
||||
import rip.crit.nestj.controller.*
|
||||
import rip.crit.nestj.extractions.Extractions
|
||||
import rip.crit.nestj.http.method.Method
|
||||
import rip.crit.nestj.http.request.Request
|
||||
import rip.crit.nestj.http.response.HTTPResponse
|
||||
import rip.crit.nestj.http.response.Response
|
||||
import rip.crit.nestj.http.server.Server
|
||||
import rip.crit.nestj.inject.DepResolver
|
||||
import rip.crit.nestj.module.Module
|
||||
import rip.crit.nestj.plugin.NestjPlugin
|
||||
|
||||
class Application private constructor(val server: Server, val resolver: DepResolver) {
|
||||
companion object {
|
||||
fun create(root: KClass<*>): Application {
|
||||
val app = Application(Server(), DepResolver())
|
||||
app.installModule(root, mutableSetOf())
|
||||
ServiceLoader.load(NestjPlugin::class.java).forEach { it.configure(app) }
|
||||
return app
|
||||
}
|
||||
}
|
||||
|
||||
fun use(plugin: NestjPlugin): Application = apply { plugin.configure(this) }
|
||||
|
||||
private fun installModule(type: KClass<*>, seen: MutableSet<KClass<*>>) {
|
||||
if (!seen.add(type)) return
|
||||
val module =
|
||||
requireNotNull(type.findAnnotation<Module>()) { "${type.qualifiedName} is not a @Module" }
|
||||
module.imports.forEach { installModule(it, seen) }
|
||||
module.providers.forEach { resolver.resolve(it) }
|
||||
module.controllers.forEach(::installController)
|
||||
}
|
||||
|
||||
private fun installController(type: KClass<*>) {
|
||||
val controller = resolver.resolve(type)
|
||||
val basePath = type.findAnnotation<Controller>()?.path ?: ""
|
||||
type.memberFunctions.forEach { function ->
|
||||
route(function)?.let { (method, path) ->
|
||||
server.route(join(basePath, path), method) { request ->
|
||||
invoke(controller, function, request)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun route(function: KFunction<*>): Pair<Method, String>? =
|
||||
when {
|
||||
function.findAnnotation<Get>() != null ->
|
||||
Method.GET to function.findAnnotation<Get>()!!.path
|
||||
function.findAnnotation<Post>() != null ->
|
||||
Method.POST to function.findAnnotation<Post>()!!.path
|
||||
function.findAnnotation<Put>() != null ->
|
||||
Method.PUT to function.findAnnotation<Put>()!!.path
|
||||
function.findAnnotation<Delete>() != null ->
|
||||
Method.DELETE to function.findAnnotation<Delete>()!!.path
|
||||
function.findAnnotation<Patch>() != null ->
|
||||
Method.PATCH to function.findAnnotation<Patch>()!!.path
|
||||
function.findAnnotation<Options>() != null ->
|
||||
Method.OPTIONS to function.findAnnotation<Options>()!!.path
|
||||
function.findAnnotation<Head>() != null ->
|
||||
Method.HEAD to function.findAnnotation<Head>()!!.path
|
||||
function.findAnnotation<All>() != null ->
|
||||
Method.ALL to function.findAnnotation<All>()!!.path
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun invoke(controller: Any, function: KFunction<*>, request: Request): Response {
|
||||
val values =
|
||||
mutableMapOf<KParameter, Any?>(
|
||||
function.parameters.first { it.kind == KParameter.Kind.INSTANCE } to controller
|
||||
)
|
||||
function.parameters
|
||||
.filter { it.kind == KParameter.Kind.VALUE }
|
||||
.forEach { parameter ->
|
||||
val value = Extractions.value(parameter, request)
|
||||
if (value != null) values[parameter] = value
|
||||
else if (!parameter.isOptional && !parameter.type.isMarkedNullable)
|
||||
error("Missing route argument ${parameter.name}")
|
||||
}
|
||||
return when (val result = function.callBy(values)) {
|
||||
is Response -> result
|
||||
is ByteArray -> HTTPResponse(result, 200, request)
|
||||
null -> HTTPResponse(ByteArray(0), 204, request)
|
||||
else -> HTTPResponse(result.toString(), 200, request)
|
||||
}
|
||||
}
|
||||
|
||||
private fun join(first: String, second: String): String =
|
||||
"/" + listOf(first, second).joinToString("/") { it.trim('/') }.trim('/')
|
||||
}
|
||||
17
lib/src/main/kotlin/rip/crit/nestj/Library.kt
Normal file
17
lib/src/main/kotlin/rip/crit/nestj/Library.kt
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
package rip.crit.nestj
|
||||
|
||||
import rip.crit.nestj.http.server.Server
|
||||
import rip.crit.nestj.websocket.WebSocketServer
|
||||
|
||||
class Library {
|
||||
fun createServer(): Server = Server()
|
||||
|
||||
fun createWebSocketServer(): WebSocketServer = WebSocketServer()
|
||||
|
||||
fun createApplication(module: kotlin.reflect.KClass<*>): Application = Application.create(module)
|
||||
|
||||
@Deprecated("Use createServer() to begin building an application")
|
||||
fun someLibraryMethod(): Boolean {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package rip.crit.nestj.config
|
||||
|
||||
import rip.crit.nestj.module.Module
|
||||
|
||||
@Module(providers = [ConfigService::class]) class ConfigModule
|
||||
14
lib/src/main/kotlin/rip/crit/nestj/config/ConfigService.kt
Normal file
14
lib/src/main/kotlin/rip/crit/nestj/config/ConfigService.kt
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
package rip.crit.nestj.config
|
||||
|
||||
import rip.crit.nestj.inject.Injectable
|
||||
|
||||
@Injectable
|
||||
class ConfigService(private val values: Map<String, String> = emptyMap()) {
|
||||
fun get(name: String, default: String? = null): String? =
|
||||
values[name] ?: System.getenv(name) ?: System.getProperty(name) ?: default
|
||||
|
||||
fun require(name: String): String =
|
||||
requireNotNull(get(name)) { "Missing configuration value: $name" }
|
||||
|
||||
fun getInt(name: String, default: Int): Int = get(name)?.toIntOrNull() ?: default
|
||||
}
|
||||
57
lib/src/main/kotlin/rip/crit/nestj/controller/Controller.kt
Normal file
57
lib/src/main/kotlin/rip/crit/nestj/controller/Controller.kt
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
package rip.crit.nestj.controller
|
||||
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
@Target(AnnotationTarget.CLASS)
|
||||
annotation class Controller(val path: String = "")
|
||||
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
@Target(AnnotationTarget.FUNCTION)
|
||||
annotation class Get(val path: String = "")
|
||||
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
@Target(AnnotationTarget.FUNCTION)
|
||||
annotation class Post(val path: String = "")
|
||||
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
@Target(AnnotationTarget.FUNCTION)
|
||||
annotation class Put(val path: String = "")
|
||||
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
@Target(AnnotationTarget.FUNCTION)
|
||||
annotation class Delete(val path: String = "")
|
||||
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
@Target(AnnotationTarget.FUNCTION)
|
||||
annotation class Patch(val path: String = "")
|
||||
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
@Target(AnnotationTarget.FUNCTION)
|
||||
annotation class Options(val path: String = "")
|
||||
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
@Target(AnnotationTarget.FUNCTION)
|
||||
annotation class Head(val path: String = "")
|
||||
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
@Target(AnnotationTarget.FUNCTION)
|
||||
annotation class All(val path: String = "")
|
||||
|
||||
@Target(AnnotationTarget.VALUE_PARAMETER)
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
annotation class Param(val name: String)
|
||||
|
||||
@Target(AnnotationTarget.VALUE_PARAMETER)
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
annotation class Query(val name: String)
|
||||
|
||||
@Target(AnnotationTarget.VALUE_PARAMETER)
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
annotation class Header(val name: String)
|
||||
|
||||
@Target(AnnotationTarget.VALUE_PARAMETER)
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
annotation class Body
|
||||
|
||||
@Target(AnnotationTarget.VALUE_PARAMETER)
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
annotation class RequestObject
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
package rip.crit.nestj.extractions
|
||||
|
||||
import kotlin.reflect.KParameter
|
||||
import kotlin.reflect.full.findAnnotation
|
||||
import rip.crit.nestj.controller.Body
|
||||
import rip.crit.nestj.controller.Header
|
||||
import rip.crit.nestj.controller.Param
|
||||
import rip.crit.nestj.controller.Query
|
||||
import rip.crit.nestj.controller.RequestObject
|
||||
import rip.crit.nestj.http.request.Request
|
||||
|
||||
object Extractions {
|
||||
fun value(parameter: KParameter, request: Request): Any? =
|
||||
when {
|
||||
parameter.findAnnotation<RequestObject>() != null -> request
|
||||
parameter.findAnnotation<Body>() != null -> body(parameter, request)
|
||||
parameter.findAnnotation<Param>() != null ->
|
||||
request.getPathParams()[parameter.findAnnotation<Param>()!!.name]
|
||||
parameter.findAnnotation<Query>() != null ->
|
||||
request.getQueryParams()[parameter.findAnnotation<Query>()!!.name]
|
||||
parameter.findAnnotation<Header>() != null ->
|
||||
request
|
||||
.getHeaders()
|
||||
.entries
|
||||
.firstOrNull { it.key.equals(parameter.findAnnotation<Header>()!!.name, true) }
|
||||
?.value
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun body(parameter: KParameter, request: Request): Any =
|
||||
when (parameter.type.classifier) {
|
||||
ByteArray::class -> request.getBody()
|
||||
else -> request.getBody().toString(Charsets.UTF_8)
|
||||
}
|
||||
}
|
||||
17
lib/src/main/kotlin/rip/crit/nestj/gate/Gates.kt
Normal file
17
lib/src/main/kotlin/rip/crit/nestj/gate/Gates.kt
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
package rip.crit.nestj.gate
|
||||
|
||||
import rip.crit.nestj.http.request.Request
|
||||
|
||||
fun interface Gate {
|
||||
fun canActivate(request: Request): Boolean
|
||||
}
|
||||
|
||||
typealias Gates = Gate
|
||||
|
||||
object AllowAllGate : Gate {
|
||||
override fun canActivate(request: Request): Boolean = true
|
||||
}
|
||||
|
||||
@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION)
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
annotation class UseGates(vararg val gates: kotlin.reflect.KClass<out Gate>)
|
||||
12
lib/src/main/kotlin/rip/crit/nestj/http/method/Method.kt
Normal file
12
lib/src/main/kotlin/rip/crit/nestj/http/method/Method.kt
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
package rip.crit.nestj.http.method
|
||||
|
||||
enum class Method {
|
||||
GET,
|
||||
POST,
|
||||
PUT,
|
||||
DELETE,
|
||||
PATCH,
|
||||
OPTIONS,
|
||||
HEAD,
|
||||
ALL,
|
||||
}
|
||||
144
lib/src/main/kotlin/rip/crit/nestj/http/pathfinder/Pathfinder.kt
Normal file
144
lib/src/main/kotlin/rip/crit/nestj/http/pathfinder/Pathfinder.kt
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
package rip.crit.nestj.http.pathfinder
|
||||
|
||||
import rip.crit.nestj.http.method.Method
|
||||
import rip.crit.nestj.http.request.Request
|
||||
import rip.crit.nestj.http.response.Response
|
||||
|
||||
enum class RoutePartType {
|
||||
STATIC,
|
||||
PARAMETER,
|
||||
WILDCARD,
|
||||
}
|
||||
|
||||
data class RoutePart(val type: RoutePartType, val value: String)
|
||||
|
||||
/** A route selected for a request, together with its extracted path values. */
|
||||
data class RouteMatch(
|
||||
val method: Method,
|
||||
val path: String,
|
||||
val handler: (Request) -> Response,
|
||||
val pathParams: HashMap<String, String>,
|
||||
)
|
||||
|
||||
private data class RegisteredRoute(
|
||||
val method: Method,
|
||||
val path: String,
|
||||
val parts: List<RoutePart>,
|
||||
val handler: (Request) -> Response,
|
||||
val order: Int,
|
||||
)
|
||||
|
||||
/** A simple pathfinder for routing HTTP requests to handlers based on path and method. */
|
||||
class Pathfinder {
|
||||
private val registeredRoutes = mutableListOf<RegisteredRoute>()
|
||||
|
||||
val routes: HashMap<(Request) -> Response, Pair<String, RoutePart>>
|
||||
get() =
|
||||
HashMap(
|
||||
registeredRoutes.associate { route ->
|
||||
route.handler to
|
||||
(route.method.name to
|
||||
(route.parts.lastOrNull() ?: RoutePart(RoutePartType.STATIC, "")))
|
||||
}
|
||||
)
|
||||
|
||||
fun addRoute(path: String, method: String, callback: (Request) -> Response) =
|
||||
addRoute(path, parseMethod(method), callback)
|
||||
|
||||
fun addRoute(path: String, method: Method, callback: (Request) -> Response) {
|
||||
val normalizedPath = normalizePath(path)
|
||||
val parts = parseRoute(normalizedPath)
|
||||
require(registeredRoutes.none { it.method == method && it.path == normalizedPath }) {
|
||||
"Route already registered: ${method.name} $normalizedPath"
|
||||
}
|
||||
registeredRoutes +=
|
||||
RegisteredRoute(method, normalizedPath, parts, callback, registeredRoutes.size)
|
||||
}
|
||||
|
||||
fun findRoute(path: String, method: Method): RouteMatch? {
|
||||
val segments = splitPath(path)
|
||||
return registeredRoutes
|
||||
.asSequence()
|
||||
.filter { it.method == method || it.method == Method.ALL }
|
||||
.mapNotNull { route -> match(route, segments) }
|
||||
.sortedWith(
|
||||
compareByDescending<RouteMatch> { it.method == method }
|
||||
.thenByDescending { specificity(it.path) }
|
||||
.thenBy { match ->
|
||||
registeredRoutes
|
||||
.first { it.path == match.path && it.method == match.method }
|
||||
.order
|
||||
}
|
||||
)
|
||||
.firstOrNull()
|
||||
}
|
||||
|
||||
fun findRoute(path: String, method: String): RouteMatch? = findRoute(path, parseMethod(method))
|
||||
|
||||
fun resolve(path: String, method: String): Pair<String, RoutePart>? =
|
||||
findRoute(path, method)?.let {
|
||||
it.method.name to (parseRoute(it.path).lastOrNull() ?: RoutePart(RoutePartType.STATIC, ""))
|
||||
}
|
||||
|
||||
private fun match(route: RegisteredRoute, segments: List<String>): RouteMatch? {
|
||||
val params = linkedMapOf<String, String>()
|
||||
var index = 0
|
||||
for ((type, value) in route.parts) {
|
||||
when (type) {
|
||||
RoutePartType.STATIC -> if (segments.getOrNull(index) != value) return null else index++
|
||||
RoutePartType.PARAMETER -> {
|
||||
params[value] = segments.getOrNull(index) ?: return null
|
||||
index++
|
||||
}
|
||||
RoutePartType.WILDCARD -> {
|
||||
params[value] = segments.drop(index).joinToString("/")
|
||||
index = segments.size
|
||||
}
|
||||
}
|
||||
}
|
||||
if (index != segments.size) return null
|
||||
return RouteMatch(route.method, route.path, route.handler, params)
|
||||
}
|
||||
|
||||
private fun parseRoute(path: String): List<RoutePart> {
|
||||
val segments = splitPath(path)
|
||||
return segments.mapIndexed { index, segment ->
|
||||
when {
|
||||
segment.startsWith(":") -> {
|
||||
require(segment.length > 1) { "Route parameter names cannot be empty" }
|
||||
RoutePart(RoutePartType.PARAMETER, segment.drop(1))
|
||||
}
|
||||
segment == "*" -> {
|
||||
require(index == segments.lastIndex) { "A wildcard must be the final route segment" }
|
||||
RoutePart(RoutePartType.WILDCARD, "*")
|
||||
}
|
||||
else -> RoutePart(RoutePartType.STATIC, segment)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun specificity(path: String): Int =
|
||||
parseRoute(path).sumOf {
|
||||
when (it.type) {
|
||||
RoutePartType.STATIC -> 4
|
||||
RoutePartType.PARAMETER -> 2
|
||||
RoutePartType.WILDCARD -> 0
|
||||
}
|
||||
}
|
||||
|
||||
private fun splitPath(path: String): List<String> =
|
||||
normalizePath(path).removePrefix("/").split('/').filter(String::isNotEmpty)
|
||||
|
||||
private fun normalizePath(path: String): String {
|
||||
val pathname = path.substringBefore('?').substringBefore('#').trim()
|
||||
require(pathname.isNotEmpty()) { "Route path cannot be empty" }
|
||||
return "/" + pathname.trim('/').replace(Regex("/+"), "/").trim('/')
|
||||
}
|
||||
|
||||
private fun parseMethod(method: String): Method =
|
||||
try {
|
||||
Method.valueOf(method.trim().uppercase())
|
||||
} catch (_: IllegalArgumentException) {
|
||||
throw IllegalArgumentException("Unsupported HTTP method: $method")
|
||||
}
|
||||
}
|
||||
81
lib/src/main/kotlin/rip/crit/nestj/http/request/Request.kt
Normal file
81
lib/src/main/kotlin/rip/crit/nestj/http/request/Request.kt
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
package rip.crit.nestj.http.request
|
||||
|
||||
import rip.crit.nestj.http.method.Method
|
||||
|
||||
interface Request {
|
||||
fun getMethod(): Method
|
||||
|
||||
fun getPath(): String
|
||||
|
||||
fun getHeaders(): HashMap<String, String>
|
||||
|
||||
fun getBody(): ByteArray
|
||||
|
||||
fun getQueryParams(): HashMap<String, String>
|
||||
|
||||
fun getPathParams(): HashMap<String, String>
|
||||
|
||||
fun getHash(): String
|
||||
|
||||
fun getProtocol(): String
|
||||
}
|
||||
|
||||
class HTTPRequest(
|
||||
private val method: Method,
|
||||
private val path: String,
|
||||
private val headers: HashMap<String, String>,
|
||||
private val body: ByteArray,
|
||||
private val queryParams: HashMap<String, String>,
|
||||
private val pathParams: HashMap<String, String>,
|
||||
private val hash: String,
|
||||
private val protocol: String,
|
||||
) : Request {
|
||||
override fun getMethod(): Method = method
|
||||
|
||||
override fun getPath(): String = path
|
||||
|
||||
private val immutableHeaders = headers.toMap()
|
||||
private val immutableBody = body.copyOf()
|
||||
private val immutableQueryParams = queryParams.toMap()
|
||||
private val immutablePathParams = pathParams.toMap()
|
||||
|
||||
override fun getHeaders(): HashMap<String, String> = HashMap(immutableHeaders)
|
||||
|
||||
override fun getBody(): ByteArray = immutableBody.copyOf()
|
||||
|
||||
override fun getQueryParams(): HashMap<String, String> = HashMap(immutableQueryParams)
|
||||
|
||||
override fun getPathParams(): HashMap<String, String> = HashMap(immutablePathParams)
|
||||
|
||||
override fun getHash(): String = hash
|
||||
|
||||
override fun getProtocol(): String = protocol
|
||||
|
||||
constructor(
|
||||
method: Method,
|
||||
path: String,
|
||||
) : this(
|
||||
method,
|
||||
path.substringBefore('?'),
|
||||
HashMap(),
|
||||
ByteArray(0),
|
||||
parseQuery(path),
|
||||
HashMap(),
|
||||
"",
|
||||
"HTTP/1.1",
|
||||
)
|
||||
|
||||
companion object {
|
||||
private fun parseQuery(path: String): HashMap<String, String> =
|
||||
path
|
||||
.substringAfter('?', "")
|
||||
.substringBefore('#')
|
||||
.split('&')
|
||||
.filter(String::isNotEmpty)
|
||||
.associate { pair ->
|
||||
val key = pair.substringBefore('=')
|
||||
key to pair.substringAfter('=', "")
|
||||
}
|
||||
.let(::HashMap)
|
||||
}
|
||||
}
|
||||
60
lib/src/main/kotlin/rip/crit/nestj/http/response/Response.kt
Normal file
60
lib/src/main/kotlin/rip/crit/nestj/http/response/Response.kt
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
package rip.crit.nestj.http.response
|
||||
|
||||
import rip.crit.nestj.http.request.Request
|
||||
|
||||
interface Response {
|
||||
fun getBody(): ByteArray
|
||||
|
||||
fun getHeaders(): HashMap<String, String>
|
||||
|
||||
fun getStatusCode(): Int
|
||||
|
||||
fun getContentType(): String
|
||||
|
||||
fun getRequest(): Request
|
||||
}
|
||||
|
||||
class HTTPResponse(
|
||||
private val body: ByteArray,
|
||||
private val headers: HashMap<String, String>,
|
||||
private val statusCode: Int,
|
||||
private val contentType: String,
|
||||
private val request: Request,
|
||||
) : Response {
|
||||
private val immutableBody = body.copyOf()
|
||||
private val immutableHeaders = headers.toMap()
|
||||
|
||||
override fun getBody(): ByteArray = immutableBody.copyOf()
|
||||
|
||||
override fun getHeaders(): HashMap<String, String> = HashMap(immutableHeaders)
|
||||
|
||||
override fun getStatusCode(): Int = statusCode
|
||||
|
||||
override fun getContentType(): String = contentType
|
||||
|
||||
override fun getRequest(): Request = request
|
||||
|
||||
constructor(
|
||||
body: ByteArray,
|
||||
statusCode: Int,
|
||||
request: Request,
|
||||
) : this(
|
||||
body,
|
||||
HashMap(),
|
||||
statusCode,
|
||||
"text/plain",
|
||||
request,
|
||||
)
|
||||
|
||||
constructor(
|
||||
body: String,
|
||||
statusCode: Int,
|
||||
request: Request,
|
||||
) : this(
|
||||
body.toByteArray(Charsets.UTF_8),
|
||||
hashMapOf("Content-Type" to "text/plain; charset=utf-8"),
|
||||
statusCode,
|
||||
"text/plain",
|
||||
request,
|
||||
)
|
||||
}
|
||||
62
lib/src/main/kotlin/rip/crit/nestj/http/server/Server.kt
Normal file
62
lib/src/main/kotlin/rip/crit/nestj/http/server/Server.kt
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
package rip.crit.nestj.http.server
|
||||
|
||||
import rip.crit.nestj.gate.Gate
|
||||
import rip.crit.nestj.http.method.Method
|
||||
import rip.crit.nestj.http.pathfinder.Pathfinder
|
||||
import rip.crit.nestj.http.request.Request
|
||||
import rip.crit.nestj.http.response.HTTPResponse
|
||||
import rip.crit.nestj.http.response.Response
|
||||
import rip.crit.nestj.pipe.HttpPipe
|
||||
|
||||
/** HTTP dispatcher with pipes and gates. */
|
||||
class Server(private val pathfinder: Pathfinder = Pathfinder()) {
|
||||
private val pipes = mutableListOf<HttpPipe>()
|
||||
private val gates = mutableListOf<Gate>()
|
||||
|
||||
fun use(pipe: HttpPipe): Server = apply { pipes += pipe }
|
||||
|
||||
fun use(gate: Gate): Server = apply { gates += gate }
|
||||
|
||||
fun route(path: String, method: Method, handler: (Request) -> Response): Server = apply {
|
||||
pathfinder.addRoute(path, method, handler)
|
||||
}
|
||||
|
||||
fun get(path: String, handler: (Request) -> Response): Server = route(path, Method.GET, handler)
|
||||
|
||||
fun post(path: String, handler: (Request) -> Response): Server = route(path, Method.POST, handler)
|
||||
|
||||
fun put(path: String, handler: (Request) -> Response): Server = route(path, Method.PUT, handler)
|
||||
|
||||
fun delete(path: String, handler: (Request) -> Response): Server =
|
||||
route(path, Method.DELETE, handler)
|
||||
|
||||
fun patch(path: String, handler: (Request) -> Response): Server =
|
||||
route(path, Method.PATCH, handler)
|
||||
|
||||
fun options(path: String, handler: (Request) -> Response): Server =
|
||||
route(path, Method.OPTIONS, handler)
|
||||
|
||||
fun head(path: String, handler: (Request) -> Response): Server = route(path, Method.HEAD, handler)
|
||||
|
||||
fun all(path: String, handler: (Request) -> Response): Server = route(path, Method.ALL, handler)
|
||||
|
||||
fun handleRequest(request: Request): Response {
|
||||
if (gates.any { !it.canActivate(request) }) return HTTPResponse("Forbidden", 403, request)
|
||||
return try {
|
||||
val pipedRequest = pipes.fold(request) { current, pipe -> pipe.transform(current) }
|
||||
val match =
|
||||
pathfinder.findRoute(pipedRequest.getPath(), pipedRequest.getMethod())
|
||||
?: return HTTPResponse("Route not found", 404, pipedRequest)
|
||||
match.handler(PathParamsRequest(pipedRequest, match.pathParams))
|
||||
} catch (error: IllegalArgumentException) {
|
||||
HTTPResponse(error.message ?: "Bad request", 400, request)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class PathParamsRequest(
|
||||
private val delegate: Request,
|
||||
private val params: Map<String, String>,
|
||||
) : Request by delegate {
|
||||
override fun getPathParams(): HashMap<String, String> = HashMap(params)
|
||||
}
|
||||
47
lib/src/main/kotlin/rip/crit/nestj/inject/Injectable.kt
Normal file
47
lib/src/main/kotlin/rip/crit/nestj/inject/Injectable.kt
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
package rip.crit.nestj.inject
|
||||
|
||||
import kotlin.reflect.KClass
|
||||
import kotlin.reflect.full.primaryConstructor
|
||||
|
||||
@Target(AnnotationTarget.CLASS) @Retention(AnnotationRetention.RUNTIME) annotation class Injectable
|
||||
|
||||
@Target(AnnotationTarget.VALUE_PARAMETER)
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
annotation class Inject(val type: KClass<*>)
|
||||
|
||||
/** Singleton constructor-injection container used by modules and controllers. */
|
||||
class DepResolver {
|
||||
private val instances = linkedMapOf<KClass<*>, Any>()
|
||||
|
||||
fun <T : Any> register(type: KClass<T>, instance: T): DepResolver = apply {
|
||||
instances[type] = instance
|
||||
}
|
||||
|
||||
fun <T : Any> resolve(type: KClass<T>): T {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
instances[type]?.let {
|
||||
return it as T
|
||||
}
|
||||
require(type.annotations.any { it is Injectable } || type.constructors.isNotEmpty()) {
|
||||
"No provider for ${type.qualifiedName}"
|
||||
}
|
||||
val constructor =
|
||||
type.primaryConstructor
|
||||
?: type.constructors.singleOrNull { it.parameters.isEmpty() }
|
||||
?: error("${type.qualifiedName} needs a primary constructor")
|
||||
val instance =
|
||||
constructor.callBy(
|
||||
constructor.parameters
|
||||
.filterNot { it.isOptional }
|
||||
.associateWith { parameter ->
|
||||
val dependency =
|
||||
parameter.annotations.filterIsInstance<Inject>().firstOrNull()?.type
|
||||
?: parameter.type.classifier as? KClass<*>
|
||||
?: error("Cannot resolve ${parameter.name}")
|
||||
resolve(dependency)
|
||||
}
|
||||
)
|
||||
instances[type] = instance
|
||||
return instance
|
||||
}
|
||||
}
|
||||
12
lib/src/main/kotlin/rip/crit/nestj/module/Module.kt
Normal file
12
lib/src/main/kotlin/rip/crit/nestj/module/Module.kt
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
package rip.crit.nestj.module
|
||||
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
@Target(AnnotationTarget.CLASS)
|
||||
annotation class Module(
|
||||
val imports: Array<KClass<*>> = [],
|
||||
val providers: Array<KClass<*>> = [],
|
||||
val controllers: Array<KClass<*>> = [],
|
||||
val exports: Array<KClass<*>> = [],
|
||||
)
|
||||
43
lib/src/main/kotlin/rip/crit/nestj/pipe/Pipes.kt
Normal file
43
lib/src/main/kotlin/rip/crit/nestj/pipe/Pipes.kt
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
package rip.crit.nestj.pipe
|
||||
|
||||
import rip.crit.nestj.http.request.Request
|
||||
|
||||
enum class Ingest {
|
||||
HEADER,
|
||||
BODY,
|
||||
QUERY,
|
||||
PARAMS,
|
||||
IP,
|
||||
COOKIE_JAR,
|
||||
}
|
||||
|
||||
@Target(AnnotationTarget.CLASS)
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
annotation class Pipe(val name: String = "", val ingests: Array<Ingest> = [])
|
||||
|
||||
fun interface HttpPipe {
|
||||
fun transform(request: Request): Request
|
||||
}
|
||||
|
||||
object IdentityPipe : HttpPipe {
|
||||
override fun transform(request: Request): Request = request
|
||||
}
|
||||
|
||||
class MaxBodySizePipe(private val maximumBytes: Int) : HttpPipe {
|
||||
init {
|
||||
require(maximumBytes >= 0)
|
||||
}
|
||||
|
||||
override fun transform(request: Request): Request {
|
||||
require(request.getBody().size <= maximumBytes) { "Request body exceeds $maximumBytes bytes" }
|
||||
return request
|
||||
}
|
||||
}
|
||||
|
||||
class RequiredQueryPipe(private vararg val names: String) : HttpPipe {
|
||||
override fun transform(request: Request): Request {
|
||||
val missing = names.filterNot(request.getQueryParams()::containsKey)
|
||||
require(missing.isEmpty()) { "Missing query parameter(s): ${missing.joinToString()}" }
|
||||
return request
|
||||
}
|
||||
}
|
||||
21
lib/src/main/kotlin/rip/crit/nestj/pipe/header/Header.kt
Normal file
21
lib/src/main/kotlin/rip/crit/nestj/pipe/header/Header.kt
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
package rip.crit.nestj.pipe.header
|
||||
|
||||
import rip.crit.nestj.http.request.Request
|
||||
import rip.crit.nestj.pipe.HttpPipe
|
||||
import rip.crit.nestj.pipe.Ingest
|
||||
import rip.crit.nestj.pipe.Pipe
|
||||
|
||||
@Pipe(name = "HeaderPipe", ingests = [Ingest.HEADER])
|
||||
class HeaderPipe(private val requiredHeader: String? = null) : HttpPipe {
|
||||
override fun transform(request: Request): Request {
|
||||
if (
|
||||
requiredHeader != null && request.getHeaders().keys.none { it.equals(requiredHeader, true) }
|
||||
) {
|
||||
throw MissingHeaderException(requiredHeader)
|
||||
}
|
||||
return request
|
||||
}
|
||||
}
|
||||
|
||||
class MissingHeaderException(header: String) :
|
||||
IllegalArgumentException("Missing required header: $header")
|
||||
8
lib/src/main/kotlin/rip/crit/nestj/plugin/Plugin.kt
Normal file
8
lib/src/main/kotlin/rip/crit/nestj/plugin/Plugin.kt
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
package rip.crit.nestj.plugin
|
||||
|
||||
import rip.crit.nestj.Application
|
||||
|
||||
/** Extension point discoverable with Java's ServiceLoader. */
|
||||
fun interface NestjPlugin {
|
||||
fun configure(application: Application)
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package rip.crit.nestj.serve
|
||||
|
||||
import rip.crit.nestj.module.Module
|
||||
|
||||
@Module(providers = [ServeDirService::class]) class ServeDirModule
|
||||
29
lib/src/main/kotlin/rip/crit/nestj/serve/ServeDirService.kt
Normal file
29
lib/src/main/kotlin/rip/crit/nestj/serve/ServeDirService.kt
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
package rip.crit.nestj.serve
|
||||
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.Paths
|
||||
import rip.crit.nestj.http.request.Request
|
||||
import rip.crit.nestj.http.response.HTTPResponse
|
||||
import rip.crit.nestj.http.response.Response
|
||||
import rip.crit.nestj.inject.Injectable
|
||||
|
||||
/** Safe static-file service that refuses traversal outside its configured document root. */
|
||||
@Injectable
|
||||
class ServeDirService(root: Path = Paths.get(".")) {
|
||||
private val root = root.toAbsolutePath().normalize()
|
||||
|
||||
fun serve(request: Request, relativePath: String): Response {
|
||||
val target = root.resolve(relativePath.removePrefix("/")).normalize()
|
||||
if (!target.startsWith(root) || !Files.isRegularFile(target))
|
||||
return HTTPResponse("Not found", 404, request)
|
||||
val contentType = Files.probeContentType(target) ?: "application/octet-stream"
|
||||
return HTTPResponse(
|
||||
Files.readAllBytes(target),
|
||||
hashMapOf("Content-Type" to contentType),
|
||||
200,
|
||||
contentType,
|
||||
request,
|
||||
)
|
||||
}
|
||||
}
|
||||
121
lib/src/main/kotlin/rip/crit/nestj/websocket/WebSocket.kt
Normal file
121
lib/src/main/kotlin/rip/crit/nestj/websocket/WebSocket.kt
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
package rip.crit.nestj.websocket
|
||||
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
interface WebSocketGateway {
|
||||
val path: String
|
||||
|
||||
fun onConnect(session: WebSocketSession) {}
|
||||
|
||||
fun onMessage(session: WebSocketSession, message: String) {}
|
||||
|
||||
fun onDisconnect(session: WebSocketSession, code: Int, reason: String) {}
|
||||
|
||||
fun onError(session: WebSocketSession, error: Throwable) {}
|
||||
}
|
||||
|
||||
@Target(AnnotationTarget.CLASS)
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
annotation class Gateway(val path: String = "/")
|
||||
|
||||
class WebSocketSession(
|
||||
val id: String = UUID.randomUUID().toString(),
|
||||
private val sender: (String) -> Unit,
|
||||
private val closer: (Int, String) -> Unit,
|
||||
) {
|
||||
var isOpen: Boolean = true
|
||||
private set
|
||||
|
||||
fun send(message: String) {
|
||||
check(isOpen) { "WebSocket session is closed" }
|
||||
sender(message)
|
||||
}
|
||||
|
||||
fun close(code: Int = 1000, reason: String = "") {
|
||||
if (!isOpen) return
|
||||
isOpen = false
|
||||
closer(code, reason)
|
||||
}
|
||||
|
||||
internal fun markClosed() {
|
||||
isOpen = false
|
||||
}
|
||||
}
|
||||
|
||||
class WebSocketServer {
|
||||
private val gateways = ConcurrentHashMap<String, WebSocketGateway>()
|
||||
private val sessions = ConcurrentHashMap<String, Pair<WebSocketGateway, WebSocketSession>>()
|
||||
|
||||
fun register(gateway: WebSocketGateway): WebSocketServer = apply {
|
||||
val path = normalize(gateway.path)
|
||||
require(gateways.putIfAbsent(path, gateway) == null) { "Gateway already registered: $path" }
|
||||
}
|
||||
|
||||
fun connect(
|
||||
path: String,
|
||||
send: (String) -> Unit,
|
||||
close: (Int, String) -> Unit = { _, _ -> },
|
||||
): WebSocketSession? {
|
||||
val gateway = gateways[normalize(path)] ?: return null
|
||||
lateinit var session: WebSocketSession
|
||||
session =
|
||||
WebSocketSession(sender = send) { code, reason ->
|
||||
close(code, reason)
|
||||
disconnect(session.id, code, reason)
|
||||
}
|
||||
sessions[session.id] = gateway to session
|
||||
try {
|
||||
gateway.onConnect(session)
|
||||
} catch (error: Throwable) {
|
||||
gateway.onError(session, error)
|
||||
session.close(1011, "Gateway error")
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
fun receive(sessionId: String, message: String) {
|
||||
val (gateway, session) = sessions[sessionId] ?: return
|
||||
if (!session.isOpen) return
|
||||
try {
|
||||
gateway.onMessage(session, message)
|
||||
} catch (error: Throwable) {
|
||||
gateway.onError(session, error)
|
||||
}
|
||||
}
|
||||
|
||||
fun disconnect(sessionId: String, code: Int = 1000, reason: String = "") {
|
||||
val pair = sessions.remove(sessionId) ?: return
|
||||
val (gateway, session) = pair
|
||||
session.markClosed()
|
||||
gateway.onDisconnect(session, code, reason)
|
||||
}
|
||||
|
||||
fun broadcast(path: String, message: String) {
|
||||
val target = gateways[normalize(path)] ?: return
|
||||
sessions.values.filter { it.first === target }.forEach { it.second.send(message) }
|
||||
}
|
||||
|
||||
fun heartbeat(path: String, payload: String = "ping") {
|
||||
broadcast(path, payload)
|
||||
}
|
||||
|
||||
fun heartbeat(
|
||||
path: String,
|
||||
nowMillis: Long,
|
||||
intervalMillis: Long,
|
||||
state: HeartbeatState,
|
||||
payload: String = "ping",
|
||||
) {
|
||||
require(intervalMillis > 0) { "Heartbeat interval must be positive" }
|
||||
if (nowMillis - state.lastSentAtMillis >= intervalMillis) {
|
||||
heartbeat(path, payload)
|
||||
state.lastSentAtMillis = nowMillis
|
||||
}
|
||||
}
|
||||
|
||||
private fun normalize(path: String): String =
|
||||
"/" + path.trim().trim('/').let { if (it.isEmpty()) "" else it }
|
||||
}
|
||||
|
||||
class HeartbeatState(var lastSentAtMillis: Long = Long.MIN_VALUE / 2)
|
||||
111
lib/src/test/kotlin/rip/crit/nestj/LibraryTest.kt
Normal file
111
lib/src/test/kotlin/rip/crit/nestj/LibraryTest.kt
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
package rip.crit.nestj
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
import rip.crit.nestj.gate.Gate
|
||||
import rip.crit.nestj.http.method.Method
|
||||
import rip.crit.nestj.http.request.HTTPRequest
|
||||
import rip.crit.nestj.http.response.HTTPResponse
|
||||
import rip.crit.nestj.http.server.Server
|
||||
import rip.crit.nestj.pipe.HttpPipe
|
||||
import rip.crit.nestj.websocket.WebSocketGateway
|
||||
import rip.crit.nestj.websocket.WebSocketServer
|
||||
|
||||
class LibraryTest {
|
||||
@Test
|
||||
fun someLibraryMethodReturnsTrue() {
|
||||
val classUnderTest = Library()
|
||||
assertTrue(classUnderTest.someLibraryMethod(), "someLibraryMethod should return 'true'")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dispatcherPrefersStaticRoutesAndExposesParameters() {
|
||||
val server =
|
||||
Server()
|
||||
.get("/users/:id") { request ->
|
||||
HTTPResponse(request.getPathParams().getValue("id"), 200, request)
|
||||
}
|
||||
.get("/users/me") { request -> HTTPResponse("me", 200, request) }
|
||||
|
||||
val staticResponse = server.handleRequest(HTTPRequest(Method.GET, "/users/me"))
|
||||
val parameterResponse = server.handleRequest(HTTPRequest(Method.GET, "/users/42?active=true"))
|
||||
|
||||
assertEquals("me", staticResponse.getBody().toString(Charsets.UTF_8))
|
||||
assertEquals("42", parameterResponse.getBody().toString(Charsets.UTF_8))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dispatcherSupportsWildcardAndAllMethods() {
|
||||
val server =
|
||||
Server().all("/assets/*") { request ->
|
||||
HTTPResponse(request.getPathParams().getValue("*"), 200, request)
|
||||
}
|
||||
|
||||
val response = server.handleRequest(HTTPRequest(Method.POST, "/assets/css/site.css"))
|
||||
assertEquals("css/site.css", response.getBody().toString(Charsets.UTF_8))
|
||||
assertEquals(404, server.handleRequest(HTTPRequest(Method.GET, "/missing")).getStatusCode())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun methodSpecificRouteOutranksAnAllRoute() {
|
||||
val server =
|
||||
Server()
|
||||
.all("/health") { request -> HTTPResponse("fallback", 200, request) }
|
||||
.get("/health") { request -> HTTPResponse("ok", 200, request) }
|
||||
|
||||
assertEquals(
|
||||
"ok",
|
||||
server.handleRequest(HTTPRequest(Method.GET, "/health")).getBody().toString(Charsets.UTF_8),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun gatesBlockAndPipesRunBeforeHandlers() {
|
||||
val pipe = HttpPipe { request ->
|
||||
object : rip.crit.nestj.http.request.Request by request {
|
||||
override fun getPath(): String = "/processed"
|
||||
}
|
||||
}
|
||||
val allowed =
|
||||
Server().use(pipe).use(Gate { true }).get("/processed") { request ->
|
||||
HTTPResponse("ok", 200, request)
|
||||
}
|
||||
val denied =
|
||||
Server().use(Gate { false }).get("/") { request -> HTTPResponse("no", 200, request) }
|
||||
|
||||
assertEquals(200, allowed.handleRequest(HTTPRequest(Method.GET, "/original")).getStatusCode())
|
||||
assertEquals(403, denied.handleRequest(HTTPRequest(Method.GET, "/")).getStatusCode())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun websocketGatewayReceivesAndBroadcastsMessages() {
|
||||
val received = mutableListOf<String>()
|
||||
val sent = mutableListOf<String>()
|
||||
val gateway =
|
||||
object : WebSocketGateway {
|
||||
override val path = "/chat"
|
||||
|
||||
override fun onMessage(
|
||||
session: rip.crit.nestj.websocket.WebSocketSession,
|
||||
message: String,
|
||||
) {
|
||||
received += message
|
||||
session.send("echo:$message")
|
||||
}
|
||||
}
|
||||
val server = WebSocketServer().register(gateway)
|
||||
val session = requireNotNull(server.connect("/chat", sent::add))
|
||||
server.receive(session.id, "hello")
|
||||
server.broadcast("/chat", "all")
|
||||
|
||||
assertEquals(listOf("hello"), received)
|
||||
assertEquals(listOf("echo:hello", "all"), sent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun modulesControllersInjectionsAndExtractionsAreWired() {
|
||||
val response = createTestServer().handleRequest(HTTPRequest(Method.GET, "/hello/Ada"))
|
||||
assertEquals("Hello, Ada", response.getBody().toString(Charsets.UTF_8))
|
||||
}
|
||||
}
|
||||
22
lib/src/test/kotlin/rip/crit/nestj/TestServer.kt
Normal file
22
lib/src/test/kotlin/rip/crit/nestj/TestServer.kt
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
package rip.crit.nestj
|
||||
|
||||
import rip.crit.nestj.controller.Controller
|
||||
import rip.crit.nestj.controller.Get
|
||||
import rip.crit.nestj.controller.Param
|
||||
import rip.crit.nestj.inject.Injectable
|
||||
import rip.crit.nestj.module.Module
|
||||
|
||||
@Injectable
|
||||
class GreetingService {
|
||||
fun greeting(name: String) = "Hello, $name"
|
||||
}
|
||||
|
||||
@Controller("/hello")
|
||||
class GreetingController(private val greetings: GreetingService) {
|
||||
@Get("/:name") fun hello(@Param("name") name: String) = greetings.greeting(name)
|
||||
}
|
||||
|
||||
@Module(providers = [GreetingService::class], controllers = [GreetingController::class])
|
||||
class TestServerModule
|
||||
|
||||
fun createTestServer() = Application.create(TestServerModule::class).server
|
||||
22
orm-core/build.gradle.kts
Normal file
22
orm-core/build.gradle.kts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
plugins {
|
||||
alias(libs.plugins.kotlin.jvm)
|
||||
`java-library`
|
||||
`maven-publish`
|
||||
id("com.ncorti.ktfmt.gradle") version "0.27.0"
|
||||
}
|
||||
|
||||
dependencies { api(kotlin("stdlib")) }
|
||||
|
||||
java {
|
||||
toolchain { languageVersion = JavaLanguageVersion.of(25) }
|
||||
withSourcesJar()
|
||||
}
|
||||
|
||||
publishing {
|
||||
publications {
|
||||
create<MavenPublication>("maven") {
|
||||
from(components["java"])
|
||||
artifactId = "nestj-orm-core"
|
||||
}
|
||||
}
|
||||
}
|
||||
62
orm-core/src/main/kotlin/rip/crit/nestj/orm/Database.kt
Normal file
62
orm-core/src/main/kotlin/rip/crit/nestj/orm/Database.kt
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
package rip.crit.nestj.orm
|
||||
|
||||
import java.sql.Connection
|
||||
import javax.sql.DataSource
|
||||
|
||||
interface Database : AutoCloseable {
|
||||
fun <T> transaction(block: (Connection) -> T): T
|
||||
|
||||
fun execute(sql: String, parameters: List<Any?> = emptyList()): Int
|
||||
|
||||
fun query(sql: String, parameters: List<Any?> = emptyList()): List<Row>
|
||||
}
|
||||
|
||||
typealias Row = Map<String, Any?>
|
||||
|
||||
open class JdbcDatabase(private val dataSource: DataSource) : Database {
|
||||
override fun <T> transaction(block: (Connection) -> T): T =
|
||||
dataSource.connection.use { connection ->
|
||||
val previous = connection.autoCommit
|
||||
connection.autoCommit = false
|
||||
try {
|
||||
block(connection).also { connection.commit() }
|
||||
} catch (error: Throwable) {
|
||||
connection.rollback()
|
||||
throw error
|
||||
} finally {
|
||||
connection.autoCommit = previous
|
||||
}
|
||||
}
|
||||
|
||||
override fun execute(sql: String, parameters: List<Any?>): Int =
|
||||
dataSource.connection.use { connection ->
|
||||
connection.prepareStatement(sql).use { statement ->
|
||||
bind(statement, parameters)
|
||||
statement.executeUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
override fun query(sql: String, parameters: List<Any?>): List<Row> =
|
||||
dataSource.connection.use { connection ->
|
||||
connection.prepareStatement(sql).use { statement ->
|
||||
bind(statement, parameters)
|
||||
statement.executeQuery().use { result ->
|
||||
val metadata = result.metaData
|
||||
buildList {
|
||||
while (result.next()) add(
|
||||
(1..metadata.columnCount).associate {
|
||||
metadata.getColumnLabel(it) to result.getObject(it)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {}
|
||||
|
||||
private fun bind(statement: java.sql.PreparedStatement, parameters: List<Any?>) =
|
||||
parameters.forEachIndexed { index, value ->
|
||||
statement.setObject(index + 1, value)
|
||||
}
|
||||
}
|
||||
104
orm-core/src/main/kotlin/rip/crit/nestj/orm/Repository.kt
Normal file
104
orm-core/src/main/kotlin/rip/crit/nestj/orm/Repository.kt
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
package rip.crit.nestj.orm
|
||||
|
||||
/** Parameterized SQL fragments. Values are always bound through PreparedStatement. */
|
||||
data class Sql(val text: String, val parameters: List<Any?> = emptyList()) {
|
||||
operator fun plus(other: Sql) = Sql("$text ${other.text}", parameters + other.parameters)
|
||||
}
|
||||
|
||||
object SqlBuilder {
|
||||
fun select(table: String, columns: List<String> = listOf("*"), where: Sql? = null): Sql =
|
||||
Sql("SELECT ${columns.joinToString(", ")} FROM ${identifier(table)}") +
|
||||
(where?.let { Sql("WHERE ${it.text}", it.parameters) } ?: Sql(""))
|
||||
|
||||
fun insert(table: String, values: Map<String, Any?>): Sql {
|
||||
require(values.isNotEmpty())
|
||||
return Sql(
|
||||
"INSERT INTO ${identifier(table)} (${values.keys.joinToString(", ", transform = ::identifier)}) VALUES (${values.keys.joinToString(", ") { "?" }})",
|
||||
values.values.toList(),
|
||||
)
|
||||
}
|
||||
|
||||
fun whereEquals(column: String, value: Any?) = Sql("${identifier(column)} = ?", listOf(value))
|
||||
|
||||
fun identifier(value: String): String {
|
||||
require(value.matches(Regex("[A-Za-z_][A-Za-z0-9_]*"))) { "Unsafe SQL identifier" }
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
interface Repository<T : Any, ID : Any> {
|
||||
fun findById(id: ID): T?
|
||||
|
||||
fun findAll(): List<T>
|
||||
|
||||
fun save(entity: T): T
|
||||
|
||||
fun deleteById(id: ID): Boolean
|
||||
}
|
||||
|
||||
/** Base repository with explicit mappers, avoiding unsafe reflection-to-SQL conventions. */
|
||||
abstract class JdbcRepository<T : Any, ID : Any>(
|
||||
private val database: Database,
|
||||
private val table: String,
|
||||
private val idColumn: String = "id",
|
||||
) : Repository<T, ID> {
|
||||
abstract fun fromRow(row: Row): T
|
||||
|
||||
abstract fun insertValues(entity: T): Map<String, Any?>
|
||||
|
||||
override fun findById(id: ID): T? =
|
||||
database
|
||||
.query(
|
||||
SqlBuilder.select(table, where = SqlBuilder.whereEquals(idColumn, id)).text,
|
||||
SqlBuilder.whereEquals(idColumn, id).parameters,
|
||||
)
|
||||
.firstOrNull()
|
||||
?.let(::fromRow)
|
||||
|
||||
override fun findAll(): List<T> = database.query(SqlBuilder.select(table).text).map(::fromRow)
|
||||
|
||||
override fun save(entity: T): T {
|
||||
val statement = SqlBuilder.insert(table, insertValues(entity))
|
||||
database.execute(statement.text, statement.parameters)
|
||||
return entity
|
||||
}
|
||||
|
||||
override fun deleteById(id: ID): Boolean =
|
||||
database.execute(
|
||||
"DELETE FROM ${SqlBuilder.identifier(table)} WHERE ${SqlBuilder.identifier(idColumn)} = ?",
|
||||
listOf(id),
|
||||
) > 0
|
||||
}
|
||||
|
||||
interface Migration {
|
||||
val version: Int
|
||||
|
||||
fun up(database: Database)
|
||||
}
|
||||
|
||||
class MigrationRunner(private val database: Database) {
|
||||
fun run(migrations: Iterable<Migration>) {
|
||||
migrations
|
||||
.sortedBy { it.version }
|
||||
.forEach { migration ->
|
||||
database.execute(
|
||||
"CREATE TABLE IF NOT EXISTS nestj_migrations (version INTEGER PRIMARY KEY)"
|
||||
)
|
||||
if (
|
||||
database
|
||||
.query(
|
||||
"SELECT version FROM nestj_migrations WHERE version = ?",
|
||||
listOf(migration.version),
|
||||
)
|
||||
.isEmpty()
|
||||
)
|
||||
database.transaction {
|
||||
migration.up(database)
|
||||
database.execute(
|
||||
"INSERT INTO nestj_migrations (version) VALUES (?)",
|
||||
listOf(migration.version),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
25
orm-postgres/build.gradle.kts
Normal file
25
orm-postgres/build.gradle.kts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
plugins {
|
||||
alias(libs.plugins.kotlin.jvm)
|
||||
`java-library`
|
||||
`maven-publish`
|
||||
id("com.ncorti.ktfmt.gradle") version "0.27.0"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(project(":orm-core"))
|
||||
implementation(libs.postgres.jdbc)
|
||||
}
|
||||
|
||||
java {
|
||||
toolchain { languageVersion = JavaLanguageVersion.of(25) }
|
||||
withSourcesJar()
|
||||
}
|
||||
|
||||
publishing {
|
||||
publications {
|
||||
create<MavenPublication>("maven") {
|
||||
from(components["java"])
|
||||
artifactId = "nestj-orm-postgres"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
package rip.crit.nestj.orm.postgres
|
||||
|
||||
import org.postgresql.ds.PGSimpleDataSource
|
||||
import rip.crit.nestj.orm.JdbcDatabase
|
||||
|
||||
class PostgresDatabase(url: String, user: String, password: String) :
|
||||
JdbcDatabase(
|
||||
PGSimpleDataSource().apply {
|
||||
setURL(url)
|
||||
this.user = user
|
||||
this.password = password
|
||||
}
|
||||
)
|
||||
25
orm-sqlite/build.gradle.kts
Normal file
25
orm-sqlite/build.gradle.kts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
plugins {
|
||||
alias(libs.plugins.kotlin.jvm)
|
||||
`java-library`
|
||||
`maven-publish`
|
||||
id("com.ncorti.ktfmt.gradle") version "0.27.0"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(project(":orm-core"))
|
||||
implementation(libs.sqlite.jdbc)
|
||||
}
|
||||
|
||||
java {
|
||||
toolchain { languageVersion = JavaLanguageVersion.of(25) }
|
||||
withSourcesJar()
|
||||
}
|
||||
|
||||
publishing {
|
||||
publications {
|
||||
create<MavenPublication>("maven") {
|
||||
from(components["java"])
|
||||
artifactId = "nestj-orm-sqlite"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package rip.crit.nestj.orm.sqlite
|
||||
|
||||
import org.sqlite.SQLiteDataSource
|
||||
import rip.crit.nestj.orm.JdbcDatabase
|
||||
|
||||
class SqliteDatabase(path: String) :
|
||||
JdbcDatabase(SQLiteDataSource().apply { url = "jdbc:sqlite:$path" })
|
||||
5
package.json
Normal file
5
package.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"dependencies": {
|
||||
"ws": "^8.21.3"
|
||||
}
|
||||
}
|
||||
25
pom.xml
Normal file
25
pom.xml
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<project
|
||||
xmlns="http://maven.apache.org/POM/4.0.0">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<groupId>rip.crit</groupId>
|
||||
<artifactId>nestj</artifactId>
|
||||
<version>0.1.0-SNAPSHOT</version>
|
||||
<packaging>pom</packaging>
|
||||
<name>NestJ</name>
|
||||
<modules>
|
||||
<module>lib</module>
|
||||
<module>socketio</module>
|
||||
<module>orm-core</module>
|
||||
<module>orm-sqlite</module>
|
||||
<module>orm-postgres</module>
|
||||
<module>servlet-compat</module>
|
||||
<module>vaadin</module>
|
||||
<module>activitypub</module>
|
||||
</modules>
|
||||
<distributionManagement>
|
||||
<repository>
|
||||
<id>forgejo</id>
|
||||
<url>https://ai.crit.rip/api/packages/jprims/maven</url>
|
||||
</repository>
|
||||
</distributionManagement>
|
||||
</project>
|
||||
25
servlet-compat/build.gradle.kts
Normal file
25
servlet-compat/build.gradle.kts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
plugins {
|
||||
alias(libs.plugins.kotlin.jvm)
|
||||
`java-library`
|
||||
`maven-publish`
|
||||
id("com.ncorti.ktfmt.gradle") version "0.27.0"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(project(":lib"))
|
||||
api(libs.jakarta.servlet)
|
||||
}
|
||||
|
||||
java {
|
||||
toolchain { languageVersion = JavaLanguageVersion.of(25) }
|
||||
withSourcesJar()
|
||||
}
|
||||
|
||||
publishing {
|
||||
publications {
|
||||
create<MavenPublication>("maven") {
|
||||
from(components["java"])
|
||||
artifactId = "nestj-servlet-compat"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,198 @@
|
|||
package rip.crit.nestj.servlet
|
||||
|
||||
import jakarta.servlet.ServletConfig
|
||||
import jakarta.servlet.ServletContext
|
||||
import jakarta.servlet.ServletInputStream
|
||||
import jakarta.servlet.ServletOutputStream
|
||||
import jakarta.servlet.http.HttpServletRequest
|
||||
import jakarta.servlet.http.HttpServletResponse
|
||||
import jakarta.servlet.http.HttpSession
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.PrintWriter
|
||||
import java.lang.reflect.InvocationHandler
|
||||
import java.lang.reflect.Proxy
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import rip.crit.nestj.http.request.Request
|
||||
import rip.crit.nestj.http.response.HTTPResponse
|
||||
|
||||
/** Servlet session store suitable for Vaadin's VaadinSession attributes. */
|
||||
class SessionStore {
|
||||
private val sessions = ConcurrentHashMap<String, MutableMap<String, Any>>()
|
||||
|
||||
fun session(id: String = UUID.randomUUID().toString()): Pair<String, HttpSession> {
|
||||
val attributes = sessions.computeIfAbsent(id) { ConcurrentHashMap() }
|
||||
return id to
|
||||
proxy(HttpSession::class.java) { method, args ->
|
||||
when (method.name) {
|
||||
"getId" -> id
|
||||
"getAttribute" -> attributes[args!![0] as String]
|
||||
"setAttribute" -> {
|
||||
attributes[args!![0] as String] = args[1]!!
|
||||
null
|
||||
}
|
||||
"removeAttribute" -> {
|
||||
attributes.remove(args!![0] as String)
|
||||
null
|
||||
}
|
||||
"invalidate" -> {
|
||||
sessions.remove(id)
|
||||
null
|
||||
}
|
||||
"getAttributeNames" -> java.util.Collections.enumeration(attributes.keys)
|
||||
"isNew" -> false
|
||||
else -> default(method.returnType)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ServletBridge(
|
||||
private val contextPath: String = "",
|
||||
private val sessions: SessionStore = SessionStore(),
|
||||
) {
|
||||
val context: ServletContext =
|
||||
proxy(ServletContext::class.java) { method, args ->
|
||||
when (method.name) {
|
||||
"getContextPath" -> contextPath
|
||||
"log" -> {
|
||||
if (args?.isNotEmpty() == true) System.err.println(args[0])
|
||||
null
|
||||
}
|
||||
"getRealPath" -> null
|
||||
else -> default(method.returnType)
|
||||
}
|
||||
}
|
||||
|
||||
fun servletConfig(name: String): ServletConfig =
|
||||
proxy(ServletConfig::class.java) { method, _ ->
|
||||
when (method.name) {
|
||||
"getServletName" -> name
|
||||
"getServletContext" -> context
|
||||
"getInitParameterNames" -> java.util.Collections.emptyEnumeration<String>()
|
||||
else -> default(method.returnType)
|
||||
}
|
||||
}
|
||||
|
||||
fun request(request: Request): HttpServletRequest {
|
||||
val sessionId =
|
||||
request
|
||||
.getHeaders()
|
||||
.entries
|
||||
.firstOrNull { it.key.equals("cookie", true) }
|
||||
?.value
|
||||
?.split(';')
|
||||
?.map { it.trim() }
|
||||
?.firstOrNull { it.startsWith("JSESSIONID=") }
|
||||
?.substringAfter('=')
|
||||
val (id, session) = sessions.session(sessionId ?: UUID.randomUUID().toString())
|
||||
return proxy(HttpServletRequest::class.java) { method, args ->
|
||||
when (method.name) {
|
||||
"getMethod" -> request.getMethod().name
|
||||
"getRequestURI" -> request.getPath()
|
||||
"getContextPath" -> contextPath
|
||||
"getSession" -> session
|
||||
"getHeader" ->
|
||||
request
|
||||
.getHeaders()
|
||||
.entries
|
||||
.firstOrNull { it.key.equals(args!![0] as String, true) }
|
||||
?.value
|
||||
"getHeaderNames" -> java.util.Collections.enumeration(request.getHeaders().keys)
|
||||
"getInputStream" -> input(request.getBody())
|
||||
"getCharacterEncoding" -> "UTF-8"
|
||||
"getContentType" -> request.getHeaders()["Content-Type"]
|
||||
"getRequestedSessionId" -> id
|
||||
"getQueryString" ->
|
||||
request.getQueryParams().entries.joinToString("&") { "${it.key}=${it.value}" }
|
||||
"getParameter" -> request.getQueryParams()[args!![0] as String]
|
||||
"getParameterMap" -> request.getQueryParams().mapValues { arrayOf(it.value) }
|
||||
else -> default(method.returnType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun response(request: Request): BufferedServletResponse = BufferedServletResponse(request)
|
||||
}
|
||||
|
||||
class BufferedServletResponse(private val request: Request) {
|
||||
private val bytes = ByteArrayOutputStream()
|
||||
private val headers = linkedMapOf<String, String>()
|
||||
private var status = 200
|
||||
private var contentType = "text/plain"
|
||||
val servlet: HttpServletResponse =
|
||||
proxy(HttpServletResponse::class.java) { method, args ->
|
||||
when (method.name) {
|
||||
"setStatus" -> {
|
||||
status = args!![0] as Int
|
||||
null
|
||||
}
|
||||
"setContentType" -> {
|
||||
contentType = args!![0] as String
|
||||
null
|
||||
}
|
||||
"setHeader",
|
||||
"addHeader" -> {
|
||||
headers[args!![0] as String] = args[1] as String
|
||||
null
|
||||
}
|
||||
"getOutputStream" -> output(bytes)
|
||||
"getWriter" -> PrintWriter(bytes, true, Charsets.UTF_8)
|
||||
"getStatus" -> status
|
||||
"getContentType" -> contentType
|
||||
else -> default(method.returnType)
|
||||
}
|
||||
}
|
||||
|
||||
fun toNestjResponse() =
|
||||
HTTPResponse(
|
||||
bytes.toByteArray(),
|
||||
HashMap(headers + ("Content-Type" to contentType)),
|
||||
status,
|
||||
contentType,
|
||||
request,
|
||||
)
|
||||
}
|
||||
|
||||
private fun input(bytes: ByteArray) =
|
||||
object : ServletInputStream() {
|
||||
private val input = ByteArrayInputStream(bytes)
|
||||
|
||||
override fun read() = input.read()
|
||||
|
||||
override fun isFinished() = input.available() == 0
|
||||
|
||||
override fun isReady() = true
|
||||
|
||||
override fun setReadListener(listener: jakarta.servlet.ReadListener?) {}
|
||||
}
|
||||
|
||||
private fun output(bytes: ByteArrayOutputStream) =
|
||||
object : ServletOutputStream() {
|
||||
override fun write(value: Int) = bytes.write(value)
|
||||
|
||||
override fun isReady() = true
|
||||
|
||||
override fun setWriteListener(listener: jakarta.servlet.WriteListener?) {}
|
||||
}
|
||||
|
||||
private fun <T> proxy(
|
||||
type: Class<T>,
|
||||
action: (java.lang.reflect.Method, Array<out Any?>?) -> Any?,
|
||||
): T =
|
||||
Proxy.newProxyInstance(
|
||||
type.classLoader,
|
||||
arrayOf(type),
|
||||
InvocationHandler { _, method, args -> action(method, args) },
|
||||
) as T
|
||||
|
||||
private fun default(type: Class<*>): Any? =
|
||||
when {
|
||||
!type.isPrimitive -> null
|
||||
type == Boolean::class.javaPrimitiveType -> false
|
||||
type == Int::class.javaPrimitiveType -> 0
|
||||
type == Long::class.javaPrimitiveType -> 0L
|
||||
type == Double::class.javaPrimitiveType -> 0.0
|
||||
else -> 0
|
||||
}
|
||||
22
settings.gradle.kts
Normal file
22
settings.gradle.kts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
/*
|
||||
* This file was generated by the Gradle 'init' task.
|
||||
*
|
||||
* The settings file is used to specify which projects to include in your build.
|
||||
* For more detailed information on multi-project builds, please refer to https://docs.gradle.org/9.7.1/userguide/multi_project_builds.html in the Gradle documentation.
|
||||
* This project uses @Incubating APIs which are subject to change.
|
||||
*/
|
||||
|
||||
plugins {
|
||||
// Apply the foojay-resolver plugin to allow automatic download of JDKs
|
||||
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
|
||||
}
|
||||
|
||||
rootProject.name = "nestj"
|
||||
include("lib")
|
||||
include("socketio")
|
||||
include("orm-core")
|
||||
include("orm-sqlite")
|
||||
include("orm-postgres")
|
||||
include("servlet-compat")
|
||||
include("vaadin")
|
||||
include("activitypub")
|
||||
25
socketio/build.gradle.kts
Normal file
25
socketio/build.gradle.kts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
plugins {
|
||||
alias(libs.plugins.kotlin.jvm)
|
||||
`java-library`
|
||||
`maven-publish`
|
||||
id("com.ncorti.ktfmt.gradle") version "0.27.0"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(project(":lib"))
|
||||
implementation(libs.netty.socketio)
|
||||
}
|
||||
|
||||
java {
|
||||
toolchain { languageVersion = JavaLanguageVersion.of(25) }
|
||||
withSourcesJar()
|
||||
}
|
||||
|
||||
publishing {
|
||||
publications {
|
||||
create<MavenPublication>("maven") {
|
||||
from(components["java"])
|
||||
artifactId = "nestj-socketio"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
package rip.crit.nestj.socketio
|
||||
|
||||
import com.corundumstudio.socketio.Configuration
|
||||
import com.corundumstudio.socketio.SocketIOServer
|
||||
import rip.crit.nestj.websocket.WebSocketGateway
|
||||
import rip.crit.nestj.websocket.WebSocketSession
|
||||
|
||||
class SocketIoGatewayServer(private val host: String = "0.0.0.0", private val port: Int = 3000) :
|
||||
AutoCloseable {
|
||||
private val server =
|
||||
SocketIOServer(
|
||||
Configuration().apply {
|
||||
hostname = host
|
||||
this.port = port
|
||||
}
|
||||
)
|
||||
|
||||
fun register(gateway: WebSocketGateway, event: String = "message"): SocketIoGatewayServer =
|
||||
apply {
|
||||
val sessions = mutableMapOf<String, WebSocketSession>()
|
||||
server.addConnectListener { client ->
|
||||
val session =
|
||||
WebSocketSession(
|
||||
client.sessionId.toString(),
|
||||
{ message -> client.sendEvent(event, message) },
|
||||
{ _, _ -> client.disconnect() },
|
||||
)
|
||||
sessions[session.id] = session
|
||||
gateway.onConnect(session)
|
||||
}
|
||||
server.addDisconnectListener { client ->
|
||||
sessions.remove(client.sessionId.toString())?.let {
|
||||
gateway.onDisconnect(it, 1000, "disconnect")
|
||||
}
|
||||
}
|
||||
server.addEventListener(event, String::class.java) { client, data, _ ->
|
||||
sessions[client.sessionId.toString()]?.let { session ->
|
||||
try {
|
||||
gateway.onMessage(session, data)
|
||||
} catch (error: Throwable) {
|
||||
gateway.onError(session, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun start() = server.start()
|
||||
|
||||
fun stop() = server.stop()
|
||||
|
||||
override fun close() = stop()
|
||||
}
|
||||
25
vaadin/build.gradle.kts
Normal file
25
vaadin/build.gradle.kts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
plugins {
|
||||
alias(libs.plugins.kotlin.jvm)
|
||||
`java-library`
|
||||
`maven-publish`
|
||||
id("com.ncorti.ktfmt.gradle") version "0.27.0"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(project(":servlet-compat"))
|
||||
api(libs.vaadin.core)
|
||||
}
|
||||
|
||||
java {
|
||||
toolchain { languageVersion = JavaLanguageVersion.of(25) }
|
||||
withSourcesJar()
|
||||
}
|
||||
|
||||
publishing {
|
||||
publications {
|
||||
create<MavenPublication>("maven") {
|
||||
from(components["java"])
|
||||
artifactId = "nestj-vaadin"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
package rip.crit.nestj.vaadin
|
||||
|
||||
import com.vaadin.flow.server.VaadinServlet
|
||||
import jakarta.servlet.ServletConfig
|
||||
import rip.crit.nestj.http.request.Request
|
||||
import rip.crit.nestj.http.response.Response
|
||||
import rip.crit.nestj.servlet.ServletBridge
|
||||
|
||||
class VaadinService(
|
||||
private val servlet: VaadinServlet,
|
||||
private val bridge: ServletBridge = ServletBridge(),
|
||||
) {
|
||||
fun initialize(config: ServletConfig = bridge.servletConfig("NestJVaadin")): VaadinService =
|
||||
apply {
|
||||
servlet.init(config)
|
||||
}
|
||||
|
||||
fun handle(request: Request): Response {
|
||||
val response = bridge.response(request)
|
||||
servlet.service(bridge.request(request), response.servlet)
|
||||
return response.toNestjResponse()
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue