weeks of bullshit
This commit is contained in:
Generated
+6
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="AgentMigrationStateService">
|
||||||
|
<option name="migrationStatus" value="COMPLETED" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
Generated
+6
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="AskMigrationStateService">
|
||||||
|
<option name="migrationStatus" value="COMPLETED" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
Generated
+6
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="EditMigrationStateService">
|
||||||
|
<option name="migrationStatus" value="COMPLETED" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
Generated
+2
@@ -17,6 +17,8 @@
|
|||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/resources" charset="UTF-8" />
|
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/resources" charset="UTF-8" />
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-view/src/main/java" charset="UTF-8" />
|
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-view/src/main/java" charset="UTF-8" />
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-view/src/main/resources" charset="UTF-8" />
|
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-view/src/main/resources" charset="UTF-8" />
|
||||||
|
<file url="file://$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java" charset="UTF-8" />
|
||||||
|
<file url="file://$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/resources" charset="UTF-8" />
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/src/main/java" charset="UTF-8" />
|
<file url="file://$PROJECT_DIR$/flash-extensions/src/main/java" charset="UTF-8" />
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/src/main/resources" charset="UTF-8" />
|
<file url="file://$PROJECT_DIR$/flash-extensions/src/main/resources" charset="UTF-8" />
|
||||||
<file url="file://$PROJECT_DIR$/flash/src/main/java" charset="UTF-8" />
|
<file url="file://$PROJECT_DIR$/flash/src/main/java" charset="UTF-8" />
|
||||||
|
|||||||
Generated
+128
-147
@@ -4,76 +4,65 @@
|
|||||||
<option name="autoReloadType" value="SELECTIVE" />
|
<option name="autoReloadType" value="SELECTIVE" />
|
||||||
</component>
|
</component>
|
||||||
<component name="ChangeListManager">
|
<component name="ChangeListManager">
|
||||||
<list default="true" id="fc757130-fe3e-4ea9-8d69-c26ffb8545a4" name="Changes" comment="preparing for a conceptual refactoring...">
|
<list default="true" id="fc757130-fe3e-4ea9-8d69-c26ffb8545a4" name="Changes" comment="preparing for another refactoring...">
|
||||||
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-jackson/src/main/java/dev/relism/ext/jackson/Json.java" afterDir="false" />
|
|
||||||
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/docs/README.md" afterDir="false" />
|
|
||||||
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/docs/annotation.md" afterDir="false" />
|
|
||||||
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/docs/guard.md" afterDir="false" />
|
|
||||||
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/docs/http-headers.md" afterDir="false" />
|
|
||||||
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/docs/key-resolvers.md" afterDir="false" />
|
|
||||||
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/docs/strategies.md" afterDir="false" />
|
|
||||||
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/pom.xml" afterDir="false" />
|
|
||||||
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/Bucket.java" afterDir="false" />
|
|
||||||
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/BucketStore.java" afterDir="false" />
|
|
||||||
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/Guard.java" afterDir="false" />
|
|
||||||
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/KeyResolver.java" afterDir="false" />
|
|
||||||
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/Limit.java" afterDir="false" />
|
|
||||||
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/LimitConfig.java" afterDir="false" />
|
|
||||||
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/LimitStrategy.java" afterDir="false" />
|
|
||||||
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/LimiterConfig.java" afterDir="false" />
|
|
||||||
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/LimiterExtension.java" afterDir="false" />
|
|
||||||
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/RateLimitStrategy.java" afterDir="false" />
|
|
||||||
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/strategy/FixedWindowStrategy.java" afterDir="false" />
|
|
||||||
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/strategy/TokenBucketStrategy.java" afterDir="false" />
|
|
||||||
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/routeviewer-ui/pnpm-lock.yaml" afterDir="false" />
|
|
||||||
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view/pom.xml" afterDir="false" />
|
|
||||||
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/Renderer.java" afterDir="false" />
|
|
||||||
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/Template.java" afterDir="false" />
|
|
||||||
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/ThymeleafEngine.java" afterDir="false" />
|
|
||||||
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/View.java" afterDir="false" />
|
|
||||||
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/ViewEngine.java" afterDir="false" />
|
|
||||||
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/ViewEngineType.java" afterDir="false" />
|
|
||||||
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/ViewExtension.java" afterDir="false" />
|
|
||||||
<change afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/exceptions/InitializationException.java" afterDir="false" />
|
|
||||||
<change afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/FlashContext.java" afterDir="false" />
|
|
||||||
<change afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/RouteDefinition.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/.idea/encodings.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/encodings.xml" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/.idea/encodings.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/encodings.xml" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/README.md" beforeDir="false" afterPath="$PROJECT_DIR$/README.md" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-jackson/README.md" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-jackson/README.md" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-jackson/src/main/java/dev/relism/ext/jackson/JacksonExtension.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-jackson/src/main/java/dev/relism/ext/jackson/JacksonExtension.java" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-jackson/src/main/java/dev/relism/ext/jackson/JacksonExtension.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-jackson/src/main/java/dev/relism/ext/jackson/JacksonExtension.java" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-jackson/src/main/java/dev/relism/ext/jackson/JacksonHandler.java" beforeDir="false" />
|
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-jackson/src/main/java/dev/relism/ext/jackson/Json.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-jackson/src/main/java/dev/relism/ext/jackson/Json.java" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/Bucket.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/Bucket.java" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/Guard.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/Guard.java" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/LimitStrategy.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/LimitStrategy.java" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/LimiterExtension.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/LimiterExtension.java" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/RateLimitStrategy.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/RateLimitStrategy.java" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/strategy/TokenBucketStrategy.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/strategy/TokenBucketStrategy.java" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/README.md" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/README.md" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcConfig.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcConfig.java" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcExtension.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcExtension.java" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcExtension.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcExtension.java" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcMiddleware.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcMiddleware.java" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcMiddleware.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcMiddleware.java" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcUser.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcUser.java" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/README.md" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/README.md" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/README.md" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/README.md" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/pom.xml" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/pom.xml" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/ApiOperation.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/ApiOperation.java" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/ApiResponse.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/ApiResponse.java" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/OpenApiBuilder.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/OpenApiBuilder.java" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/OpenApiExtension.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/OpenApiExtension.java" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/OpenApiExtension.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/OpenApiExtension.java" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/OpenApiSecurityContributor.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/OpenApiSecurityContributor.java" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/ext/routeviewer/RouteViewerDataHandler.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/ext/routeviewer/RouteViewerDataHandler.java" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/OpenApiSecurityRegistry.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/OpenApiSecurityRegistry.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/routeviewer-ui/src/layout.js" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/routeviewer-ui/src/layout.js" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/routeviewer-ui/src/nodes/HandlerNode.jsx" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/routeviewer-ui/src/nodes/HandlerNode.jsx" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/ext/routeviewer/RouteViewerExtension.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/ext/routeviewer/RouteViewerExtension.java" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/ext/routeviewer/RouteViewerExtension.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/ext/routeviewer/RouteViewerExtension.java" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/ext/routeviewer/model/RouteRecord.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/ext/routeviewer/model/RouteRecord.java" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/ext/routeviewer/RouteViewerHandler.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/ext/routeviewer/RouteViewerHandler.java" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/resources/routeviewer/app.js" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/resources/routeviewer/app.js" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/ext/routeviewer/RouteViewerStaticHandler.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/ext/routeviewer/RouteViewerStaticHandler.java" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/Renderer.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/Renderer.java" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/ViewExtension.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/ViewExtension.java" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/pom.xml" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/pom.xml" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/flash-extensions/pom.xml" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/pom.xml" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/flash/pom.xml" beforeDir="false" afterPath="$PROJECT_DIR$/flash/pom.xml" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/ChunkedInputStream.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/ChunkedInputStream.java" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/Flash.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/Flash.java" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/HttpServer.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/HttpServer.java" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/HttpServer.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/HttpServer.java" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/RequestParser.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/RequestParser.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/ServerHandle.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/ServerHandle.java" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/ServerHandle.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/ServerHandle.java" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/exceptions/DuplicateNamespaceException.java" beforeDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/AnnotationProcessor.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/AnnotationProcessor.java" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/AnnotationProcessor.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/AnnotationProcessor.java" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/ExtensionContext.java" beforeDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/FlashApp.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/FlashApp.java" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/FlashApp.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/FlashApp.java" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/FlashContext.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/FlashContext.java" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/FlashExtension.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/FlashExtension.java" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/FlashExtension.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/FlashExtension.java" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/FlashRegistrar.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/FlashRegistrar.java" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/FlashRegistrar.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/FlashRegistrar.java" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/FlashScope.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/FlashScope.java" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/FlashScope.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/FlashScope.java" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/PackageScanner.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/PackageScanner.java" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/PackageScanner.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/PackageScanner.java" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/RouteListener.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/RouteListener.java" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/RouteDefinition.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/RouteDefinition.java" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/RouteEvent.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/RouteEvent.java" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/models/HeaderMap.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/models/HeaderMap.java" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/models/PathParams.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/models/PathParams.java" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/models/Request.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/models/Request.java" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/models/Request.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/models/Request.java" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/models/RequestBody.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/models/RequestBody.java" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/models/RequestHandler.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/models/RequestHandler.java" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/models/RequestHandler.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/models/RequestHandler.java" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/models/Response.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/models/Response.java" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/models/SimpleHandler.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/models/SimpleHandler.java" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/AbstractRouter.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/AbstractRouter.java" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/AbstractRouter.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/AbstractRouter.java" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/GlobalRouter.java" beforeDir="false" />
|
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/Middleware.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/Middleware.java" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/routers/fastpathrouter/FastPathRouterImpl.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/routers/fastpathrouter/FastPathRouterImpl.java" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/Route.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/Route.java" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/RouteHandle.java" beforeDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/HttpServerConcurrencyTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/HttpServerConcurrencyTest.java" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/HttpServerTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/HttpServerTest.java" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/models/ResponseTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/models/ResponseTest.java" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/routing/AbstractRouterTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/routing/AbstractRouterTest.java" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/routing/AbstractRouterTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/routing/AbstractRouterTest.java" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/routing/GlobalRouterTest.java" beforeDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/routing/routers/fastpathrouter/FastPathRouterImplTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/routing/routers/fastpathrouter/FastPathRouterImplTest.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/pom.xml" beforeDir="false" afterPath="$PROJECT_DIR$/pom.xml" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/pom.xml" beforeDir="false" afterPath="$PROJECT_DIR$/pom.xml" afterDir="false" />
|
||||||
</list>
|
</list>
|
||||||
<option name="SHOW_DIALOG" value="false" />
|
<option name="SHOW_DIALOG" value="false" />
|
||||||
@@ -88,7 +77,7 @@
|
|||||||
</persistenceIdMap>
|
</persistenceIdMap>
|
||||||
</component>
|
</component>
|
||||||
<component name="EmbeddingIndexingInfo">
|
<component name="EmbeddingIndexingInfo">
|
||||||
<option name="cachedIndexableFilesCount" value="226" />
|
<option name="cachedIndexableFilesCount" value="287" />
|
||||||
<option name="fileBasedEmbeddingIndicesEnabled" value="true" />
|
<option name="fileBasedEmbeddingIndicesEnabled" value="true" />
|
||||||
</component>
|
</component>
|
||||||
<component name="FileTemplateManagerImpl">
|
<component name="FileTemplateManagerImpl">
|
||||||
@@ -127,52 +116,57 @@
|
|||||||
<option name="hideEmptyMiddlePackages" value="true" />
|
<option name="hideEmptyMiddlePackages" value="true" />
|
||||||
<option name="showLibraryContents" value="true" />
|
<option name="showLibraryContents" value="true" />
|
||||||
</component>
|
</component>
|
||||||
<component name="PropertiesComponent">{
|
<component name="PropertiesComponent"><![CDATA[{
|
||||||
"keyToString": {
|
"keyToString": {
|
||||||
"Application.ExternalBenchmark (1).executor": "Run",
|
"Application.(dev) flash-bench.executor": "Run",
|
||||||
"Application.ExternalBenchmark.executor": "Run",
|
"Application.ExternalBenchmark (1).executor": "Run",
|
||||||
"Application.Main.executor": "Run",
|
"Application.ExternalBenchmark.executor": "Run",
|
||||||
"Application.dev.relism.bench.Main.executor": "Run",
|
"Application.Main.executor": "Run",
|
||||||
"JUnit.RequestParserTest.executor": "Run",
|
"Application.MainAlt.executor": "Run",
|
||||||
"JUnit.RequestParserTest.headers_caseInsensitive.executor": "Debug",
|
"Application.dev.relism.bench.Main.executor": "Run",
|
||||||
"Maven.FlashPractice [test].executor": "Run",
|
"JUnit.RequestParserTest.executor": "Run",
|
||||||
"Maven.flash [compile].executor": "Run",
|
"JUnit.RequestParserTest.headers_caseInsensitive.executor": "Debug",
|
||||||
"Maven.flash [install].executor": "Run",
|
"Maven.FlashPractice [test].executor": "Run",
|
||||||
"Maven.flash [test].executor": "Run",
|
"Maven.flash [compile].executor": "Run",
|
||||||
"Maven.flash-bench [clean].executor": "Run",
|
"Maven.flash [install].executor": "Run",
|
||||||
"Maven.flash-bench [install].executor": "Run",
|
"Maven.flash [test].executor": "Run",
|
||||||
"Maven.flash-bench [package].executor": "Run",
|
"Maven.flash-bench [clean].executor": "Run",
|
||||||
"Maven.flash-bench [validate].executor": "Run",
|
"Maven.flash-bench [install].executor": "Run",
|
||||||
"Maven.flash-ext-limiter [install].executor": "Run",
|
"Maven.flash-bench [package].executor": "Run",
|
||||||
"Maven.flash-ext-limiter [package].executor": "Run",
|
"Maven.flash-bench [validate].executor": "Run",
|
||||||
"Maven.flash-parent [install].executor": "Run",
|
"Maven.flash-ext-limiter [install].executor": "Run",
|
||||||
"Maven.flash-parent [package].executor": "Run",
|
"Maven.flash-ext-limiter [package].executor": "Run",
|
||||||
"ModuleVcsDetector.initialDetectionPerformed": "true",
|
"Maven.flash-parent [compile].executor": "Run",
|
||||||
"RunOnceActivity.MCP Project settings loaded": "true",
|
"Maven.flash-parent [deploy].executor": "Run",
|
||||||
"RunOnceActivity.ShowReadmeOnStart": "true",
|
"Maven.flash-parent [install].executor": "Run",
|
||||||
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
|
"Maven.flash-parent [package].executor": "Run",
|
||||||
"RunOnceActivity.git.unshallow": "true",
|
"Maven.flash-web-bundler [test].executor": "Run",
|
||||||
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
|
"ModuleVcsDetector.initialDetectionPerformed": "true",
|
||||||
"SHARE_PROJECT_CONFIGURATION_FILES": "true",
|
"RunOnceActivity.MCP Project settings loaded": "true",
|
||||||
"git-widget-placeholder": "master",
|
"RunOnceActivity.ShowReadmeOnStart": "true",
|
||||||
"ignore.virus.scanning.warn.message": "true",
|
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
|
||||||
"kotlin-language-version-configured": "true",
|
"RunOnceActivity.git.unshallow": "true",
|
||||||
"last_opened_file_path": "C:/Users/elorc/Documents/Coding/Java/practice/Flash",
|
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
|
||||||
"node.js.detected.package.eslint": "true",
|
"SHARE_PROJECT_CONFIGURATION_FILES": "true",
|
||||||
"node.js.detected.package.tslint": "true",
|
"git-widget-placeholder": "master",
|
||||||
"node.js.selected.package.eslint": "(autodetect)",
|
"ignore.virus.scanning.warn.message": "true",
|
||||||
"node.js.selected.package.tslint": "(autodetect)",
|
"kotlin-language-version-configured": "true",
|
||||||
"nodejs_package_manager_path": "npm",
|
"last_opened_file_path": "C:/Users/elorc/Documents/Coding/Java/practice/Flash",
|
||||||
"npm.build.executor": "Run",
|
"node.js.detected.package.eslint": "true",
|
||||||
"onboarding.tips.debug.path": "C:/Users/elorc/Documents/Coding/Java/practice/FlashPractice/flash-bench/src/main/java/dev/relism/Main.java",
|
"node.js.detected.package.tslint": "true",
|
||||||
"project.structure.last.edited": "Modules",
|
"node.js.selected.package.eslint": "(autodetect)",
|
||||||
"project.structure.proportion": "0.15",
|
"node.js.selected.package.tslint": "(autodetect)",
|
||||||
"project.structure.side.proportion": "0.1150748",
|
"nodejs_package_manager_path": "npm",
|
||||||
"settings.editor.selected.configurable": "project.propVCSSupport.DirectoryMappings",
|
"npm.build.executor": "Run",
|
||||||
"ts.external.directory.path": "C:\\Users\\elorc\\Documents\\Coding\\Java\\practice\\Flash\\nuxt-shadcn-dashboard\\node_modules\\typescript\\lib",
|
"onboarding.tips.debug.path": "C:/Users/elorc/Documents/Coding/Java/practice/FlashPractice/flash-bench/src/main/java/dev/relism/Main.java",
|
||||||
"vue.rearranger.settings.migration": "true"
|
"project.structure.last.edited": "Modules",
|
||||||
|
"project.structure.proportion": "0.15",
|
||||||
|
"project.structure.side.proportion": "0.1150748",
|
||||||
|
"settings.editor.selected.configurable": "project.propVCSSupport.DirectoryMappings",
|
||||||
|
"ts.external.directory.path": "C:\\Users\\elorc\\Documents\\Coding\\Java\\practice\\Flash\\nuxt-shadcn-dashboard\\node_modules\\typescript\\lib",
|
||||||
|
"vue.rearranger.settings.migration": "true"
|
||||||
}
|
}
|
||||||
}</component>
|
}]]></component>
|
||||||
<component name="RecentsManager">
|
<component name="RecentsManager">
|
||||||
<key name="MoveFile.RECENT_KEYS">
|
<key name="MoveFile.RECENT_KEYS">
|
||||||
<recent name="C:\Users\elorc\Documents\Coding\Java\practice\Flash" />
|
<recent name="C:\Users\elorc\Documents\Coding\Java\practice\Flash" />
|
||||||
@@ -181,13 +175,13 @@
|
|||||||
<recent name="dev.relism" />
|
<recent name="dev.relism" />
|
||||||
</key>
|
</key>
|
||||||
</component>
|
</component>
|
||||||
<component name="RunManager" selected="Application.dev.relism.bench.Main">
|
<component name="RunManager" selected="Application.MainAlt">
|
||||||
<configuration name="ExternalBenchmark (1)" type="Application" factoryName="Application" temporary="true" nameIsGenerated="true">
|
<configuration name="ExternalBenchmark" type="Application" factoryName="Application" temporary="true" nameIsGenerated="true">
|
||||||
<option name="MAIN_CLASS_NAME" value="dev.relism.ExternalBenchmark" />
|
<option name="MAIN_CLASS_NAME" value="dev.relism.bench.ExternalBenchmark" />
|
||||||
<module name="flash" />
|
<module name="flash" />
|
||||||
<extension name="coverage">
|
<extension name="coverage">
|
||||||
<pattern>
|
<pattern>
|
||||||
<option name="PATTERN" value="dev.relism.*" />
|
<option name="PATTERN" value="dev.relism.bench.*" />
|
||||||
<option name="ENABLED" value="true" />
|
<option name="ENABLED" value="true" />
|
||||||
</pattern>
|
</pattern>
|
||||||
</extension>
|
</extension>
|
||||||
@@ -195,9 +189,10 @@
|
|||||||
<option name="Make" enabled="true" />
|
<option name="Make" enabled="true" />
|
||||||
</method>
|
</method>
|
||||||
</configuration>
|
</configuration>
|
||||||
<configuration name="ExternalBenchmark" type="Application" factoryName="Application" temporary="true" nameIsGenerated="true">
|
<configuration name="MainAlt" type="Application" factoryName="Application" temporary="true" nameIsGenerated="true">
|
||||||
<option name="MAIN_CLASS_NAME" value="dev.relism.bench.ExternalBenchmark" />
|
<option name="MAIN_CLASS_NAME" value="dev.relism.bench.MainAlt" />
|
||||||
<module name="flash-bench" />
|
<module name="flash-bench" />
|
||||||
|
<option name="VM_PARAMETERS" value="-Dflash.env=dev" />
|
||||||
<extension name="coverage">
|
<extension name="coverage">
|
||||||
<pattern>
|
<pattern>
|
||||||
<option name="PATTERN" value="dev.relism.bench.*" />
|
<option name="PATTERN" value="dev.relism.bench.*" />
|
||||||
@@ -208,35 +203,10 @@
|
|||||||
<option name="Make" enabled="true" />
|
<option name="Make" enabled="true" />
|
||||||
</method>
|
</method>
|
||||||
</configuration>
|
</configuration>
|
||||||
<configuration name="ExternalBenchmark" type="Application" factoryName="Application" temporary="true" nameIsGenerated="true">
|
<configuration name="(dev) flash-bench" type="Application" factoryName="Application">
|
||||||
<option name="MAIN_CLASS_NAME" value="dev.relism.bench.ExternalBenchmark" />
|
|
||||||
<module name="flash" />
|
|
||||||
<extension name="coverage">
|
|
||||||
<pattern>
|
|
||||||
<option name="PATTERN" value="dev.relism.bench.*" />
|
|
||||||
<option name="ENABLED" value="true" />
|
|
||||||
</pattern>
|
|
||||||
</extension>
|
|
||||||
<method v="2">
|
|
||||||
<option name="Make" enabled="true" />
|
|
||||||
</method>
|
|
||||||
</configuration>
|
|
||||||
<configuration name="Main" type="Application" factoryName="Application" temporary="true" nameIsGenerated="true">
|
|
||||||
<option name="MAIN_CLASS_NAME" value="dev.relism.Main" />
|
|
||||||
<module name="flash" />
|
|
||||||
<extension name="coverage">
|
|
||||||
<pattern>
|
|
||||||
<option name="PATTERN" value="dev.relism.bench.*" />
|
|
||||||
<option name="ENABLED" value="true" />
|
|
||||||
</pattern>
|
|
||||||
</extension>
|
|
||||||
<method v="2">
|
|
||||||
<option name="Make" enabled="true" />
|
|
||||||
</method>
|
|
||||||
</configuration>
|
|
||||||
<configuration name="dev.relism.bench.Main" type="Application" factoryName="Application" temporary="true" nameIsGenerated="true">
|
|
||||||
<option name="MAIN_CLASS_NAME" value="dev.relism.bench.Main" />
|
<option name="MAIN_CLASS_NAME" value="dev.relism.bench.Main" />
|
||||||
<module name="flash-bench" />
|
<module name="flash-bench" />
|
||||||
|
<option name="VM_PARAMETERS" value="-Dflash.env=dev" />
|
||||||
<extension name="coverage">
|
<extension name="coverage">
|
||||||
<pattern>
|
<pattern>
|
||||||
<option name="PATTERN" value="dev.relism.bench.*" />
|
<option name="PATTERN" value="dev.relism.bench.*" />
|
||||||
@@ -247,23 +217,14 @@
|
|||||||
<option name="Make" enabled="true" />
|
<option name="Make" enabled="true" />
|
||||||
</method>
|
</method>
|
||||||
</configuration>
|
</configuration>
|
||||||
<configuration name="build" type="js.build_tools.npm" temporary="true" nameIsGenerated="true">
|
<list>
|
||||||
<package-json value="$PROJECT_DIR$/nuxt-shadcn-dashboard/package.json" />
|
<item itemvalue="Application.(dev) flash-bench" />
|
||||||
<command value="run" />
|
<item itemvalue="Application.ExternalBenchmark" />
|
||||||
<scripts>
|
<item itemvalue="Application.MainAlt" />
|
||||||
<script value="build" />
|
</list>
|
||||||
</scripts>
|
|
||||||
<node-interpreter value="project" />
|
|
||||||
<envs />
|
|
||||||
<method v="2" />
|
|
||||||
</configuration>
|
|
||||||
<recent_temporary>
|
<recent_temporary>
|
||||||
<list>
|
<list>
|
||||||
<item itemvalue="Application.dev.relism.bench.Main" />
|
<item itemvalue="Application.MainAlt" />
|
||||||
<item itemvalue="npm.build" />
|
|
||||||
<item itemvalue="Application.Main" />
|
|
||||||
<item itemvalue="Application.ExternalBenchmark (1)" />
|
|
||||||
<item itemvalue="Application.ExternalBenchmark" />
|
|
||||||
</list>
|
</list>
|
||||||
</recent_temporary>
|
</recent_temporary>
|
||||||
</component>
|
</component>
|
||||||
@@ -320,7 +281,18 @@
|
|||||||
<workItem from="1774703412987" duration="25271000" />
|
<workItem from="1774703412987" duration="25271000" />
|
||||||
<workItem from="1774777423667" duration="2127000" />
|
<workItem from="1774777423667" duration="2127000" />
|
||||||
<workItem from="1774790933314" duration="10099000" />
|
<workItem from="1774790933314" duration="10099000" />
|
||||||
<workItem from="1774814271256" duration="2412000" />
|
<workItem from="1774814271256" duration="6223000" />
|
||||||
|
<workItem from="1774872087063" duration="23188000" />
|
||||||
|
<workItem from="1774944423363" duration="2161000" />
|
||||||
|
<workItem from="1774953226940" duration="4167000" />
|
||||||
|
<workItem from="1775295922142" duration="5680000" />
|
||||||
|
<workItem from="1775312662640" duration="1858000" />
|
||||||
|
<workItem from="1775391482685" duration="1961000" />
|
||||||
|
<workItem from="1775495346638" duration="596000" />
|
||||||
|
<workItem from="1775747075572" duration="1237000" />
|
||||||
|
<workItem from="1776192895352" duration="3823000" />
|
||||||
|
<workItem from="1776198526994" duration="4187000" />
|
||||||
|
<workItem from="1776240695245" duration="14368000" />
|
||||||
</task>
|
</task>
|
||||||
<task id="LOCAL-00001" summary="Initial">
|
<task id="LOCAL-00001" summary="Initial">
|
||||||
<option name="closed" value="true" />
|
<option name="closed" value="true" />
|
||||||
@@ -394,7 +366,15 @@
|
|||||||
<option name="project" value="LOCAL" />
|
<option name="project" value="LOCAL" />
|
||||||
<updated>1774703474044</updated>
|
<updated>1774703474044</updated>
|
||||||
</task>
|
</task>
|
||||||
<option name="localTasksCounter" value="10" />
|
<task id="LOCAL-00010" summary="preparing for another refactoring...">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1774819003821</created>
|
||||||
|
<option name="number" value="00010" />
|
||||||
|
<option name="presentableId" value="LOCAL-00010" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1774819003821</updated>
|
||||||
|
</task>
|
||||||
|
<option name="localTasksCounter" value="11" />
|
||||||
<servers />
|
<servers />
|
||||||
</component>
|
</component>
|
||||||
<component name="TypeScriptGeneratedFilesManager">
|
<component name="TypeScriptGeneratedFilesManager">
|
||||||
@@ -433,7 +413,8 @@
|
|||||||
<MESSAGE value="enhanced router middleware support; added pre-fused middleware handling and improved handler registration" />
|
<MESSAGE value="enhanced router middleware support; added pre-fused middleware handling and improved handler registration" />
|
||||||
<MESSAGE value="pre-major refactoring + ext api." />
|
<MESSAGE value="pre-major refactoring + ext api." />
|
||||||
<MESSAGE value="preparing for a conceptual refactoring..." />
|
<MESSAGE value="preparing for a conceptual refactoring..." />
|
||||||
<option name="LAST_COMMIT_MESSAGE" value="preparing for a conceptual refactoring..." />
|
<MESSAGE value="preparing for another refactoring..." />
|
||||||
|
<option name="LAST_COMMIT_MESSAGE" value="preparing for another refactoring..." />
|
||||||
</component>
|
</component>
|
||||||
<component name="XSLT-Support.FileAssociations.UIState">
|
<component name="XSLT-Support.FileAssociations.UIState">
|
||||||
<expand />
|
<expand />
|
||||||
|
|||||||
@@ -20,14 +20,16 @@ Jackson JSON integration for the Flash HTTP server.
|
|||||||
</dependency>
|
</dependency>
|
||||||
```
|
```
|
||||||
|
|
||||||
Install before any extension that needs JSON (e.g. `flash-ext-openapi`, `flash-ext-oidc`):
|
|
||||||
|
|
||||||
```java
|
```java
|
||||||
FlashApp.create(8080)
|
FlashApp.create(8080)
|
||||||
.install(new JacksonExtension())
|
.install(new JacksonExtension())
|
||||||
// ... other extensions
|
.install(new OpenApiExtension("/openapi", "My API", "1.0.0"))
|
||||||
|
.start();
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Install order is irrelevant — Flash's two-phase extension model ensures `ObjectMapper` is
|
||||||
|
resolved before any extension that calls `ctx.require(ObjectMapper.class)` needs it.
|
||||||
|
|
||||||
### Custom mapper
|
### Custom mapper
|
||||||
|
|
||||||
```java
|
```java
|
||||||
@@ -56,7 +58,7 @@ public class CreateBlog extends JacksonHandler {
|
|||||||
public Object handle(Request req, Response res) throws Exception {
|
public Object handle(Request req, Response res) throws Exception {
|
||||||
CreateBlogRequest body = bodyAs(req, CreateBlogRequest.class);
|
CreateBlogRequest body = bodyAs(req, CreateBlogRequest.class);
|
||||||
Blog created = service.create(body);
|
Blog created = service.create(body);
|
||||||
res.setStatusCode(201);
|
res.status(201);
|
||||||
return json(res, created); // sets Content-Type: application/json
|
return json(res, created); // sets Content-Type: application/json
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -96,7 +98,7 @@ For lambda-style routes, use the `ObjectMapper` directly from the context:
|
|||||||
ObjectMapper mapper = app.ctx().require(ObjectMapper.class);
|
ObjectMapper mapper = app.ctx().require(ObjectMapper.class);
|
||||||
|
|
||||||
app.get("/api/status", (req, res) -> {
|
app.get("/api/status", (req, res) -> {
|
||||||
res.setContentType(ContentType.JSON);
|
res.type(ContentType.JSON);
|
||||||
return mapper.writeValueAsString(Map.of("status", "ok"));
|
return mapper.writeValueAsString(Map.of("status", "ok"));
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|||||||
+4
-16
@@ -4,21 +4,19 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
|||||||
import com.fasterxml.jackson.databind.json.JsonMapper;
|
import com.fasterxml.jackson.databind.json.JsonMapper;
|
||||||
import dev.relism.extension.FlashContext;
|
import dev.relism.extension.FlashContext;
|
||||||
import dev.relism.extension.FlashExtension;
|
import dev.relism.extension.FlashExtension;
|
||||||
import dev.relism.extension.FlashRegistrar;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Registers JSON support into the Flash extension layer.
|
* Registers JSON support into the Flash extension layer.
|
||||||
*
|
*
|
||||||
* <p>Exposes a {@link Json} utility instance in the {@link FlashContext} under
|
* <p>Exposes a {@link Json} utility instance in the {@link FlashContext} under
|
||||||
* {@code Json.class}. Any handler or extension in the same scope can retrieve
|
* {@code Json.class}. Any handler or extension can retrieve it via {@code ctx.require(Json.class)}
|
||||||
* it via {@code require(Json.class)} inside {@code onInit()}.
|
* inside {@code onInit()} (class-based) or inside {@link FlashExtension#routes} (extensions).
|
||||||
*
|
*
|
||||||
* <p>The raw {@link ObjectMapper} is also registered under {@code ObjectMapper.class}
|
* <p>The raw {@link ObjectMapper} is also registered under {@code ObjectMapper.class}
|
||||||
* for extensions that need direct mapper access (e.g. OpenAPI schema generation).
|
* for extensions that need direct mapper access (e.g. OpenAPI schema generation).
|
||||||
*
|
*
|
||||||
* <h3>Usage — composition (preferred)</h3>
|
* <h3>Usage — composition (preferred)</h3>
|
||||||
* <pre>{@code
|
* <pre>{@code
|
||||||
* // No mandatory base class. Works from any RequestHandler.
|
|
||||||
* public class MyHandler extends RequestHandler {
|
* public class MyHandler extends RequestHandler {
|
||||||
* private Json json;
|
* private Json json;
|
||||||
*
|
*
|
||||||
@@ -33,16 +31,6 @@ import dev.relism.extension.FlashRegistrar;
|
|||||||
* }
|
* }
|
||||||
* }</pre>
|
* }</pre>
|
||||||
*
|
*
|
||||||
* <h3>Usage — convenience base class</h3>
|
|
||||||
* <pre>{@code
|
|
||||||
* // JacksonHandler remains available as a thin opt-in wrapper.
|
|
||||||
* public class MyHandler extends JacksonHandler {
|
|
||||||
* public Object handle(Request req, Response res) throws Exception {
|
|
||||||
* return json(res, service.findAll());
|
|
||||||
* }
|
|
||||||
* }
|
|
||||||
* }</pre>
|
|
||||||
*
|
|
||||||
* <h3>Custom mapper</h3>
|
* <h3>Custom mapper</h3>
|
||||||
* <pre>{@code
|
* <pre>{@code
|
||||||
* ObjectMapper mapper = JsonMapper.builder()
|
* ObjectMapper mapper = JsonMapper.builder()
|
||||||
@@ -69,9 +57,9 @@ public class JacksonExtension implements FlashExtension {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void install(FlashRegistrar app, FlashContext ctx) {
|
public void provide(FlashContext ctx) {
|
||||||
Json json = new Json(mapper);
|
Json json = new Json(mapper);
|
||||||
ctx.provide(Json.class, json);
|
ctx.provide(Json.class, json);
|
||||||
ctx.provide(ObjectMapper.class, mapper); // backward compat for extensions (OpenAPI, etc.)
|
ctx.provide(ObjectMapper.class, mapper);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,16 +91,7 @@ public final class Json {
|
|||||||
* <p>The returned string is used as the response body by the Flash runtime.
|
* <p>The returned string is used as the response body by the Flash runtime.
|
||||||
*/
|
*/
|
||||||
public String write(Response res, Object obj) throws Exception {
|
public String write(Response res, Object obj) throws Exception {
|
||||||
res.setContentType(ContentType.JSON);
|
res.type(ContentType.JSON);
|
||||||
return mapper.writeValueAsString(obj);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Like {@link #write(Response, Object)} but also sets an explicit HTTP status code.
|
|
||||||
*/
|
|
||||||
public String write(Response res, int status, Object obj) throws Exception {
|
|
||||||
res.status(status);
|
|
||||||
res.setContentType(ContentType.JSON);
|
|
||||||
return mapper.writeValueAsString(obj);
|
return mapper.writeValueAsString(obj);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,7 +100,7 @@ public final class Json {
|
|||||||
* restricting serialization to fields visible under {@code view}.
|
* restricting serialization to fields visible under {@code view}.
|
||||||
*/
|
*/
|
||||||
public String writeView(Response res, Object obj, Class<?> view) throws Exception {
|
public String writeView(Response res, Object obj, Class<?> view) throws Exception {
|
||||||
res.setContentType(ContentType.JSON);
|
res.type(ContentType.JSON);
|
||||||
return mapper.writerWithView(view).writeValueAsString(obj);
|
return mapper.writerWithView(view).writeValueAsString(obj);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import java.util.concurrent.atomic.AtomicLong;
|
|||||||
* <ul>
|
* <ul>
|
||||||
* <li><b>FIXED_WINDOW</b>: {@code slot0} = packed {@code (epoch << 32 | count)},
|
* <li><b>FIXED_WINDOW</b>: {@code slot0} = packed {@code (epoch << 32 | count)},
|
||||||
* {@code slot1} unused.</li>
|
* {@code slot1} unused.</li>
|
||||||
|
* <li><b>SLIDING_WINDOW</b>: {@code slot0} = packed {@code (epoch << 32 | count)},
|
||||||
|
* {@code slot1} = request count from the immediately preceding epoch.</li>
|
||||||
* <li><b>TOKEN_BUCKET</b>: {@code slot0} = tokens × 1000 (scaled),
|
* <li><b>TOKEN_BUCKET</b>: {@code slot0} = tokens × 1000 (scaled),
|
||||||
* {@code slot1} = last-refill timestamp (ms since epoch).</li>
|
* {@code slot1} = last-refill timestamp (ms since epoch).</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
|
|||||||
@@ -16,17 +16,15 @@ import java.util.concurrent.TimeUnit;
|
|||||||
* time — the resolver lambda is captured directly from the registry (no runtime map lookup):
|
* time — the resolver lambda is captured directly from the registry (no runtime map lookup):
|
||||||
* <pre>{@code
|
* <pre>{@code
|
||||||
* // 50 req/s per IP — fixed window (default)
|
* // 50 req/s per IP — fixed window (default)
|
||||||
* app.get("/api/search", handler)
|
* app.get("/api/search", handler, guard.limit("ip", 50, 1, TimeUnit.SECONDS));
|
||||||
* .with(guard.limit("ip", 50, 1, TimeUnit.SECONDS));
|
|
||||||
*
|
*
|
||||||
* // 10 req/min per authenticated user — token bucket
|
* // 10 req/min per authenticated user — token bucket
|
||||||
* app.post("/api/export", handler)
|
* app.post("/api/export", handler, guard.limit("auth_user", 10, 1, TimeUnit.MINUTES, LimitStrategy.TOKEN_BUCKET));
|
||||||
* .with(guard.limit("auth_user", 10, 1, TimeUnit.MINUTES, LimitStrategy.TOKEN_BUCKET));
|
|
||||||
* }</pre>
|
* }</pre>
|
||||||
*
|
*
|
||||||
* <p>The resolver name is looked up once here (at {@code .with(guard.limit(...))} call time,
|
* <p>The resolver name is looked up once here (at wiring time, not on each request).
|
||||||
* i.e. during app wiring, not on each request). If the name is not registered,
|
* If the name is not registered, {@link dev.relism.exceptions.InitializationException}
|
||||||
* {@link dev.relism.exceptions.InitializationException} is thrown immediately.
|
* is thrown immediately.
|
||||||
*/
|
*/
|
||||||
public final class Guard {
|
public final class Guard {
|
||||||
|
|
||||||
|
|||||||
+13
@@ -1,6 +1,7 @@
|
|||||||
package dev.relism.ext.limiter;
|
package dev.relism.ext.limiter;
|
||||||
|
|
||||||
import dev.relism.ext.limiter.strategy.FixedWindowStrategy;
|
import dev.relism.ext.limiter.strategy.FixedWindowStrategy;
|
||||||
|
import dev.relism.ext.limiter.strategy.SlidingWindowStrategy;
|
||||||
import dev.relism.ext.limiter.strategy.TokenBucketStrategy;
|
import dev.relism.ext.limiter.strategy.TokenBucketStrategy;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -38,6 +39,18 @@ public enum LimitStrategy {
|
|||||||
TOKEN_BUCKET {
|
TOKEN_BUCKET {
|
||||||
@Override
|
@Override
|
||||||
public RateLimitStrategy create() { return new TokenBucketStrategy(); }
|
public RateLimitStrategy create() { return new TokenBucketStrategy(); }
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sliding-window counter: interpolates between the previous window's count and the
|
||||||
|
* current window's count weighted by how far into the current window we are.
|
||||||
|
* Eliminates the boundary burst of {@link #FIXED_WINDOW} while remaining O(1)
|
||||||
|
* memory and lock-free. Slight approximation — worst-case error ≈ a few percent at
|
||||||
|
* window boundaries.
|
||||||
|
*/
|
||||||
|
SLIDING_WINDOW {
|
||||||
|
@Override
|
||||||
|
public RateLimitStrategy create() { return new SlidingWindowStrategy(); }
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Creates a fresh, stateless {@link RateLimitStrategy} instance for this algorithm. */
|
/** Creates a fresh, stateless {@link RateLimitStrategy} instance for this algorithm. */
|
||||||
|
|||||||
+20
-21
@@ -1,24 +1,24 @@
|
|||||||
package dev.relism.ext.limiter;
|
package dev.relism.ext.limiter;
|
||||||
|
|
||||||
|
import dev.relism.extension.ExtensionPhase;
|
||||||
import dev.relism.extension.FlashContext;
|
import dev.relism.extension.FlashContext;
|
||||||
import dev.relism.extension.FlashExtension;
|
import dev.relism.extension.FlashExtension;
|
||||||
import dev.relism.extension.FlashRegistrar;
|
|
||||||
import dev.relism.http.HttpStatus;
|
import dev.relism.http.HttpStatus;
|
||||||
import dev.relism.routing.Middleware;
|
import dev.relism.routing.Middleware;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rate-limiting extension for Flash.
|
* Rate-limiting extension for Flash.
|
||||||
*
|
*
|
||||||
* <p>On {@link #install}:
|
* <p>At {@link #provide}:
|
||||||
* <ol>
|
* <ol>
|
||||||
* <li>Creates a single {@link BucketStore} shared by all rules in this extension instance.</li>
|
* <li>Creates a single {@link BucketStore} shared by all rules in this extension instance.</li>
|
||||||
* <li>Provides a {@link Guard} in the {@link FlashContext} for manual use on lambda routes.</li>
|
* <li>Provides a {@link Guard} in the {@link FlashContext} for manual use on lambda routes.</li>
|
||||||
* <li>Registers an {@link dev.relism.extension.AnnotationProcessor} for {@link Limit}:
|
* <li>Registers an {@link dev.relism.extension.AnnotationProcessor} for {@link Limit}:
|
||||||
* reads the annotation once per handler class at boot, resolves the key lambda
|
* reads the annotation once per handler class at boot, resolves the key lambda
|
||||||
* from the registry <em>fail-fast</em>, then returns a pre-compiled middleware
|
* fail-fast, then returns a pre-compiled middleware — zero map lookups at request time.</li>
|
||||||
* that captures the lambda and config directly — zero map lookups at request time.</li>
|
|
||||||
* </ol>
|
* </ol>
|
||||||
*
|
*
|
||||||
* <h3>Annotation-based (class handlers)</h3>
|
* <h3>Annotation-based (class handlers)</h3>
|
||||||
@@ -34,23 +34,24 @@ import java.util.List;
|
|||||||
*
|
*
|
||||||
* <h3>Lambda routes (via Guard)</h3>
|
* <h3>Lambda routes (via Guard)</h3>
|
||||||
* <pre>{@code
|
* <pre>{@code
|
||||||
* LimiterConfig conf = new LimiterConfig()
|
* app.install(new LimiterExtension(
|
||||||
* .registerResolver("auth_user", req -> ClaimsHolder.user().sub());
|
* new LimiterConfig().registerResolver("auth_user", req -> ClaimsHolder.user().sub())));
|
||||||
*
|
*
|
||||||
* app.install(new LimiterExtension(conf));
|
* // inside FlashExtension.routes() or after install():
|
||||||
*
|
* Guard guard = ctx.require(Guard.class);
|
||||||
* Guard guard = app.ctx().require(Guard.class);
|
* app.get("/api/search", handler, guard.limit("ip", 50, 1, TimeUnit.SECONDS));
|
||||||
* app.get("/api/search", handler).with(guard.limit("ip", 50, 1, TimeUnit.SECONDS));
|
|
||||||
* }</pre>
|
* }</pre>
|
||||||
*
|
|
||||||
* <h3>Installation order matters</h3>
|
|
||||||
* Install {@code LimiterExtension} <em>before</em> authentication extensions so that
|
|
||||||
* rate-limit checks short-circuit before expensive token validation on over-limit requests.
|
|
||||||
*/
|
*/
|
||||||
public final class LimiterExtension implements FlashExtension {
|
public final class LimiterExtension implements FlashExtension {
|
||||||
|
|
||||||
private final LimiterConfig config;
|
private final LimiterConfig config;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rate limiting runs before authentication — cheaper check rejects over-limit
|
||||||
|
* requests before any token validation occurs.
|
||||||
|
*/
|
||||||
|
@Override public int priority() { return ExtensionPhase.EARLY.value; }
|
||||||
|
|
||||||
/** Installs with default config (only the built-in {@code "ip"} resolver). */
|
/** Installs with default config (only the built-in {@code "ip"} resolver). */
|
||||||
public LimiterExtension() {
|
public LimiterExtension() {
|
||||||
this(new LimiterConfig());
|
this(new LimiterConfig());
|
||||||
@@ -62,7 +63,7 @@ public final class LimiterExtension implements FlashExtension {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void install(FlashRegistrar app, FlashContext ctx) {
|
public void provide(FlashContext ctx) {
|
||||||
BucketStore store = new BucketStore();
|
BucketStore store = new BucketStore();
|
||||||
Guard guard = new Guard(config, store);
|
Guard guard = new Guard(config, store);
|
||||||
|
|
||||||
@@ -96,14 +97,13 @@ public final class LimiterExtension implements FlashExtension {
|
|||||||
* <li>{@code resolver} is captured directly in the closure — no registry lookup per request.</li>
|
* <li>{@code resolver} is captured directly in the closure — no registry lookup per request.</li>
|
||||||
* <li>{@code resultBuf} is a per-{@link Middleware}-instance ThreadLocal {@code long[2]}.
|
* <li>{@code resultBuf} is a per-{@link Middleware}-instance ThreadLocal {@code long[2]}.
|
||||||
* Allocated once per thread, reused forever — zero per-request allocation.</li>
|
* Allocated once per thread, reused forever — zero per-request allocation.</li>
|
||||||
* <li>Header values ({@code String.valueOf(...)}) are the only unavoidable allocations;
|
* <li>The static {@code X-RateLimit-Limit} header is pre-encoded at boot — zero-alloc.</li>
|
||||||
* they are tiny and bounded.</li>
|
|
||||||
* </ul>
|
* </ul>
|
||||||
*/
|
*/
|
||||||
static Middleware buildMiddleware(KeyResolver resolver, LimitConfig cfg, BucketStore store) {
|
static Middleware buildMiddleware(KeyResolver resolver, LimitConfig cfg, BucketStore store) {
|
||||||
// One result buffer per thread, per middleware instance.
|
|
||||||
// ThreadLocal is captured in the closure at boot time — not re-created per request.
|
|
||||||
ThreadLocal<long[]> resultBuf = ThreadLocal.withInitial(() -> new long[2]);
|
ThreadLocal<long[]> resultBuf = ThreadLocal.withInitial(() -> new long[2]);
|
||||||
|
byte[] limitHeader = ("X-RateLimit-Limit: " + cfg.limit() + "\r\n")
|
||||||
|
.getBytes(StandardCharsets.UTF_8);
|
||||||
|
|
||||||
return next -> (req, res) -> {
|
return next -> (req, res) -> {
|
||||||
String key = resolver.resolve(req);
|
String key = resolver.resolve(req);
|
||||||
@@ -112,8 +112,7 @@ public final class LimiterExtension implements FlashExtension {
|
|||||||
|
|
||||||
boolean allowed = cfg.strategy().check(bucket, cfg, out);
|
boolean allowed = cfg.strategy().check(bucket, cfg, out);
|
||||||
|
|
||||||
// Always inject rate-limit headers — useful even on allowed requests.
|
res.header(limitHeader);
|
||||||
res.header("X-RateLimit-Limit", String.valueOf(cfg.limit()));
|
|
||||||
res.header("X-RateLimit-Remaining", String.valueOf(out[0]));
|
res.header("X-RateLimit-Remaining", String.valueOf(out[0]));
|
||||||
res.header("X-RateLimit-Reset", String.valueOf(out[1]));
|
res.header("X-RateLimit-Reset", String.valueOf(out[1]));
|
||||||
|
|
||||||
|
|||||||
+1
@@ -11,6 +11,7 @@ package dev.relism.ext.limiter;
|
|||||||
* <p>Called on every request — must not allocate on the hot path.
|
* <p>Called on every request — must not allocate on the hot path.
|
||||||
*
|
*
|
||||||
* @see dev.relism.ext.limiter.strategy.FixedWindowStrategy
|
* @see dev.relism.ext.limiter.strategy.FixedWindowStrategy
|
||||||
|
* @see dev.relism.ext.limiter.strategy.SlidingWindowStrategy
|
||||||
* @see dev.relism.ext.limiter.strategy.TokenBucketStrategy
|
* @see dev.relism.ext.limiter.strategy.TokenBucketStrategy
|
||||||
*/
|
*/
|
||||||
public interface RateLimitStrategy {
|
public interface RateLimitStrategy {
|
||||||
|
|||||||
+86
@@ -0,0 +1,86 @@
|
|||||||
|
package dev.relism.ext.limiter.strategy;
|
||||||
|
|
||||||
|
import dev.relism.ext.limiter.Bucket;
|
||||||
|
import dev.relism.ext.limiter.LimitConfig;
|
||||||
|
import dev.relism.ext.limiter.RateLimitStrategy;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sliding-window counter rate limit: approximates a true sliding window by interpolating
|
||||||
|
* between the previous fixed window's count and the current window's count.
|
||||||
|
*
|
||||||
|
* <pre>
|
||||||
|
* estimate = prevCount × (1 − elapsed / windowMs) + currentCount
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
|
* <p>This is the same approximation used by Redis. It eliminates the boundary burst
|
||||||
|
* problem of {@link FixedWindowStrategy} while staying O(1) memory and lock-free.
|
||||||
|
* The error is bounded: in the worst case the true rate at the boundary can exceed
|
||||||
|
* the limit by at most {@code limit × (1 − elapsed/windowMs)} — typically a few percent.
|
||||||
|
*
|
||||||
|
* <h3>Slot layout</h3>
|
||||||
|
* <ul>
|
||||||
|
* <li>{@link Bucket#slot0} — packed {@code (reducedEpoch << 32 | currentCount)}</li>
|
||||||
|
* <li>{@link Bucket#slot1} — count from the immediately preceding epoch (0 = none)</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>On a window transition the thread that wins the {@code slot0} CAS also writes
|
||||||
|
* {@code slot1}. A concurrent thread that reads {@code slot0} after the transition but
|
||||||
|
* before {@code slot1} is written sees a slightly stale previous count — acceptable for
|
||||||
|
* an approximation algorithm.
|
||||||
|
*/
|
||||||
|
public final class SlidingWindowStrategy implements RateLimitStrategy {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean check(Bucket bucket, LimitConfig cfg, long[] out) {
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
long windowMs = cfg.windowMs();
|
||||||
|
long absEpoch = now / windowMs;
|
||||||
|
int epoch = (int)(absEpoch & 0xFFFFFFFFL);
|
||||||
|
long elapsed = now % windowMs; // ms elapsed inside the current window
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
long packed = bucket.slot0.get();
|
||||||
|
int storedEpoch = (int)(packed >>> 32);
|
||||||
|
int count = (int)(packed & 0xFFFFFFFFL);
|
||||||
|
|
||||||
|
if (storedEpoch == epoch) {
|
||||||
|
// ── Same window ──────────────────────────────────────────────────
|
||||||
|
long prevCount = bucket.slot1.get();
|
||||||
|
// Integer interpolation — no floating-point on hot path.
|
||||||
|
long estimate = (prevCount * (windowMs - elapsed)) / windowMs + count + 1;
|
||||||
|
|
||||||
|
if (estimate > cfg.limit()) {
|
||||||
|
out[0] = 0L;
|
||||||
|
out[1] = (absEpoch + 1) * windowMs / 1000L;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int newCount = Math.min(count + 1, cfg.limit() + 1);
|
||||||
|
long newPacked = ((long) epoch << 32) | (newCount & 0xFFFFFFFFL);
|
||||||
|
if (!bucket.slot0.compareAndSet(packed, newPacked)) continue; // CAS lost, retry
|
||||||
|
|
||||||
|
out[0] = Math.max(0L, cfg.limit() - estimate);
|
||||||
|
out[1] = (absEpoch + 1) * windowMs / 1000L;
|
||||||
|
return true;
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// ── Window transition ────────────────────────────────────────────
|
||||||
|
// If the stored epoch is exactly the one before ours, carry its count forward.
|
||||||
|
// If it's older (gap ≥ 2 windows), the previous window is effectively empty.
|
||||||
|
int prevEpoch = (int)((absEpoch - 1) & 0xFFFFFFFFL);
|
||||||
|
long oldCount = (storedEpoch == prevEpoch) ? count : 0L;
|
||||||
|
|
||||||
|
long newPacked = ((long) epoch << 32) | 1L;
|
||||||
|
if (!bucket.slot0.compareAndSet(packed, newPacked)) continue; // CAS lost, retry
|
||||||
|
|
||||||
|
// Won the transition: publish old count so the same-window branch can read it.
|
||||||
|
bucket.slot1.set(oldCount);
|
||||||
|
|
||||||
|
long estimate = (oldCount * (windowMs - elapsed)) / windowMs + 1;
|
||||||
|
out[0] = Math.max(0L, cfg.limit() - estimate);
|
||||||
|
out[1] = (absEpoch + 1) * windowMs / 1000L;
|
||||||
|
return estimate <= cfg.limit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-5
@@ -17,9 +17,8 @@ import dev.relism.ext.limiter.RateLimitStrategy;
|
|||||||
* <li>{@link Bucket#slot1} — last-refill timestamp in ms. 0 = not yet initialised.</li>
|
* <li>{@link Bucket#slot1} — last-refill timestamp in ms. 0 = not yet initialised.</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
*
|
*
|
||||||
* <p>Each request CAS-loops on {@code slot0}; {@code slot1} is updated best-effort after a
|
* <p>Each request CAS-loops on {@code slot0}; {@code slot1} is advanced monotonically via CAS
|
||||||
* successful CAS. The resulting inaccuracy is bounded by the nanoseconds between the CAS
|
* after a successful token consumption — never regresses to an older timestamp under concurrent load.
|
||||||
* and the {@code set} — negligible and self-correcting for rate limiting purposes.
|
|
||||||
*/
|
*/
|
||||||
public final class TokenBucketStrategy implements RateLimitStrategy {
|
public final class TokenBucketStrategy implements RateLimitStrategy {
|
||||||
|
|
||||||
@@ -57,8 +56,8 @@ public final class TokenBucketStrategy implements RateLimitStrategy {
|
|||||||
|
|
||||||
long newTokens = currentTokens - SCALE;
|
long newTokens = currentTokens - SCALE;
|
||||||
if (bucket.slot0.compareAndSet(rawTokens, newTokens)) {
|
if (bucket.slot0.compareAndSet(rawTokens, newTokens)) {
|
||||||
// Consumed successfully. Update refill baseline best-effort.
|
// Advance refill baseline: CAS ensures we never regress to an older timestamp.
|
||||||
bucket.slot1.set(now);
|
if (lastMs < now) bucket.slot1.compareAndSet(lastMs, now);
|
||||||
out[0] = newTokens / SCALE;
|
out[0] = newTokens / SCALE;
|
||||||
out[1] = now / 1000L;
|
out[1] = now / 1000L;
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -3,6 +3,9 @@
|
|||||||
Full OIDC Authorization Code + PKCE flow for the Flash HTTP server.
|
Full OIDC Authorization Code + PKCE flow for the Flash HTTP server.
|
||||||
Supports Keycloak, Authelia, Auth0, Google, and any RFC 8414-compliant provider.
|
Supports Keycloak, Authelia, Auth0, Google, and any RFC 8414-compliant provider.
|
||||||
|
|
||||||
|
Standards alignment focuses on OIDC Core + OAuth2 bearer APIs while preserving Flash's
|
||||||
|
hot-path model (middleware compiled at mount time, no heavy runtime work).
|
||||||
|
|
||||||
## What it provides
|
## What it provides
|
||||||
|
|
||||||
| Component | Description |
|
| Component | Description |
|
||||||
@@ -12,6 +15,7 @@ Supports Keycloak, Authelia, Auth0, Google, and any RFC 8414-compliant provider.
|
|||||||
| `POST {prefix}/logout` | Invalidates the session, redirects to the provider's `end_session_endpoint` |
|
| `POST {prefix}/logout` | Invalidates the session, redirects to the provider's `end_session_endpoint` |
|
||||||
| `@Authenticated` | Annotation: protects a class-based handler (redirects browsers, 401 for API clients) |
|
| `@Authenticated` | Annotation: protects a class-based handler (redirects browsers, 401 for API clients) |
|
||||||
| `@RolesAllowed(...)` | Annotation: protects with role check (OR semantics) |
|
| `@RolesAllowed(...)` | Annotation: protects with role check (OR semantics) |
|
||||||
|
| `@ScopesAllowed(...)` | Annotation: protects with scope check (`ALL` default, `ANY` optional) |
|
||||||
| `OidcMiddleware` | Programmatic middleware for lambda routes |
|
| `OidcMiddleware` | Programmatic middleware for lambda routes |
|
||||||
| `ClaimsHolder` / `OidcUser` | Thread-local user info accessible from any protected handler |
|
| `ClaimsHolder` / `OidcUser` | Thread-local user info accessible from any protected handler |
|
||||||
| `JwtValidator` | JWKS-backed JWT validator (PKCE + key rotation + caching) |
|
| `JwtValidator` | JWKS-backed JWT validator (PKCE + key rotation + caching) |
|
||||||
@@ -40,9 +44,14 @@ FlashApp.create(8080)
|
|||||||
"https://idp.example.com",
|
"https://idp.example.com",
|
||||||
"my-client", "my-secret", "/auth/callback")
|
"my-client", "my-secret", "/auth/callback")
|
||||||
.build()
|
.build()
|
||||||
));
|
))
|
||||||
|
.start();
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Install order is irrelevant. The two-phase extension model guarantees all services
|
||||||
|
(including `OpenApiSecurityRegistry` from `flash-ext-openapi`) are registered before
|
||||||
|
any extension's routes phase runs.
|
||||||
|
|
||||||
### Keycloak shortcut
|
### Keycloak shortcut
|
||||||
|
|
||||||
```java
|
```java
|
||||||
@@ -86,6 +95,7 @@ OidcConfig.builder("https://auth.example.com", "my-client", "secret", "/auth/cal
|
|||||||
| `.selfScheme("http")` | `"http"` | Scheme used when resolving server-relative redirect URIs |
|
| `.selfScheme("http")` | `"http"` | Scheme used when resolving server-relative redirect URIs |
|
||||||
| `.https()` | — | Shorthand for `.selfScheme("https")` |
|
| `.https()` | — | Shorthand for `.selfScheme("https")` |
|
||||||
| `.rolesClaimPath("realm_access.roles")` | `"realm_access.roles"` | Dot-path to the roles array in JWT claims |
|
| `.rolesClaimPath("realm_access.roles")` | `"realm_access.roles"` | Dot-path to the roles array in JWT claims |
|
||||||
|
| `.scopeClaimPaths("scope,scp")` | `"scope,scp"` | Comma-separated claim paths used to resolve OAuth scopes |
|
||||||
| `.algorithm("RS256")` | `"RS256"` | JWS algorithm for token validation |
|
| `.algorithm("RS256")` | `"RS256"` | JWS algorithm for token validation |
|
||||||
| `.postLogoutRedirectUri("/")` | `"/"` | Where to redirect after logout |
|
| `.postLogoutRedirectUri("/")` | `"/"` | Where to redirect after logout |
|
||||||
| `.sessionStore(store)` | `InMemoryOidcSessionStore` | Custom session store (see below) |
|
| `.sessionStore(store)` | `InMemoryOidcSessionStore` | Custom session store (see below) |
|
||||||
@@ -104,6 +114,7 @@ OIDC_SCOPES default: openid profile email
|
|||||||
OIDC_ROUTE_PREFIX default: /auth
|
OIDC_ROUTE_PREFIX default: /auth
|
||||||
OIDC_SELF_SCHEME default: http
|
OIDC_SELF_SCHEME default: http
|
||||||
OIDC_ROLES_CLAIM default: realm_access.roles
|
OIDC_ROLES_CLAIM default: realm_access.roles
|
||||||
|
OIDC_SCOPE_CLAIMS default: scope,scp
|
||||||
OIDC_ALGORITHM default: RS256
|
OIDC_ALGORITHM default: RS256
|
||||||
OIDC_POST_LOGOUT_REDIRECT default: /
|
OIDC_POST_LOGOUT_REDIRECT default: /
|
||||||
OIDC_CLIENT_AUTH_METHOD default: POST
|
OIDC_CLIENT_AUTH_METHOD default: POST
|
||||||
@@ -128,14 +139,35 @@ public class MePage extends JacksonHandler {
|
|||||||
@RolesAllowed("admin") // OR semantics: "admin" OR "superuser"
|
@RolesAllowed("admin") // OR semantics: "admin" OR "superuser"
|
||||||
// @RolesAllowed({"admin", "superuser"})
|
// @RolesAllowed({"admin", "superuser"})
|
||||||
public class AdminPage extends JacksonHandler { ... }
|
public class AdminPage extends JacksonHandler { ... }
|
||||||
|
|
||||||
|
@Route(method = HttpMethod.POST, path = "/orders")
|
||||||
|
@ScopesAllowed("orders:write") // default = ALL semantics
|
||||||
|
public class CreateOrder extends JacksonHandler { ... }
|
||||||
|
|
||||||
|
@Route(method = HttpMethod.POST, path = "/payments")
|
||||||
|
@ScopesAllowed(value = {"payments:write", "payments:admin"}, match = ScopesAllowed.Match.ANY)
|
||||||
|
public class PayOrder extends JacksonHandler { ... }
|
||||||
|
|
||||||
|
@Route(method = HttpMethod.DELETE, path = "/admin/users/{id}")
|
||||||
|
@RolesAllowed("admin")
|
||||||
|
@ScopesAllowed("users:delete") // combined with AND semantics
|
||||||
|
public class DeleteUser extends JacksonHandler { ... }
|
||||||
```
|
```
|
||||||
|
|
||||||
The middleware is injected automatically by the annotation processor — no manual wiring needed.
|
The middleware is injected automatically by the annotation processor — no manual wiring needed.
|
||||||
|
|
||||||
|
Annotation composition rules:
|
||||||
|
|
||||||
|
- `@Authenticated` requires auth only
|
||||||
|
- `@RolesAllowed` implies authentication + role OR-check
|
||||||
|
- `@ScopesAllowed` implies authentication + scope check (`ALL`/`ANY`)
|
||||||
|
- combining `@RolesAllowed` + `@ScopesAllowed` uses AND semantics
|
||||||
|
- `@Authenticated(optional = true)` cannot be combined with role/scope constraints
|
||||||
|
|
||||||
### Lambda routes (manual middleware)
|
### Lambda routes (manual middleware)
|
||||||
|
|
||||||
For lambda routes you must apply the middleware explicitly. Retrieve it from the context
|
For lambda routes, pass the middleware as a varargs argument. Retrieve `OidcMiddleware`
|
||||||
after `install()` completes:
|
from the context inside another extension's `routes()` phase, or after `start()`:
|
||||||
|
|
||||||
```java
|
```java
|
||||||
OidcMiddleware oidc = app.ctx().require(OidcMiddleware.class);
|
OidcMiddleware oidc = app.ctx().require(OidcMiddleware.class);
|
||||||
@@ -144,21 +176,26 @@ OidcMiddleware oidc = app.ctx().require(OidcMiddleware.class);
|
|||||||
app.get("/api/me", (req, res) -> {
|
app.get("/api/me", (req, res) -> {
|
||||||
OidcUser u = ClaimsHolder.user(); // never null here
|
OidcUser u = ClaimsHolder.user(); // never null here
|
||||||
return Map.of("sub", u.sub(), "email", u.email());
|
return Map.of("sub", u.sub(), "email", u.email());
|
||||||
}).with(oidc.protect());
|
}, oidc.protect());
|
||||||
|
|
||||||
// Authentication + role check
|
// Authentication + role check
|
||||||
app.delete("/api/admin/users/{id}", (req, res) -> {
|
app.delete("/api/admin/users/{id}", (req, res) -> {
|
||||||
OidcUser u = ClaimsHolder.user();
|
OidcUser u = ClaimsHolder.user();
|
||||||
// ...
|
// ...
|
||||||
}).with(oidc.requireRole("admin"));
|
}, oidc.requireRole("admin"));
|
||||||
|
|
||||||
// Multiple roles (OR): passes if user holds any one of them
|
// Multiple roles (OR): passes if user holds any one of them
|
||||||
app.get("/api/reports", (req, res) -> { ... })
|
app.get("/api/reports", (req, res) -> { ... }, oidc.requireRole("admin", "reports-viewer"));
|
||||||
.with(oidc.requireRole("admin", "reports-viewer"));
|
|
||||||
|
// Require all listed scopes
|
||||||
|
app.post("/api/orders", (req, res) -> { ... }, oidc.requireScopes("orders:write", "payments:write"));
|
||||||
|
|
||||||
|
// Require at least one listed scope
|
||||||
|
app.post("/api/payments", (req, res) -> { ... }, oidc.requireAnyScope("payments:write", "payments:admin"));
|
||||||
```
|
```
|
||||||
|
|
||||||
`oidc.protect()` / `oidc.requireRole(...)` return a `Middleware` — a composable
|
`oidc.protect()` / `oidc.requireRole(...)` / `oidc.requireScopes(...)` return a `Middleware` — a composable
|
||||||
`Handler -> Handler` wrapper. Flash applies middleware right-to-left so the OIDC check
|
`Handler → Handler` wrapper. Flash applies middleware right-to-left so the OIDC check
|
||||||
runs before your handler.
|
runs before your handler.
|
||||||
|
|
||||||
## Accessing the authenticated user
|
## Accessing the authenticated user
|
||||||
@@ -185,6 +222,14 @@ List<String> groups = u.roles("groups"); // Authelia
|
|||||||
|
|
||||||
boolean isAdmin = u.hasRole("realm_access.roles", "admin");
|
boolean isAdmin = u.hasRole("realm_access.roles", "admin");
|
||||||
|
|
||||||
|
// Scopes (OIDC/OAuth2 generic): checks "scope" then "scp"
|
||||||
|
List<String> scopes = u.scopes();
|
||||||
|
boolean canWrite = u.hasScope("orders:write");
|
||||||
|
|
||||||
|
// Custom claim path resolution (for provider-specific payloads)
|
||||||
|
List<String> customScopes = u.scopes("scope,scp,permissions.scopes");
|
||||||
|
boolean canApprove = u.hasScope("permissions.scopes", "orders:approve");
|
||||||
|
|
||||||
// Arbitrary claim
|
// Arbitrary claim
|
||||||
String locale = (String) u.claim("locale");
|
String locale = (String) u.claim("locale");
|
||||||
Long exp = u.claim("exp", Long.class);
|
Long exp = u.claim("exp", Long.class);
|
||||||
@@ -207,7 +252,7 @@ The middleware adds negligible overhead on the hot path for authenticated reques
|
|||||||
| Step | Cost |
|
| Step | Cost |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `Authorization` header check | `O(1)` map lookup |
|
| `Authorization` header check | `O(1)` map lookup |
|
||||||
| Cookie parse | `O(cookie_count)` string split |
|
| Cookie parse | `O(cookie_length)` single pass scan |
|
||||||
| Session lookup | `O(1)` `ConcurrentHashMap.get()` |
|
| Session lookup | `O(1)` `ConcurrentHashMap.get()` |
|
||||||
| Token expiry check | `O(1)` `Instant` comparison |
|
| Token expiry check | `O(1)` `Instant` comparison |
|
||||||
| `ClaimsHolder.set()` | `O(1)` `ThreadLocal.set()` |
|
| `ClaimsHolder.set()` | `O(1)` `ThreadLocal.set()` |
|
||||||
@@ -216,6 +261,8 @@ No network calls, no cryptography, no JSON parsing on the happy path (valid sess
|
|||||||
JWKS key fetching only happens for Bearer token validation and is cached + rate-limited by
|
JWKS key fetching only happens for Bearer token validation and is cached + rate-limited by
|
||||||
Nimbus's `JWKSourceBuilder`. Silent token refresh only triggers when the access token expires.
|
Nimbus's `JWKSourceBuilder`. Silent token refresh only triggers when the access token expires.
|
||||||
|
|
||||||
|
Role/scope claim paths are compiled once during middleware construction (mount time), not per request.
|
||||||
|
|
||||||
## Authentication flow details
|
## Authentication flow details
|
||||||
|
|
||||||
On each request the middleware resolves credentials in this order:
|
On each request the middleware resolves credentials in this order:
|
||||||
@@ -227,6 +274,16 @@ On each request the middleware resolves credentials in this order:
|
|||||||
- Browser clients (no `Accept: application/json`) → redirect to `{prefix}/login?redirect={path}`
|
- Browser clients (no `Accept: application/json`) → redirect to `{prefix}/login?redirect={path}`
|
||||||
- API clients → `401 Unauthorized`
|
- API clients → `401 Unauthorized`
|
||||||
|
|
||||||
|
### API error semantics (RFC 6750)
|
||||||
|
|
||||||
|
For API clients (`Accept: application/json`) the middleware includes `WWW-Authenticate`:
|
||||||
|
|
||||||
|
- missing credentials: `Bearer realm="<schemeName>"`
|
||||||
|
- invalid bearer token: `Bearer realm="<schemeName>", error="invalid_token"`
|
||||||
|
- insufficient scopes: `Bearer realm="<schemeName>", error="insufficient_scope", scope="<required scopes>"`
|
||||||
|
|
||||||
|
This enables interoperable client-side handling and proper OAuth2 challenge semantics.
|
||||||
|
|
||||||
### Token validation (OIDC Core §3.1.3.7)
|
### Token validation (OIDC Core §3.1.3.7)
|
||||||
|
|
||||||
| Check | Access token | ID token |
|
| Check | Access token | ID token |
|
||||||
@@ -249,6 +306,52 @@ At callback time the extension merges access token + ID token claims:
|
|||||||
This is provider-agnostic: authorization claims live in the AT per RFC 9068,
|
This is provider-agnostic: authorization claims live in the AT per RFC 9068,
|
||||||
identity claims live in the IT per OIDC Core.
|
identity claims live in the IT per OIDC Core.
|
||||||
|
|
||||||
|
## Standards & compliance notes
|
||||||
|
|
||||||
|
This extension is designed to be compliant with the most relevant OIDC/OAuth2 RFCs:
|
||||||
|
|
||||||
|
- RFC 8414 (Authorization Server Metadata): discovery via `/.well-known/openid-configuration`
|
||||||
|
- OpenID Connect Core 1.0: Authorization Code flow + PKCE + `nonce` validation on ID token
|
||||||
|
- RFC 7636 (PKCE): S256 challenge/verifier flow
|
||||||
|
- RFC 6750 (Bearer Token Usage): `WWW-Authenticate` challenges with standard error codes
|
||||||
|
- RFC 9068 (JWT Profile for Access Tokens): JWT bearer access-token validation path
|
||||||
|
- RFC 7519 / RFC 7517 / RFC 7515 family: JWT/JWK/JWS validation via Nimbus + JWKS caching/rotation
|
||||||
|
|
||||||
|
Provider interoperability details:
|
||||||
|
|
||||||
|
- scope extraction supports both standard forms: `scope` (space-delimited string) and `scp` (list/string)
|
||||||
|
- roles remain configurable via `rolesClaimPath` (`realm_access.roles`, `groups`, etc.)
|
||||||
|
- scope claim fallback chain is configurable via `scopeClaimPaths`
|
||||||
|
|
||||||
|
## Testing scopes with Keycloak
|
||||||
|
|
||||||
|
Quick path to test `@ScopesAllowed` end-to-end:
|
||||||
|
|
||||||
|
1. **Create a client scope**
|
||||||
|
- Realm -> Client scopes -> Create
|
||||||
|
- Name: `orders:write` (or any scope name you want to enforce)
|
||||||
|
2. **Attach it to your client**
|
||||||
|
- Clients -> `<your-client>` -> Client scopes
|
||||||
|
- Add the scope as `Default` (always in token) or `Optional` (requested via `scope` param)
|
||||||
|
3. **Ensure scope mapper reaches the token**
|
||||||
|
- For most Keycloak setups this is automatic via built-in `microprofile-jwt`/scope mappers
|
||||||
|
- Verify the access token contains either `scope` string or `scp` list
|
||||||
|
4. **Request the scope in Flash config**
|
||||||
|
- Include it in `OidcConfig.scopes(...)`, e.g. `"openid profile email orders:write"`
|
||||||
|
5. **Protect a handler**
|
||||||
|
- `@ScopesAllowed("orders:write")` on class-based handlers
|
||||||
|
- or `oidc.requireScopes("orders:write")` for lambda routes
|
||||||
|
6. **Verify behavior**
|
||||||
|
- token with scope -> 200
|
||||||
|
- token without scope -> 403 + `WWW-Authenticate: ... insufficient_scope`
|
||||||
|
|
||||||
|
Useful token inspection flow while testing:
|
||||||
|
|
||||||
|
- Obtain a token from Keycloak
|
||||||
|
- Decode payload (`jwt.io` or local tool)
|
||||||
|
- check `scope` / `scp` claims
|
||||||
|
- call your protected endpoint and inspect status + `WWW-Authenticate`
|
||||||
|
|
||||||
## Session store
|
## Session store
|
||||||
|
|
||||||
The default `InMemoryOidcSessionStore` is sufficient for single-instance deployments.
|
The default `InMemoryOidcSessionStore` is sufficient for single-instance deployments.
|
||||||
@@ -316,32 +419,39 @@ app.install(new OidcExtension(tenantA))
|
|||||||
.install(new OidcExtension(tenantB));
|
.install(new OidcExtension(tenantB));
|
||||||
```
|
```
|
||||||
|
|
||||||
To reference a specific tenant's middleware on lambda routes, keep the extension reference
|
To reference a specific tenant's middleware on lambda routes, keep the extension instances
|
||||||
and retrieve `OidcMiddleware` from context **after** each install:
|
and retrieve `OidcMiddleware` from context after `start()`:
|
||||||
|
|
||||||
```java
|
```java
|
||||||
app.install(new OidcExtension(tenantA));
|
OidcExtension extA = new OidcExtension(tenantA);
|
||||||
OidcMiddleware mwA = app.ctx().require(OidcMiddleware.class); // tenantA's middleware
|
OidcExtension extB = new OidcExtension(tenantB);
|
||||||
|
|
||||||
app.install(new OidcExtension(tenantB));
|
FlashApp app = FlashApp.create(8080)
|
||||||
OidcMiddleware mwB = app.ctx().require(OidcMiddleware.class); // tenantB's middleware
|
.install(extA)
|
||||||
|
.install(extB)
|
||||||
|
.start()
|
||||||
|
.join(); // wait for bind
|
||||||
|
|
||||||
app.get("/a/dashboard", (req, res) -> { ... }).with(mwA.protect());
|
OidcMiddleware mwA = app.ctx().require(OidcMiddleware.class); // last registered = tenantB
|
||||||
app.get("/b/dashboard", (req, res) -> { ... }).with(mwB.protect());
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **Note:** because both extensions register `OidcMiddleware.class` in the same context,
|
||||||
|
> only the last one wins under that key. For multi-tenant setups, use distinct context
|
||||||
|
> keys or provide middleware under a wrapper/alias type, or use lambda routes with explicit
|
||||||
|
> middleware captured from the extension instance before `install()`.
|
||||||
|
|
||||||
Class-based handlers annotated with `@Authenticated` / `@RolesAllowed` get the last
|
Class-based handlers annotated with `@Authenticated` / `@RolesAllowed` get the last
|
||||||
registered middleware injected. For multi-tenant class-based handlers, use lambdas or
|
registered processor's middleware. For true multi-tenant class-based routing, install
|
||||||
install tenant-specific annotation processors.
|
tenant-specific annotation processors with different annotations.
|
||||||
|
|
||||||
## OpenAPI integration
|
## OpenAPI integration
|
||||||
|
|
||||||
If `flash-ext-openapi` is on the classpath and installed **before** `flash-ext-oidc`,
|
If `flash-ext-openapi` is on the classpath and installed (order irrelevant),
|
||||||
the extension automatically:
|
the extension automatically:
|
||||||
|
|
||||||
- Adds a `components.securitySchemes` entry for the provider (OAuth2, authorizationCode flow)
|
- Adds a `components.securitySchemes` entry for the provider (OAuth2, authorizationCode flow)
|
||||||
- Adds `security` requirements to every operation whose handler carries `@Authenticated`
|
- Adds `security` requirements to every operation whose handler carries `@Authenticated`
|
||||||
or `@RolesAllowed`
|
, `@RolesAllowed`, or `@ScopesAllowed`
|
||||||
|
|
||||||
No extra code needed. To customize the scheme name:
|
No extra code needed. To customize the scheme name:
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package dev.relism.ext.oidc;
|
||||||
|
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compiled authorization policy derived from handler annotations at mount time.
|
||||||
|
* Immutable and allocation-free on the request hot path.
|
||||||
|
*/
|
||||||
|
final class OidcAuthPolicy {
|
||||||
|
|
||||||
|
private static final String[] EMPTY = new String[0];
|
||||||
|
|
||||||
|
private static final OidcAuthPolicy AUTH_REQUIRED = new OidcAuthPolicy(
|
||||||
|
false, EMPTY, EMPTY, ScopesAllowed.Match.ALL);
|
||||||
|
private static final OidcAuthPolicy AUTH_OPTIONAL = new OidcAuthPolicy(
|
||||||
|
true, EMPTY, EMPTY, ScopesAllowed.Match.ALL);
|
||||||
|
|
||||||
|
private final boolean optionalAuth;
|
||||||
|
private final String[] requiredRoles;
|
||||||
|
private final String[] requiredScopes;
|
||||||
|
private final ScopesAllowed.Match scopeMatch;
|
||||||
|
|
||||||
|
private OidcAuthPolicy(boolean optionalAuth,
|
||||||
|
String[] requiredRoles,
|
||||||
|
String[] requiredScopes,
|
||||||
|
ScopesAllowed.Match scopeMatch) {
|
||||||
|
this.optionalAuth = optionalAuth;
|
||||||
|
this.requiredRoles = requiredRoles;
|
||||||
|
this.requiredScopes = requiredScopes;
|
||||||
|
this.scopeMatch = scopeMatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
static OidcAuthPolicy authenticated() { return AUTH_REQUIRED; }
|
||||||
|
|
||||||
|
static OidcAuthPolicy optional() { return AUTH_OPTIONAL; }
|
||||||
|
|
||||||
|
static OidcAuthPolicy rolesAny(String... roles) {
|
||||||
|
return new OidcAuthPolicy(false, normalizeRequired("RolesAllowed", roles), EMPTY, ScopesAllowed.Match.ALL);
|
||||||
|
}
|
||||||
|
|
||||||
|
static OidcAuthPolicy scopes(String[] scopes, ScopesAllowed.Match match) {
|
||||||
|
return new OidcAuthPolicy(false, EMPTY, normalizeRequired("ScopesAllowed", scopes), match);
|
||||||
|
}
|
||||||
|
|
||||||
|
static OidcAuthPolicy compileFromAnnotations(Class<?> handlerClass) {
|
||||||
|
Authenticated auth = handlerClass.getAnnotation(Authenticated.class);
|
||||||
|
RolesAllowed roles = handlerClass.getAnnotation(RolesAllowed.class);
|
||||||
|
ScopesAllowed scopes = handlerClass.getAnnotation(ScopesAllowed.class);
|
||||||
|
|
||||||
|
if (auth == null && roles == null && scopes == null) return null;
|
||||||
|
|
||||||
|
boolean optionalAuth = auth != null && auth.optional();
|
||||||
|
String[] requiredRoles = roles != null ? normalizeRequired("RolesAllowed", roles.value()) : EMPTY;
|
||||||
|
String[] requiredScopes = scopes != null ? normalizeRequired("ScopesAllowed", scopes.value()) : EMPTY;
|
||||||
|
ScopesAllowed.Match scopeMatch = scopes != null ? scopes.match() : ScopesAllowed.Match.ALL;
|
||||||
|
|
||||||
|
if (optionalAuth && (requiredRoles.length > 0 || requiredScopes.length > 0)) {
|
||||||
|
throw new IllegalStateException("@Authenticated(optional = true) cannot be combined with @RolesAllowed/@ScopesAllowed on "
|
||||||
|
+ handlerClass.getName());
|
||||||
|
}
|
||||||
|
|
||||||
|
return new OidcAuthPolicy(optionalAuth, requiredRoles, requiredScopes, scopeMatch);
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<String> openApiScopesFor(Class<?> handlerClass) {
|
||||||
|
Authenticated auth = handlerClass.getAnnotation(Authenticated.class);
|
||||||
|
RolesAllowed roles = handlerClass.getAnnotation(RolesAllowed.class);
|
||||||
|
ScopesAllowed scopes = handlerClass.getAnnotation(ScopesAllowed.class);
|
||||||
|
if (auth == null && roles == null && scopes == null) return null;
|
||||||
|
if (scopes == null) return List.of();
|
||||||
|
return List.of(normalizeRequired("ScopesAllowed", scopes.value()));
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean optionalAuth() { return optionalAuth; }
|
||||||
|
|
||||||
|
String[] requiredRoles() { return requiredRoles; }
|
||||||
|
|
||||||
|
String[] requiredScopes() { return requiredScopes; }
|
||||||
|
|
||||||
|
ScopesAllowed.Match scopeMatch() { return scopeMatch; }
|
||||||
|
|
||||||
|
private static String[] normalizeRequired(String annotation, String[] values) {
|
||||||
|
if (values == null || values.length == 0)
|
||||||
|
throw new IllegalStateException("@" + annotation + " requires at least one value");
|
||||||
|
|
||||||
|
LinkedHashSet<String> normalized = new LinkedHashSet<>(values.length);
|
||||||
|
for (String raw : values) {
|
||||||
|
if (raw == null) continue;
|
||||||
|
String trimmed = raw.trim();
|
||||||
|
if (!trimmed.isEmpty()) normalized.add(trimmed);
|
||||||
|
}
|
||||||
|
if (normalized.isEmpty())
|
||||||
|
throw new IllegalStateException("@" + annotation + " requires at least one non-empty value");
|
||||||
|
|
||||||
|
return normalized.toArray(String[]::new);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ package dev.relism.ext.oidc;
|
|||||||
* "https://keycloak.example.com/realms/myrealm",
|
* "https://keycloak.example.com/realms/myrealm",
|
||||||
* "my-app", "secret", "/auth/callback")
|
* "my-app", "secret", "/auth/callback")
|
||||||
* .rolesClaimPath("realm_access.roles") // Keycloak default
|
* .rolesClaimPath("realm_access.roles") // Keycloak default
|
||||||
|
* .scopeClaimPaths("scope,scp") // default; supports many IdPs
|
||||||
* .build();
|
* .build();
|
||||||
*
|
*
|
||||||
* // Authelia
|
* // Authelia
|
||||||
@@ -24,6 +25,7 @@ package dev.relism.ext.oidc;
|
|||||||
* "https://auth.example.com",
|
* "https://auth.example.com",
|
||||||
* "my-app", "secret", "/auth/callback")
|
* "my-app", "secret", "/auth/callback")
|
||||||
* .rolesClaimPath("groups")
|
* .rolesClaimPath("groups")
|
||||||
|
* .scopeClaimPaths("scope,scp")
|
||||||
* .build();
|
* .build();
|
||||||
*
|
*
|
||||||
* // Two tenants on one server
|
* // Two tenants on one server
|
||||||
@@ -45,6 +47,7 @@ public final class OidcConfig {
|
|||||||
private final String routePrefix;
|
private final String routePrefix;
|
||||||
private final String selfScheme;
|
private final String selfScheme;
|
||||||
private final String rolesClaimPath;
|
private final String rolesClaimPath;
|
||||||
|
private final String scopeClaimPaths;
|
||||||
private final String algorithm;
|
private final String algorithm;
|
||||||
private final String postLogoutRedirectUri;
|
private final String postLogoutRedirectUri;
|
||||||
private final OidcSessionStore sessionStore;
|
private final OidcSessionStore sessionStore;
|
||||||
@@ -61,6 +64,7 @@ public final class OidcConfig {
|
|||||||
this.routePrefix = b.routePrefix;
|
this.routePrefix = b.routePrefix;
|
||||||
this.selfScheme = b.selfScheme;
|
this.selfScheme = b.selfScheme;
|
||||||
this.rolesClaimPath = b.rolesClaimPath;
|
this.rolesClaimPath = b.rolesClaimPath;
|
||||||
|
this.scopeClaimPaths = b.scopeClaimPaths;
|
||||||
this.algorithm = b.algorithm;
|
this.algorithm = b.algorithm;
|
||||||
this.postLogoutRedirectUri = b.postLogoutRedirectUri;
|
this.postLogoutRedirectUri = b.postLogoutRedirectUri;
|
||||||
this.sessionStore = b.sessionStore != null ? b.sessionStore
|
this.sessionStore = b.sessionStore != null ? b.sessionStore
|
||||||
@@ -80,6 +84,8 @@ public final class OidcConfig {
|
|||||||
public String routePrefix() { return routePrefix; }
|
public String routePrefix() { return routePrefix; }
|
||||||
public String selfScheme() { return selfScheme; }
|
public String selfScheme() { return selfScheme; }
|
||||||
public String rolesClaimPath() { return rolesClaimPath; }
|
public String rolesClaimPath() { return rolesClaimPath; }
|
||||||
|
/** Comma-separated claim paths used to read OAuth2 scopes (default: {@code "scope,scp"}). */
|
||||||
|
public String scopeClaimPaths() { return scopeClaimPaths; }
|
||||||
public String algorithm() { return algorithm; }
|
public String algorithm() { return algorithm; }
|
||||||
public String postLogoutRedirectUri() { return postLogoutRedirectUri; }
|
public String postLogoutRedirectUri() { return postLogoutRedirectUri; }
|
||||||
public OidcSessionStore sessionStore() { return sessionStore; }
|
public OidcSessionStore sessionStore() { return sessionStore; }
|
||||||
@@ -102,6 +108,7 @@ public final class OidcConfig {
|
|||||||
* OIDC_ROUTE_PREFIX default: /auth
|
* OIDC_ROUTE_PREFIX default: /auth
|
||||||
* OIDC_SELF_SCHEME default: http
|
* OIDC_SELF_SCHEME default: http
|
||||||
* OIDC_ROLES_CLAIM default: realm_access.roles
|
* OIDC_ROLES_CLAIM default: realm_access.roles
|
||||||
|
* OIDC_SCOPE_CLAIMS default: scope,scp
|
||||||
* OIDC_ALGORITHM default: RS256
|
* OIDC_ALGORITHM default: RS256
|
||||||
* OIDC_POST_LOGOUT_REDIRECT default: /
|
* OIDC_POST_LOGOUT_REDIRECT default: /
|
||||||
* </pre>
|
* </pre>
|
||||||
@@ -113,6 +120,7 @@ public final class OidcConfig {
|
|||||||
.routePrefix (envOr("OIDC_ROUTE_PREFIX", "/auth"))
|
.routePrefix (envOr("OIDC_ROUTE_PREFIX", "/auth"))
|
||||||
.selfScheme (envOr("OIDC_SELF_SCHEME", "http"))
|
.selfScheme (envOr("OIDC_SELF_SCHEME", "http"))
|
||||||
.rolesClaimPath (envOr("OIDC_ROLES_CLAIM", "realm_access.roles"))
|
.rolesClaimPath (envOr("OIDC_ROLES_CLAIM", "realm_access.roles"))
|
||||||
|
.scopeClaimPaths (envOr("OIDC_SCOPE_CLAIMS", "scope,scp"))
|
||||||
.algorithm (envOr("OIDC_ALGORITHM", "RS256"))
|
.algorithm (envOr("OIDC_ALGORITHM", "RS256"))
|
||||||
.postLogoutRedirectUri(envOr("OIDC_POST_LOGOUT_REDIRECT", "/"))
|
.postLogoutRedirectUri(envOr("OIDC_POST_LOGOUT_REDIRECT", "/"))
|
||||||
.clientAuthMethod(ClientAuthMethod.valueOf(
|
.clientAuthMethod(ClientAuthMethod.valueOf(
|
||||||
@@ -179,6 +187,7 @@ public final class OidcConfig {
|
|||||||
private String routePrefix = "/auth";
|
private String routePrefix = "/auth";
|
||||||
private String selfScheme = "http";
|
private String selfScheme = "http";
|
||||||
private String rolesClaimPath = "realm_access.roles";
|
private String rolesClaimPath = "realm_access.roles";
|
||||||
|
private String scopeClaimPaths = "scope,scp";
|
||||||
private String algorithm = "RS256";
|
private String algorithm = "RS256";
|
||||||
private String postLogoutRedirectUri = "/";
|
private String postLogoutRedirectUri = "/";
|
||||||
private OidcSessionStore sessionStore;
|
private OidcSessionStore sessionStore;
|
||||||
@@ -203,6 +212,8 @@ public final class OidcConfig {
|
|||||||
public Builder https() { return selfScheme("https"); }
|
public Builder https() { return selfScheme("https"); }
|
||||||
/** Dot-separated path to the roles array in JWT claims (default: {@code realm_access.roles}). */
|
/** Dot-separated path to the roles array in JWT claims (default: {@code realm_access.roles}). */
|
||||||
public Builder rolesClaimPath(String path) { this.rolesClaimPath = path; return this; }
|
public Builder rolesClaimPath(String path) { this.rolesClaimPath = path; return this; }
|
||||||
|
/** Comma-separated claim paths used to resolve OAuth2 scopes (default: {@code scope,scp}). */
|
||||||
|
public Builder scopeClaimPaths(String paths) { this.scopeClaimPaths = paths; return this; }
|
||||||
/** JWS algorithm (default: {@code RS256}). */
|
/** JWS algorithm (default: {@code RS256}). */
|
||||||
public Builder algorithm(String algorithm) { this.algorithm = algorithm; return this; }
|
public Builder algorithm(String algorithm) { this.algorithm = algorithm; return this; }
|
||||||
/** Where to redirect after logout (default: {@code /}). */
|
/** Where to redirect after logout (default: {@code /}). */
|
||||||
|
|||||||
+50
-89
@@ -21,20 +21,21 @@ import java.util.UUID;
|
|||||||
/**
|
/**
|
||||||
* Full OIDC Authorization Code + PKCE flow for Flash.
|
* Full OIDC Authorization Code + PKCE flow for Flash.
|
||||||
*
|
*
|
||||||
* <p>On {@link #install}, the extension:
|
* <p>At {@link #provide}, the extension:
|
||||||
* <ol>
|
* <ol>
|
||||||
* <li>Fetches the provider discovery document — fail-fast at startup.</li>
|
* <li>Fetches the provider discovery document — fail-fast at startup.</li>
|
||||||
* <li>Registers three routes on the {@link FlashApp}:
|
* <li>Provides {@link OidcMiddleware} and {@link JwtValidator} in the context.</li>
|
||||||
|
* <li>Registers annotation processors for {@link Authenticated}, {@link RolesAllowed}
|
||||||
|
* and {@link ScopesAllowed}.</li>
|
||||||
|
* </ol>
|
||||||
|
*
|
||||||
|
* <p>At {@link #routes}, three routes are registered:
|
||||||
* <ul>
|
* <ul>
|
||||||
* <li>{@code GET {prefix}/login} — builds the authorization URL and redirects.</li>
|
* <li>{@code GET {prefix}/login} — builds the authorization URL and redirects.</li>
|
||||||
* <li>{@code GET {prefix}/callback} — exchanges the code, creates a session, redirects.</li>
|
* <li>{@code GET {prefix}/callback} — exchanges the code, creates a session, redirects.</li>
|
||||||
* <li>{@code POST {prefix}/logout} — invalidates the session, redirects to the provider's
|
* <li>{@code POST {prefix}/logout} — invalidates the session, redirects to provider
|
||||||
* end-session endpoint (if available) or to {@link OidcConfig#postLogoutRedirectUri()}.</li>
|
* end-session endpoint (if available) or to {@link OidcConfig#postLogoutRedirectUri()}.</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* </li>
|
|
||||||
* <li>Provides {@link OidcMiddleware} and {@link JwtValidator} in the context.</li>
|
|
||||||
* <li>Registers an annotation processor for {@link Authenticated} and {@link RolesAllowed}.</li>
|
|
||||||
* </ol>
|
|
||||||
*
|
*
|
||||||
* <pre>{@code
|
* <pre>{@code
|
||||||
* // Keycloak
|
* // Keycloak
|
||||||
@@ -54,41 +55,48 @@ public class OidcExtension implements FlashExtension {
|
|||||||
|
|
||||||
private final OidcConfig config;
|
private final OidcConfig config;
|
||||||
|
|
||||||
|
// Initialized in provide(), used in routes() — private to this extension instance.
|
||||||
|
private OidcProviderMetadata meta;
|
||||||
|
private OidcStateStore stateStore;
|
||||||
|
private TokenClient tokenClient;
|
||||||
|
private JwtValidator validator;
|
||||||
|
private OidcMiddleware oidcMw;
|
||||||
|
|
||||||
public OidcExtension(OidcConfig config) {
|
public OidcExtension(OidcConfig config) {
|
||||||
this.config = config;
|
this.config = config;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
// ── Phase 1: services ─────────────────────────────────────────────────────
|
||||||
public void install(FlashRegistrar app, FlashContext ctx) {
|
|
||||||
|
|
||||||
// 1. Build the shared HttpClient (optionally with TLS verification disabled)
|
@Override
|
||||||
|
public void provide(FlashContext ctx) {
|
||||||
HttpClient http = buildHttpClient(config);
|
HttpClient http = buildHttpClient(config);
|
||||||
|
|
||||||
// 2. Discover provider endpoints (blocking; fail fast at startup)
|
// Discover provider endpoints (blocking; fail fast at startup).
|
||||||
OidcProviderMetadata meta;
|
|
||||||
try {
|
try {
|
||||||
meta = DiscoveryClient.fetch(config.issuer(), http);
|
meta = DiscoveryClient.fetch(config.issuer(), http);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
throw new IllegalStateException(
|
throw new IllegalStateException("OIDC discovery failed for issuer: " + config.issuer(), e);
|
||||||
"OIDC discovery failed for issuer: " + config.issuer(), e);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. JWKS-backed access-token validator
|
validator = new JwtValidator(meta.jwksUri(), config.issuer(), config.clientId(), config.algorithm(), http);
|
||||||
JwtValidator validator = new JwtValidator(
|
stateStore = new OidcStateStore();
|
||||||
meta.jwksUri(), config.issuer(), config.clientId(),
|
tokenClient = new TokenClient(http, config);
|
||||||
config.algorithm(), http);
|
oidcMw = new OidcMiddleware(validator, config, meta, tokenClient);
|
||||||
|
|
||||||
// 4. PKCE state store (per extension instance — safe for multi-tenant)
|
|
||||||
OidcStateStore stateStore = new OidcStateStore();
|
|
||||||
|
|
||||||
// 5. Shared token client (injected into middleware for refresh)
|
|
||||||
TokenClient tokenClient = new TokenClient(http, config);
|
|
||||||
|
|
||||||
// 6. Middleware (also exposed in context for manual lambda-route protection)
|
|
||||||
OidcMiddleware oidcMw = new OidcMiddleware(validator, config, meta, tokenClient);
|
|
||||||
ctx.provide(OidcMiddleware.class, oidcMw);
|
ctx.provide(OidcMiddleware.class, oidcMw);
|
||||||
ctx.provide(JwtValidator.class, validator);
|
ctx.provide(JwtValidator.class, validator);
|
||||||
|
|
||||||
|
ctx.addAnnotationProcessor(handlerClass -> {
|
||||||
|
OidcAuthPolicy policy = OidcAuthPolicy.compileFromAnnotations(handlerClass);
|
||||||
|
return policy != null ? List.of(oidcMw.policyMiddleware(policy)) : List.of();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Phase 2: routes ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void routes(FlashRegistrar<?> app, FlashContext ctx) {
|
||||||
String prefix = config.routePrefix();
|
String prefix = config.routePrefix();
|
||||||
|
|
||||||
// ── GET {prefix}/login ────────────────────────────────────────────────
|
// ── GET {prefix}/login ────────────────────────────────────────────────
|
||||||
@@ -97,11 +105,10 @@ public class OidcExtension implements FlashExtension {
|
|||||||
app.get(prefix + "/login", (req, res) -> {
|
app.get(prefix + "/login", (req, res) -> {
|
||||||
String verifier = PkceUtils.generateVerifier();
|
String verifier = PkceUtils.generateVerifier();
|
||||||
String challenge = PkceUtils.computeChallenge(verifier);
|
String challenge = PkceUtils.computeChallenge(verifier);
|
||||||
String state = UUID.randomUUID().toString(); // CSRF protection
|
String state = UUID.randomUUID().toString();
|
||||||
String nonce = UUID.randomUUID().toString(); // ID token replay protection
|
String nonce = UUID.randomUUID().toString();
|
||||||
|
|
||||||
String redirect = req.query("redirect");
|
String redirect = req.query("redirect");
|
||||||
// Only allow relative paths — prevents open-redirect attacks
|
|
||||||
if (redirect == null || !redirect.startsWith("/")) redirect = "/";
|
if (redirect == null || !redirect.startsWith("/")) redirect = "/";
|
||||||
|
|
||||||
stateStore.put(state, redirect, verifier, nonce);
|
stateStore.put(state, redirect, verifier, nonce);
|
||||||
@@ -118,7 +125,7 @@ public class OidcExtension implements FlashExtension {
|
|||||||
|
|
||||||
res.redirect(authUrl);
|
res.redirect(authUrl);
|
||||||
return null;
|
return null;
|
||||||
}).with();
|
});
|
||||||
|
|
||||||
// ── GET {prefix}/callback ─────────────────────────────────────────────
|
// ── GET {prefix}/callback ─────────────────────────────────────────────
|
||||||
// Validates state, exchanges code for tokens, creates session, redirects.
|
// Validates state, exchanges code for tokens, creates session, redirects.
|
||||||
@@ -154,32 +161,24 @@ public class OidcExtension implements FlashExtension {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Map<String, Object> claims = mergeClaims(tokens);
|
Map<String, Object> claims = mergeClaims(tokens);
|
||||||
|
|
||||||
OidcSession session = new OidcSession(
|
OidcSession session = new OidcSession(
|
||||||
UUID.randomUUID().toString(),
|
UUID.randomUUID().toString(),
|
||||||
tokens.accessToken(),
|
tokens.accessToken(), tokens.idToken(), tokens.refreshToken(),
|
||||||
tokens.idToken(),
|
Instant.now().plusSeconds(tokens.expiresIn()), claims);
|
||||||
tokens.refreshToken(),
|
|
||||||
Instant.now().plusSeconds(tokens.expiresIn()),
|
|
||||||
claims
|
|
||||||
);
|
|
||||||
config.sessionStore().save(session);
|
config.sessionStore().save(session);
|
||||||
|
|
||||||
res.header("Set-Cookie", sessionCookie(session.id()))
|
res.header("Set-Cookie", sessionCookie(session.id()))
|
||||||
.redirect(entry.originalUrl());
|
.redirect(entry.originalUrl());
|
||||||
return null;
|
return null;
|
||||||
}).with();
|
});
|
||||||
|
|
||||||
// ── POST {prefix}/logout ──────────────────────────────────────────────
|
// ── POST {prefix}/logout ──────────────────────────────────────────────
|
||||||
// Invalidates the local session and redirects to the provider's
|
// Invalidates the local session and redirects to end_session_endpoint.
|
||||||
// end_session_endpoint (with id_token_hint) if available.
|
|
||||||
app.post(prefix + "/logout", (req, res) -> {
|
app.post(prefix + "/logout", (req, res) -> {
|
||||||
String sessionId = OidcMiddleware.cookieValue(req, "oidc_session");
|
String sessionId = OidcMiddleware.cookieValue(req, "oidc_session");
|
||||||
String idTokenHint = null;
|
String idTokenHint = null;
|
||||||
|
|
||||||
if (sessionId != null) {
|
if (sessionId != null) {
|
||||||
config.sessionStore().find(sessionId)
|
|
||||||
.ifPresent(s -> {}); // capture id_token before delete
|
|
||||||
OidcSession session = config.sessionStore().find(sessionId).orElse(null);
|
OidcSession session = config.sessionStore().find(sessionId).orElse(null);
|
||||||
if (session != null) idTokenHint = session.idToken();
|
if (session != null) idTokenHint = session.idToken();
|
||||||
config.sessionStore().delete(sessionId);
|
config.sessionStore().delete(sessionId);
|
||||||
@@ -199,25 +198,11 @@ public class OidcExtension implements FlashExtension {
|
|||||||
location = config.postLogoutRedirectUri();
|
location = config.postLogoutRedirectUri();
|
||||||
}
|
}
|
||||||
|
|
||||||
res.header("Set-Cookie", clearCookie)
|
res.header("Set-Cookie", clearCookie).redirect(location);
|
||||||
.redirect(location);
|
|
||||||
return null;
|
return null;
|
||||||
}).with();
|
|
||||||
|
|
||||||
// 5. Annotation processor for @Authenticated / @RolesAllowed
|
|
||||||
ctx.addAnnotationProcessor(handlerClass -> {
|
|
||||||
RolesAllowed roles = handlerClass.getAnnotation(RolesAllowed.class);
|
|
||||||
if (roles != null) return List.of(oidcMw.rolesMiddleware(roles.value()));
|
|
||||||
|
|
||||||
Authenticated auth = handlerClass.getAnnotation(Authenticated.class);
|
|
||||||
if (auth != null) return List.of(auth.optional()
|
|
||||||
? oidcMw.optionalMiddleware()
|
|
||||||
: oidcMw.authenticatedMiddleware());
|
|
||||||
|
|
||||||
return List.of();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Register OpenAPI security scheme if flash-ext-openapi is on the classpath
|
// Register OpenAPI security scheme if flash-ext-openapi is on the classpath.
|
||||||
try {
|
try {
|
||||||
OpenApiIntegration.register(ctx, config, meta);
|
OpenApiIntegration.register(ctx, config, meta);
|
||||||
} catch (NoClassDefFoundError ignored) {
|
} catch (NoClassDefFoundError ignored) {
|
||||||
@@ -225,24 +210,16 @@ public class OidcExtension implements FlashExtension {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- Helpers --------------------------------------------------------------
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Merges claims from both the access token and the ID token.
|
* Merges claims from both the access token and the ID token.
|
||||||
* The access token carries provider-specific data like {@code realm_access.roles};
|
|
||||||
* the ID token carries standard identity claims (sub, email, name, …).
|
|
||||||
* ID token values win on conflict so that verified identity claims are authoritative.
|
* ID token values win on conflict so that verified identity claims are authoritative.
|
||||||
*/
|
*/
|
||||||
private static Map<String, Object> mergeClaims(OidcTokenResponse tokens) {
|
private static Map<String, Object> mergeClaims(OidcTokenResponse tokens) {
|
||||||
Map<String, Object> merged = new HashMap<>();
|
Map<String, Object> merged = new HashMap<>();
|
||||||
// Access token first — provides roles, resource_access, etc.
|
if (tokens.accessToken() != null) merged.putAll(JwtUtils.parseClaims(tokens.accessToken()));
|
||||||
if (tokens.accessToken() != null) {
|
if (tokens.idToken() != null) merged.putAll(JwtUtils.parseClaims(tokens.idToken()));
|
||||||
merged.putAll(JwtUtils.parseClaims(tokens.accessToken()));
|
|
||||||
}
|
|
||||||
// ID token overrides — its identity claims (sub, email, name, …) take priority.
|
|
||||||
if (tokens.idToken() != null) {
|
|
||||||
merged.putAll(JwtUtils.parseClaims(tokens.idToken()));
|
|
||||||
}
|
|
||||||
return Map.copyOf(merged);
|
return Map.copyOf(merged);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,18 +236,14 @@ public class OidcExtension implements FlashExtension {
|
|||||||
public void checkClientTrusted(X509Certificate[] c, String a) {}
|
public void checkClientTrusted(X509Certificate[] c, String a) {}
|
||||||
public void checkServerTrusted(X509Certificate[] c, String a) {}
|
public void checkServerTrusted(X509Certificate[] c, String a) {}
|
||||||
}};
|
}};
|
||||||
SSLContext ctx = SSLContext.getInstance("TLS");
|
SSLContext sslCtx = SSLContext.getInstance("TLS");
|
||||||
ctx.init(null, trustAll, new SecureRandom());
|
sslCtx.init(null, trustAll, new SecureRandom());
|
||||||
return HttpClient.newBuilder().sslContext(ctx).build();
|
return HttpClient.newBuilder().sslContext(sslCtx).build();
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
throw new IllegalStateException("Failed to create trust-all SSLContext", e);
|
throw new IllegalStateException("Failed to create trust-all SSLContext", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves the configured {@code redirectUri}. If it starts with {@code /},
|
|
||||||
* prepends {@code selfScheme://Host} from the current request.
|
|
||||||
*/
|
|
||||||
private String absoluteRedirectUri(dev.relism.models.Request req) {
|
private String absoluteRedirectUri(dev.relism.models.Request req) {
|
||||||
return absoluteSelf(req, config.redirectUri());
|
return absoluteSelf(req, config.redirectUri());
|
||||||
}
|
}
|
||||||
@@ -291,7 +264,6 @@ public class OidcExtension implements FlashExtension {
|
|||||||
/**
|
/**
|
||||||
* Loaded lazily so that {@code flash-ext-openapi} classes are only resolved at
|
* Loaded lazily so that {@code flash-ext-openapi} classes are only resolved at
|
||||||
* runtime when {@link dev.relism.ext.openapi.OpenApiSecurityRegistry} is actually on the classpath.
|
* runtime when {@link dev.relism.ext.openapi.OpenApiSecurityRegistry} is actually on the classpath.
|
||||||
* If not present, the {@link NoClassDefFoundError} is caught at the call site.
|
|
||||||
*/
|
*/
|
||||||
private static final class OpenApiIntegration {
|
private static final class OpenApiIntegration {
|
||||||
static void register(dev.relism.extension.FlashContext ctx,
|
static void register(dev.relism.extension.FlashContext ctx,
|
||||||
@@ -304,9 +276,6 @@ public class OidcExtension implements FlashExtension {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public java.util.Map<String, Object> schemeDefinition() {
|
public java.util.Map<String, Object> schemeDefinition() {
|
||||||
// Declare only the authorizationCode flow so Swagger UI shows
|
|
||||||
// a single clean "Authorize" dialog instead of expanding every
|
|
||||||
// grant type from the discovery document.
|
|
||||||
java.util.Map<String, String> scopesMap = new java.util.LinkedHashMap<>();
|
java.util.Map<String, String> scopesMap = new java.util.LinkedHashMap<>();
|
||||||
for (String s : config.scopes().split("\\s+")) {
|
for (String s : config.scopes().split("\\s+")) {
|
||||||
if (!s.isBlank()) scopesMap.put(s, s);
|
if (!s.isBlank()) scopesMap.put(s, s);
|
||||||
@@ -324,15 +293,7 @@ public class OidcExtension implements FlashExtension {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public java.util.List<String> requiredFor(Class<?> handlerClass) {
|
public java.util.List<String> requiredFor(Class<?> handlerClass) {
|
||||||
dev.relism.ext.oidc.RolesAllowed roles =
|
return OidcAuthPolicy.openApiScopesFor(handlerClass);
|
||||||
handlerClass.getAnnotation(dev.relism.ext.oidc.RolesAllowed.class);
|
|
||||||
if (roles != null) return java.util.Arrays.asList(roles.value());
|
|
||||||
|
|
||||||
dev.relism.ext.oidc.Authenticated auth =
|
|
||||||
handlerClass.getAnnotation(dev.relism.ext.oidc.Authenticated.class);
|
|
||||||
if (auth != null) return java.util.List.of();
|
|
||||||
|
|
||||||
return null; // not secured by this contributor
|
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
+266
-32
@@ -7,6 +7,7 @@ import dev.relism.routing.Middleware;
|
|||||||
import java.net.URLEncoder;
|
import java.net.URLEncoder;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -15,7 +16,7 @@ import java.util.Optional;
|
|||||||
/**
|
/**
|
||||||
* Request-level OIDC middleware. Exposed in the {@link dev.relism.extension.FlashContext}
|
* Request-level OIDC middleware. Exposed in the {@link dev.relism.extension.FlashContext}
|
||||||
* for manual use on lambda routes; injected automatically for handlers annotated with
|
* for manual use on lambda routes; injected automatically for handlers annotated with
|
||||||
* {@link Authenticated} or {@link RolesAllowed}.
|
* {@link Authenticated}, {@link RolesAllowed} or {@link ScopesAllowed}.
|
||||||
*
|
*
|
||||||
* <p>Resolution order on each request:
|
* <p>Resolution order on each request:
|
||||||
* <ol>
|
* <ol>
|
||||||
@@ -36,10 +37,14 @@ import java.util.Optional;
|
|||||||
*/
|
*/
|
||||||
public class OidcMiddleware {
|
public class OidcMiddleware {
|
||||||
|
|
||||||
|
private static final String BEARER = "Bearer";
|
||||||
|
|
||||||
private final JwtValidator validator;
|
private final JwtValidator validator;
|
||||||
private final OidcConfig config;
|
private final OidcConfig config;
|
||||||
private final OidcProviderMetadata meta;
|
private final OidcProviderMetadata meta;
|
||||||
private final TokenClient tokenClient;
|
private final TokenClient tokenClient;
|
||||||
|
private final String[] roleClaimPathParts;
|
||||||
|
private final String[][] scopeClaimPathParts;
|
||||||
|
|
||||||
OidcMiddleware(JwtValidator validator, OidcConfig config,
|
OidcMiddleware(JwtValidator validator, OidcConfig config,
|
||||||
OidcProviderMetadata meta, TokenClient tokenClient) {
|
OidcProviderMetadata meta, TokenClient tokenClient) {
|
||||||
@@ -47,6 +52,8 @@ public class OidcMiddleware {
|
|||||||
this.config = config;
|
this.config = config;
|
||||||
this.meta = meta;
|
this.meta = meta;
|
||||||
this.tokenClient = tokenClient;
|
this.tokenClient = tokenClient;
|
||||||
|
this.roleClaimPathParts = splitClaimPath(config.rolesClaimPath());
|
||||||
|
this.scopeClaimPathParts = splitClaimPaths(config.scopeClaimPaths());
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- Public API -----------------------------------------------------------
|
// -- Public API -----------------------------------------------------------
|
||||||
@@ -75,7 +82,7 @@ public class OidcMiddleware {
|
|||||||
* logged in (e.g. showing a username on a landing page).
|
* logged in (e.g. showing a username on a landing page).
|
||||||
*
|
*
|
||||||
* <pre>{@code
|
* <pre>{@code
|
||||||
* app.get("/", handler).with(oidc.optional());
|
* app.get("/", handler, oidc.optional());
|
||||||
* // Inside handler: ClaimsHolder.user() is non-null iff the user is logged in.
|
* // Inside handler: ClaimsHolder.user() is non-null iff the user is logged in.
|
||||||
* }</pre>
|
* }</pre>
|
||||||
*/
|
*/
|
||||||
@@ -92,15 +99,15 @@ public class OidcMiddleware {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Like {@link #protect()} but also enforces that the caller holds at least one
|
* Compiled authorization policy path used by annotation-driven mounting.
|
||||||
* of the given roles (OR semantics). Roles are extracted via
|
* The policy is immutable and built once at boot.
|
||||||
* {@link OidcConfig#rolesClaimPath()}.
|
|
||||||
*/
|
*/
|
||||||
public Middleware requireRole(String... roles) {
|
public Middleware authorize(OidcAuthPolicy policy) {
|
||||||
|
if (policy.optionalAuth()) return optional();
|
||||||
return next -> (req, res) -> {
|
return next -> (req, res) -> {
|
||||||
Map<String, Object> claims = resolve(req, res);
|
Map<String, Object> claims = resolve(req, res);
|
||||||
if (claims == null) return null;
|
if (claims == null) return null;
|
||||||
checkRoles(claims, roles);
|
enforcePolicy(claims, policy, res);
|
||||||
ClaimsHolder.set(claims);
|
ClaimsHolder.set(claims);
|
||||||
try {
|
try {
|
||||||
return next.handle(req, res);
|
return next.handle(req, res);
|
||||||
@@ -110,11 +117,40 @@ public class OidcMiddleware {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Like {@link #protect()} but also enforces that the caller holds at least one
|
||||||
|
* of the given roles (OR semantics). Roles are extracted via
|
||||||
|
* {@link OidcConfig#rolesClaimPath()}.
|
||||||
|
*/
|
||||||
|
public Middleware requireRole(String... roles) {
|
||||||
|
return authorize(OidcAuthPolicy.rolesAny(roles));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Requires all listed scopes to be present in the token.
|
||||||
|
* Scopes are resolved from configured claim paths (default: {@code scope,scp}).
|
||||||
|
*/
|
||||||
|
public Middleware requireScopes(String... scopes) {
|
||||||
|
return authorize(OidcAuthPolicy.scopes(scopes, ScopesAllowed.Match.ALL));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Requires at least one of the listed scopes to be present in the token.
|
||||||
|
* Scopes are resolved from configured claim paths (default: {@code scope,scp}).
|
||||||
|
*/
|
||||||
|
public Middleware requireAnyScope(String... scopes) {
|
||||||
|
return authorize(OidcAuthPolicy.scopes(scopes, ScopesAllowed.Match.ANY));
|
||||||
|
}
|
||||||
|
|
||||||
// -- Package-private: AnnotationProcessor hooks ---------------------------
|
// -- Package-private: AnnotationProcessor hooks ---------------------------
|
||||||
|
|
||||||
Middleware authenticatedMiddleware() { return protect(); }
|
Middleware authenticatedMiddleware() { return protect(); }
|
||||||
Middleware optionalMiddleware() { return optional(); }
|
Middleware optionalMiddleware() { return optional(); }
|
||||||
Middleware rolesMiddleware(String[] required) { return requireRole(required); }
|
Middleware rolesMiddleware(String[] required) { return requireRole(required); }
|
||||||
|
Middleware scopesMiddleware(String[] required, ScopesAllowed.Match match) {
|
||||||
|
return authorize(OidcAuthPolicy.scopes(required, match));
|
||||||
|
}
|
||||||
|
Middleware policyMiddleware(OidcAuthPolicy policy) { return authorize(policy); }
|
||||||
|
|
||||||
// -- Internals ------------------------------------------------------------
|
// -- Internals ------------------------------------------------------------
|
||||||
|
|
||||||
@@ -123,9 +159,14 @@ public class OidcMiddleware {
|
|||||||
* when no valid credentials are present. Used by {@link #optional()}.
|
* when no valid credentials are present. Used by {@link #optional()}.
|
||||||
*/
|
*/
|
||||||
private Map<String, Object> resolveQuiet(Request req) {
|
private Map<String, Object> resolveQuiet(Request req) {
|
||||||
String auth = req.header("Authorization");
|
String bearerToken = extractBearerToken(req.header("Authorization"));
|
||||||
if (auth != null && auth.startsWith("Bearer "))
|
if (bearerToken != null) {
|
||||||
return validator.validate(auth.substring(7));
|
try {
|
||||||
|
return validator.validate(bearerToken);
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
String sessionId = cookieValue(req, "oidc_session");
|
String sessionId = cookieValue(req, "oidc_session");
|
||||||
if (sessionId != null) {
|
if (sessionId != null) {
|
||||||
@@ -153,9 +194,15 @@ public class OidcMiddleware {
|
|||||||
*/
|
*/
|
||||||
private Map<String, Object> resolve(Request req, dev.relism.models.Response res) {
|
private Map<String, Object> resolve(Request req, dev.relism.models.Response res) {
|
||||||
// 1. Bearer token
|
// 1. Bearer token
|
||||||
String auth = req.header("Authorization");
|
String bearerToken = extractBearerToken(req.header("Authorization"));
|
||||||
if (auth != null && auth.startsWith("Bearer "))
|
if (bearerToken != null) {
|
||||||
return validator.validate(auth.substring(7));
|
try {
|
||||||
|
return validator.validate(bearerToken);
|
||||||
|
} catch (HttpException e) {
|
||||||
|
res.header("WWW-Authenticate", invalidTokenChallenge());
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 2. Session cookie
|
// 2. Session cookie
|
||||||
String sessionId = cookieValue(req, "oidc_session");
|
String sessionId = cookieValue(req, "oidc_session");
|
||||||
@@ -183,8 +230,10 @@ public class OidcMiddleware {
|
|||||||
|
|
||||||
// 3. No valid credentials
|
// 3. No valid credentials
|
||||||
String accept = req.header("Accept");
|
String accept = req.header("Accept");
|
||||||
if (accept != null && accept.contains("application/json"))
|
if (accept != null && accept.contains("application/json")) {
|
||||||
|
res.header("WWW-Authenticate", bearerChallenge());
|
||||||
throw HttpException.unauthorized();
|
throw HttpException.unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
// Browser — redirect to login, preserving the original URL in state
|
// Browser — redirect to login, preserving the original URL in state
|
||||||
String loginUrl = config.routePrefix() + "/login?redirect="
|
String loginUrl = config.routePrefix() + "/login?redirect="
|
||||||
@@ -209,25 +258,200 @@ public class OidcMiddleware {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void checkRoles(Map<String, Object> claims, String[] required) {
|
private void enforcePolicy(Map<String, Object> claims, OidcAuthPolicy policy, dev.relism.models.Response res) {
|
||||||
List<String> actual = extractRoles(claims);
|
checkRoles(claims, policy.requiredRoles());
|
||||||
for (String role : required) {
|
checkScopes(claims, policy.requiredScopes(), policy.scopeMatch(), res);
|
||||||
if (actual.contains(role)) return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void checkRoles(Map<String, Object> claims, String[] required) {
|
||||||
|
if (required.length == 0) return;
|
||||||
|
if (rolesAllowed(claims, required)) return;
|
||||||
throw HttpException.forbidden();
|
throw HttpException.forbidden();
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
private void checkScopes(Map<String, Object> claims, String[] required, ScopesAllowed.Match match,
|
||||||
private List<String> extractRoles(Map<String, Object> claims) {
|
dev.relism.models.Response res) {
|
||||||
String[] parts = config.rolesClaimPath().split("\\.");
|
if (required.length == 0) return;
|
||||||
Object current = claims;
|
if (scopesAllowed(claims, required, match)) return;
|
||||||
for (String part : parts) {
|
res.header("WWW-Authenticate", insufficientScopeChallenge(required));
|
||||||
if (!(current instanceof Map<?, ?> m)) return List.of();
|
throw HttpException.forbidden();
|
||||||
current = m.get(part);
|
|
||||||
}
|
}
|
||||||
if (current instanceof List<?> list)
|
|
||||||
return list.stream().map(Object::toString).toList();
|
static String extractBearerToken(String authorizationHeader) {
|
||||||
return List.of();
|
if (authorizationHeader == null) return null;
|
||||||
|
int len = authorizationHeader.length();
|
||||||
|
int start = 0;
|
||||||
|
while (start < len && Character.isWhitespace(authorizationHeader.charAt(start))) start++;
|
||||||
|
int schemeEnd = start + BEARER.length();
|
||||||
|
if (schemeEnd > len || !authorizationHeader.regionMatches(true, start, BEARER, 0, BEARER.length())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (schemeEnd == len || !Character.isWhitespace(authorizationHeader.charAt(schemeEnd))) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
int tokenStart = schemeEnd;
|
||||||
|
while (tokenStart < len && Character.isWhitespace(authorizationHeader.charAt(tokenStart))) tokenStart++;
|
||||||
|
if (tokenStart >= len) return null;
|
||||||
|
int tokenEnd = len;
|
||||||
|
while (tokenEnd > tokenStart && Character.isWhitespace(authorizationHeader.charAt(tokenEnd - 1))) tokenEnd--;
|
||||||
|
return tokenEnd > tokenStart ? authorizationHeader.substring(tokenStart, tokenEnd) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String bearerChallenge() {
|
||||||
|
return BEARER + " realm=\"" + quoted(config.schemeName()) + "\"";
|
||||||
|
}
|
||||||
|
|
||||||
|
String invalidTokenChallenge() {
|
||||||
|
return bearerChallenge() + ", error=\"invalid_token\"";
|
||||||
|
}
|
||||||
|
|
||||||
|
String insufficientScopeChallenge(String[] requiredScopes) {
|
||||||
|
return bearerChallenge() + ", error=\"insufficient_scope\", scope=\""
|
||||||
|
+ quoted(spaceDelimited(requiredScopes)) + "\"";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String spaceDelimited(String[] values) {
|
||||||
|
if (values == null || values.length == 0) return "";
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
for (int i = 0; i < values.length; i++) {
|
||||||
|
if (i > 0) sb.append(' ');
|
||||||
|
sb.append(values[i]);
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String quoted(String value) {
|
||||||
|
StringBuilder out = new StringBuilder(value.length() + 8);
|
||||||
|
for (int i = 0; i < value.length(); i++) {
|
||||||
|
char c = value.charAt(i);
|
||||||
|
if (c == '"' || c == '\\') out.append('\\');
|
||||||
|
out.append(c);
|
||||||
|
}
|
||||||
|
return out.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean rolesAllowed(Map<String, Object> claims, String[] required) {
|
||||||
|
Object actual = valueAtPath(claims, roleClaimPathParts);
|
||||||
|
if (actual == null) return false;
|
||||||
|
for (String role : required) {
|
||||||
|
if (containsToken(actual, role)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean scopesAllowed(Map<String, Object> claims, String[] required, ScopesAllowed.Match match) {
|
||||||
|
if (match == ScopesAllowed.Match.ALL) {
|
||||||
|
for (String scope : required) {
|
||||||
|
if (!hasScope(claims, scope)) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
for (String scope : required) {
|
||||||
|
if (hasScope(claims, scope)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean hasScope(Map<String, Object> claims, String scope) {
|
||||||
|
for (String[] pathParts : scopeClaimPathParts) {
|
||||||
|
Object value = valueAtPath(claims, pathParts);
|
||||||
|
if (value != null && containsToken(value, scope)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Object valueAtPath(Map<String, Object> claims, String[] pathParts) {
|
||||||
|
Object current = claims;
|
||||||
|
for (String part : pathParts) {
|
||||||
|
if (!(current instanceof Map<?, ?> map)) return null;
|
||||||
|
current = map.get(part);
|
||||||
|
if (current == null) return null;
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean containsToken(Object source, String token) {
|
||||||
|
if (source instanceof String s) return containsDelimitedToken(s, token);
|
||||||
|
if (source instanceof List<?> list) {
|
||||||
|
for (Object item : list) {
|
||||||
|
if (item == null) continue;
|
||||||
|
if (tokenEquals(item.toString(), token)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (source instanceof Object[] arr) {
|
||||||
|
for (Object item : arr) {
|
||||||
|
if (item == null) continue;
|
||||||
|
if (tokenEquals(item.toString(), token)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return tokenEquals(source.toString(), token);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean containsDelimitedToken(String value, String token) {
|
||||||
|
int len = value.length();
|
||||||
|
int i = 0;
|
||||||
|
while (i < len) {
|
||||||
|
while (i < len && isScopeDelimiter(value.charAt(i))) i++;
|
||||||
|
int start = i;
|
||||||
|
while (i < len && !isScopeDelimiter(value.charAt(i))) i++;
|
||||||
|
int end = i;
|
||||||
|
if (end > start && end - start == token.length() && value.regionMatches(start, token, 0, token.length())) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean tokenEquals(String value, String token) {
|
||||||
|
int start = 0;
|
||||||
|
int end = value.length();
|
||||||
|
while (start < end && Character.isWhitespace(value.charAt(start))) start++;
|
||||||
|
while (end > start && Character.isWhitespace(value.charAt(end - 1))) end--;
|
||||||
|
return end - start == token.length() && value.regionMatches(start, token, 0, token.length());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isScopeDelimiter(char c) {
|
||||||
|
return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == ',';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String[] splitClaimPath(String path) {
|
||||||
|
if (path == null || path.isBlank()) {
|
||||||
|
throw new IllegalStateException("OIDC claim path cannot be blank");
|
||||||
|
}
|
||||||
|
List<String> parts = new ArrayList<>(4);
|
||||||
|
int start = 0;
|
||||||
|
int len = path.length();
|
||||||
|
for (int i = 0; i <= len; i++) {
|
||||||
|
if (i == len || path.charAt(i) == '.') {
|
||||||
|
String p = path.substring(start, i).trim();
|
||||||
|
if (!p.isEmpty()) parts.add(p);
|
||||||
|
start = i + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (parts.isEmpty()) {
|
||||||
|
throw new IllegalStateException("OIDC claim path cannot be blank");
|
||||||
|
}
|
||||||
|
return parts.toArray(String[]::new);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String[][] splitClaimPaths(String paths) {
|
||||||
|
String source = (paths == null || paths.isBlank()) ? "scope,scp" : paths;
|
||||||
|
List<String[]> out = new ArrayList<>(4);
|
||||||
|
int start = 0;
|
||||||
|
int len = source.length();
|
||||||
|
for (int i = 0; i <= len; i++) {
|
||||||
|
if (i == len || source.charAt(i) == ',') {
|
||||||
|
String raw = source.substring(start, i).trim();
|
||||||
|
if (!raw.isEmpty()) out.add(splitClaimPath(raw));
|
||||||
|
start = i + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (out.isEmpty()) {
|
||||||
|
return new String[][]{ splitClaimPath("scope"), splitClaimPath("scp") };
|
||||||
|
}
|
||||||
|
return out.toArray(String[][]::new);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Map<String, Object> mergeRefreshedClaims(OidcTokenResponse tokens, OidcSession old) {
|
private static Map<String, Object> mergeRefreshedClaims(OidcTokenResponse tokens, OidcSession old) {
|
||||||
@@ -246,10 +470,20 @@ public class OidcMiddleware {
|
|||||||
static String cookieValue(Request req, String name) {
|
static String cookieValue(Request req, String name) {
|
||||||
String header = req.header("Cookie");
|
String header = req.header("Cookie");
|
||||||
if (header == null || header.isBlank()) return null;
|
if (header == null || header.isBlank()) return null;
|
||||||
for (String part : header.split(";")) {
|
int len = header.length();
|
||||||
int eq = part.indexOf('=');
|
int start = 0;
|
||||||
if (eq > 0 && part.substring(0, eq).strip().equals(name))
|
while (start < len) {
|
||||||
return part.substring(eq + 1).strip();
|
int semi = header.indexOf(';', start);
|
||||||
|
int end = semi < 0 ? len : semi;
|
||||||
|
int eq = header.indexOf('=', start);
|
||||||
|
if (eq > start && eq < end) {
|
||||||
|
int ns = start, ne = eq;
|
||||||
|
while (ns < ne && header.charAt(ns) == ' ') ns++;
|
||||||
|
while (ne > ns && header.charAt(ne-1) == ' ') ne--;
|
||||||
|
if (ne - ns == name.length() && header.regionMatches(ns, name, 0, name.length()))
|
||||||
|
return header.substring(eq + 1, end).strip();
|
||||||
|
}
|
||||||
|
start = end + 1;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package dev.relism.ext.oidc;
|
|||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Type-safe view over the JWT claims stored in {@link ClaimsHolder}.
|
* Type-safe view over the JWT claims stored in {@link ClaimsHolder}.
|
||||||
@@ -16,7 +17,7 @@ import java.util.Map;
|
|||||||
* // Lambda route (OidcMiddleware injected):
|
* // Lambda route (OidcMiddleware injected):
|
||||||
* app.get("/api/whoami", (req, res) -> {
|
* app.get("/api/whoami", (req, res) -> {
|
||||||
* OidcUser u = ClaimsHolder.user();
|
* OidcUser u = ClaimsHolder.user();
|
||||||
* return Map.of("sub", u.sub(), "email", u.email(), "roles", u.roles());
|
* return Map.of("sub", u.sub(), "email", u.email(), "roles", u.roles("realm_access.roles"), "scopes", u.scopes());
|
||||||
* }, oidcMw.protect());
|
* }, oidcMw.protect());
|
||||||
*
|
*
|
||||||
* // Class-based handler (currentUser is the DB entity; oidcUser() for raw claims):
|
* // Class-based handler (currentUser is the DB entity; oidcUser() for raw claims):
|
||||||
@@ -80,6 +81,67 @@ public final class OidcUser {
|
|||||||
return roles(claimPath).contains(role);
|
return roles(claimPath).contains(role);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -- Scopes ---------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves OAuth2 scopes from standard OIDC/OAuth claims using fallback order:
|
||||||
|
* {@code scope} then {@code scp}. Supports both space-separated string and list forms.
|
||||||
|
*/
|
||||||
|
public List<String> scopes() {
|
||||||
|
return scopes("scope,scp");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves scopes from comma-separated claim paths (example: {@code "scope,scp,permissions.scopes"}).
|
||||||
|
*/
|
||||||
|
public List<String> scopes(String claimPaths) {
|
||||||
|
List<String> out = new ArrayList<>();
|
||||||
|
for (String[] path : splitClaimPaths(claimPaths)) {
|
||||||
|
Object value = valueAtPath(path);
|
||||||
|
if (value == null) continue;
|
||||||
|
if (value instanceof String s) {
|
||||||
|
appendDelimitedTokens(out, s);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (value instanceof List<?> list) {
|
||||||
|
for (Object item : list) {
|
||||||
|
if (item == null) continue;
|
||||||
|
String token = item.toString().trim();
|
||||||
|
if (!token.isEmpty()) out.add(token);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String token = value.toString().trim();
|
||||||
|
if (!token.isEmpty()) out.add(token);
|
||||||
|
}
|
||||||
|
return out.isEmpty() ? List.of() : List.copyOf(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns {@code true} if the user has {@code scope}, searching default claim paths {@code scope,scp}. */
|
||||||
|
public boolean hasScope(String scope) {
|
||||||
|
return hasScope("scope,scp", scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns {@code true} if the user has {@code scope} in any of {@code claimPaths}. */
|
||||||
|
public boolean hasScope(String claimPaths, String scope) {
|
||||||
|
if (scope == null || scope.isBlank()) return false;
|
||||||
|
String target = scope.trim();
|
||||||
|
for (String[] path : splitClaimPaths(claimPaths)) {
|
||||||
|
Object value = valueAtPath(path);
|
||||||
|
if (value == null) continue;
|
||||||
|
if (value instanceof String s && containsDelimitedToken(s, target)) return true;
|
||||||
|
if (value instanceof List<?> list) {
|
||||||
|
for (Object item : list) {
|
||||||
|
if (item == null) continue;
|
||||||
|
if (target.equals(item.toString().trim())) return true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (target.equals(value.toString().trim())) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Arbitrary claim access ─────────────────────────────────────────────
|
// ── Arbitrary claim access ─────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -103,4 +165,73 @@ public final class OidcUser {
|
|||||||
Object v = claims.get(key);
|
Object v = claims.get(key);
|
||||||
return v != null ? v.toString() : null;
|
return v != null ? v.toString() : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Object valueAtPath(String[] path) {
|
||||||
|
Object current = claims;
|
||||||
|
for (String part : path) {
|
||||||
|
if (!(current instanceof Map<?, ?> m)) return null;
|
||||||
|
current = m.get(part);
|
||||||
|
if (current == null) return null;
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String[][] splitClaimPaths(String claimPaths) {
|
||||||
|
String source = (claimPaths == null || claimPaths.isBlank()) ? "scope,scp" : claimPaths;
|
||||||
|
List<String[]> out = new ArrayList<>(4);
|
||||||
|
int start = 0;
|
||||||
|
int len = source.length();
|
||||||
|
for (int i = 0; i <= len; i++) {
|
||||||
|
if (i == len || source.charAt(i) == ',') {
|
||||||
|
String raw = source.substring(start, i).trim();
|
||||||
|
if (!raw.isEmpty()) out.add(splitPath(raw));
|
||||||
|
start = i + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out.isEmpty() ? new String[][]{ splitPath("scope"), splitPath("scp") } : out.toArray(String[][]::new);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String[] splitPath(String path) {
|
||||||
|
List<String> out = new ArrayList<>(4);
|
||||||
|
int start = 0;
|
||||||
|
int len = path.length();
|
||||||
|
for (int i = 0; i <= len; i++) {
|
||||||
|
if (i == len || path.charAt(i) == '.') {
|
||||||
|
String raw = path.substring(start, i).trim();
|
||||||
|
if (!raw.isEmpty()) out.add(raw);
|
||||||
|
start = i + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out.isEmpty() ? new String[]{ path } : out.toArray(String[]::new);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void appendDelimitedTokens(List<String> target, String source) {
|
||||||
|
int len = source.length();
|
||||||
|
int i = 0;
|
||||||
|
while (i < len) {
|
||||||
|
while (i < len && isDelimiter(source.charAt(i))) i++;
|
||||||
|
int start = i;
|
||||||
|
while (i < len && !isDelimiter(source.charAt(i))) i++;
|
||||||
|
if (i > start) target.add(source.substring(start, i));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean containsDelimitedToken(String source, String token) {
|
||||||
|
int len = source.length();
|
||||||
|
int i = 0;
|
||||||
|
while (i < len) {
|
||||||
|
while (i < len && isDelimiter(source.charAt(i))) i++;
|
||||||
|
int start = i;
|
||||||
|
while (i < len && !isDelimiter(source.charAt(i))) i++;
|
||||||
|
int end = i;
|
||||||
|
if (end > start && end - start == token.length() && source.regionMatches(start, token, 0, token.length())) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isDelimiter(char c) {
|
||||||
|
return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == ',';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package dev.relism.ext.oidc;
|
||||||
|
|
||||||
|
import java.lang.annotation.ElementType;
|
||||||
|
import java.lang.annotation.Retention;
|
||||||
|
import java.lang.annotation.RetentionPolicy;
|
||||||
|
import java.lang.annotation.Target;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restricts a handler to callers whose token carries the required OAuth2 scopes.
|
||||||
|
* Authentication is implicitly required.
|
||||||
|
*
|
||||||
|
* <p>Scopes are resolved from the configured claim paths in
|
||||||
|
* {@link OidcConfig#scopeClaimPaths()} (default: {@code "scope,scp"}) and support
|
||||||
|
* both standard formats:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code scope}: space-separated string</li>
|
||||||
|
* <li>{@code scp}: string list (or string)</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <pre>{@code
|
||||||
|
* @Route(method = HttpMethod.GET, path = "/api/orders")
|
||||||
|
* @ScopesAllowed("orders:read")
|
||||||
|
* public class ListOrders extends JacksonHandler { ... }
|
||||||
|
*
|
||||||
|
* @Route(method = HttpMethod.POST, path = "/api/orders")
|
||||||
|
* @ScopesAllowed(value = {"orders:write", "payments:write"}, match = ScopesAllowed.Match.ANY)
|
||||||
|
* public class CreateOrder extends JacksonHandler { ... }
|
||||||
|
* }</pre>
|
||||||
|
*/
|
||||||
|
@Retention(RetentionPolicy.RUNTIME)
|
||||||
|
@Target(ElementType.TYPE)
|
||||||
|
public @interface ScopesAllowed {
|
||||||
|
/** Required scopes. */
|
||||||
|
String[] value();
|
||||||
|
|
||||||
|
/** Matching mode for {@link #value()}. */
|
||||||
|
Match match() default Match.ALL;
|
||||||
|
|
||||||
|
enum Match {
|
||||||
|
/** Any one required scope is sufficient. */
|
||||||
|
ANY,
|
||||||
|
/** All required scopes must be present. */
|
||||||
|
ALL
|
||||||
|
}
|
||||||
|
}
|
||||||
+94
@@ -0,0 +1,94 @@
|
|||||||
|
package dev.relism.ext.oidc;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class OidcAuthPolicyTest {
|
||||||
|
|
||||||
|
static class PlainHandler {}
|
||||||
|
|
||||||
|
@Authenticated
|
||||||
|
static class AuthenticatedHandler {}
|
||||||
|
|
||||||
|
@Authenticated(optional = true)
|
||||||
|
static class OptionalHandler {}
|
||||||
|
|
||||||
|
@RolesAllowed({"admin", " editor ", "admin"})
|
||||||
|
static class RolesHandler {}
|
||||||
|
|
||||||
|
@ScopesAllowed(value = {"orders:write", " payments:write ", "orders:write"}, match = ScopesAllowed.Match.ANY)
|
||||||
|
static class ScopesHandler {}
|
||||||
|
|
||||||
|
@Authenticated
|
||||||
|
@RolesAllowed("admin")
|
||||||
|
@ScopesAllowed(value = {"orders:read", "payments:read"}, match = ScopesAllowed.Match.ALL)
|
||||||
|
static class CombinedHandler {}
|
||||||
|
|
||||||
|
@Authenticated(optional = true)
|
||||||
|
@ScopesAllowed("orders:read")
|
||||||
|
static class InvalidOptionalHandler {}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void compileFromAnnotations_noSecurityAnnotations_returnsNull() {
|
||||||
|
assertNull(OidcAuthPolicy.compileFromAnnotations(PlainHandler.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void compileFromAnnotations_authenticated_createsRequiredAuthPolicy() {
|
||||||
|
OidcAuthPolicy policy = OidcAuthPolicy.compileFromAnnotations(AuthenticatedHandler.class);
|
||||||
|
assertNotNull(policy);
|
||||||
|
assertFalse(policy.optionalAuth());
|
||||||
|
assertEquals(0, policy.requiredRoles().length);
|
||||||
|
assertEquals(0, policy.requiredScopes().length);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void compileFromAnnotations_optionalAuth_createsOptionalPolicy() {
|
||||||
|
OidcAuthPolicy policy = OidcAuthPolicy.compileFromAnnotations(OptionalHandler.class);
|
||||||
|
assertNotNull(policy);
|
||||||
|
assertTrue(policy.optionalAuth());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void compileFromAnnotations_rolesAndScopes_areNormalizedAndMerged() {
|
||||||
|
OidcAuthPolicy policy = OidcAuthPolicy.compileFromAnnotations(CombinedHandler.class);
|
||||||
|
assertNotNull(policy);
|
||||||
|
assertFalse(policy.optionalAuth());
|
||||||
|
assertArrayEquals(new String[]{"admin"}, policy.requiredRoles());
|
||||||
|
assertArrayEquals(new String[]{"orders:read", "payments:read"}, policy.requiredScopes());
|
||||||
|
assertEquals(ScopesAllowed.Match.ALL, policy.scopeMatch());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void compileFromAnnotations_scopesAny_preservesMatchModeAndDedupes() {
|
||||||
|
OidcAuthPolicy policy = OidcAuthPolicy.compileFromAnnotations(ScopesHandler.class);
|
||||||
|
assertNotNull(policy);
|
||||||
|
assertArrayEquals(new String[]{"orders:write", "payments:write"}, policy.requiredScopes());
|
||||||
|
assertEquals(ScopesAllowed.Match.ANY, policy.scopeMatch());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void compileFromAnnotations_optionalCannotBeCombinedWithConstraints() {
|
||||||
|
assertThrows(IllegalStateException.class,
|
||||||
|
() -> OidcAuthPolicy.compileFromAnnotations(InvalidOptionalHandler.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void openApiScopesFor_returnsScopesWhenPresent() {
|
||||||
|
assertEquals(List.of("orders:write", "payments:write"),
|
||||||
|
OidcAuthPolicy.openApiScopesFor(ScopesHandler.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void openApiScopesFor_rolesOnly_returnsEmptyList() {
|
||||||
|
assertEquals(List.of(), OidcAuthPolicy.openApiScopesFor(RolesHandler.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void openApiScopesFor_noSecurity_returnsNull() {
|
||||||
|
assertNull(OidcAuthPolicy.openApiScopesFor(PlainHandler.class));
|
||||||
|
}
|
||||||
|
}
|
||||||
+72
@@ -0,0 +1,72 @@
|
|||||||
|
package dev.relism.ext.oidc;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class OidcMiddlewareAuthzTest {
|
||||||
|
|
||||||
|
private static OidcMiddleware middleware(String rolesPath, String scopePaths) {
|
||||||
|
OidcConfig cfg = OidcConfig.builder("https://idp.example.com", "client", "secret", "/auth/callback")
|
||||||
|
.rolesClaimPath(rolesPath)
|
||||||
|
.scopeClaimPaths(scopePaths)
|
||||||
|
.build();
|
||||||
|
return new OidcMiddleware(null, cfg, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rolesAllowed_readsConfiguredNestedClaimPath() {
|
||||||
|
OidcMiddleware mw = middleware("realm_access.roles", "scope,scp");
|
||||||
|
Map<String, Object> claims = Map.of("realm_access", Map.of("roles", List.of("user", "admin")));
|
||||||
|
|
||||||
|
assertTrue(mw.rolesAllowed(claims, new String[]{"admin"}));
|
||||||
|
assertFalse(mw.rolesAllowed(claims, new String[]{"ops"}));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void scopesAllowed_all_requiresEveryScope() {
|
||||||
|
OidcMiddleware mw = middleware("roles", "scope,scp");
|
||||||
|
Map<String, Object> claims = Map.of("scope", "openid profile orders:read");
|
||||||
|
|
||||||
|
assertTrue(mw.scopesAllowed(claims, new String[]{"openid", "orders:read"}, ScopesAllowed.Match.ALL));
|
||||||
|
assertFalse(mw.scopesAllowed(claims, new String[]{"openid", "orders:write"}, ScopesAllowed.Match.ALL));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void scopesAllowed_any_acceptsAnyConfiguredScopeSource() {
|
||||||
|
OidcMiddleware mw = middleware("roles", "scope,scp,permissions.scopes");
|
||||||
|
Map<String, Object> claims = Map.of(
|
||||||
|
"scp", List.of("payments:write"),
|
||||||
|
"permissions", Map.of("scopes", "orders:approve")
|
||||||
|
);
|
||||||
|
|
||||||
|
assertTrue(mw.scopesAllowed(claims, new String[]{"orders:approve", "orders:read"}, ScopesAllowed.Match.ANY));
|
||||||
|
assertTrue(mw.scopesAllowed(claims, new String[]{"payments:write"}, ScopesAllowed.Match.ANY));
|
||||||
|
assertFalse(mw.scopesAllowed(claims, new String[]{"unknown"}, ScopesAllowed.Match.ANY));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void extractBearerToken_acceptsCaseInsensitiveBearerAndTrimsSpaces() {
|
||||||
|
assertEquals("abc.def.ghi", OidcMiddleware.extractBearerToken("Bearer abc.def.ghi"));
|
||||||
|
assertEquals("abc", OidcMiddleware.extractBearerToken(" bearer abc "));
|
||||||
|
assertNull(OidcMiddleware.extractBearerToken("Basic Zm9vOmJhcg=="));
|
||||||
|
assertNull(OidcMiddleware.extractBearerToken("Bearer"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void bearerChallenge_containsRealmAndRfcErrors() {
|
||||||
|
OidcMiddleware mw = middleware("roles", "scope,scp");
|
||||||
|
|
||||||
|
String basic = mw.bearerChallenge();
|
||||||
|
String invalid = mw.invalidTokenChallenge();
|
||||||
|
String insufficient = mw.insufficientScopeChallenge(new String[]{"orders:read", "payments:write"});
|
||||||
|
|
||||||
|
assertTrue(basic.startsWith("Bearer realm=\""));
|
||||||
|
assertTrue(invalid.contains("error=\"invalid_token\""));
|
||||||
|
assertTrue(insufficient.contains("error=\"insufficient_scope\""));
|
||||||
|
assertTrue(insufficient.contains("scope=\"orders:read payments:write\""));
|
||||||
|
}
|
||||||
|
}
|
||||||
+47
@@ -0,0 +1,47 @@
|
|||||||
|
package dev.relism.ext.oidc;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class OidcUserScopesTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void scopes_readsStandardScopeString() {
|
||||||
|
OidcUser user = new OidcUser(Map.of("scope", "openid profile orders:read"));
|
||||||
|
|
||||||
|
assertEquals(List.of("openid", "profile", "orders:read"), user.scopes());
|
||||||
|
assertTrue(user.hasScope("orders:read"));
|
||||||
|
assertFalse(user.hasScope("orders:write"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void scopes_fallsBackToScpArray() {
|
||||||
|
OidcUser user = new OidcUser(Map.of("scp", List.of("orders:write", "payments:write")));
|
||||||
|
|
||||||
|
assertEquals(List.of("orders:write", "payments:write"), user.scopes());
|
||||||
|
assertTrue(user.hasScope("payments:write"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void scopes_supportsCustomClaimPaths() {
|
||||||
|
OidcUser user = new OidcUser(Map.of("permissions", Map.of("scopes", List.of("a", "b"))));
|
||||||
|
|
||||||
|
assertEquals(List.of("a", "b"), user.scopes("permissions.scopes"));
|
||||||
|
assertTrue(user.hasScope("permissions.scopes", "a"));
|
||||||
|
assertFalse(user.hasScope("permissions.scopes", "x"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void scopes_combinesMultipleClaimPathsInOrder() {
|
||||||
|
OidcUser user = new OidcUser(Map.of(
|
||||||
|
"scope", "openid",
|
||||||
|
"scp", List.of("profile", "orders:read")
|
||||||
|
));
|
||||||
|
|
||||||
|
assertEquals(List.of("openid", "profile", "orders:read"), user.scopes("scope,scp"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,8 +15,9 @@ from class-based handlers annotated with `@ApiOperation`.
|
|||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
Requires `flash-ext-jackson` installed **before** this extension (shares its `ObjectMapper` from context).
|
Requires `flash-ext-jackson` (shares its `ObjectMapper` from context).
|
||||||
If `flash-ext-oidc` is installed **after** this extension, OIDC security schemes are injected automatically.
|
If `flash-ext-oidc` is also installed, OIDC security schemes are injected automatically.
|
||||||
|
Install order is irrelevant — the two-phase extension model handles dependency ordering.
|
||||||
|
|
||||||
```xml
|
```xml
|
||||||
<dependency>
|
<dependency>
|
||||||
@@ -51,7 +52,7 @@ All annotations target the **handler class** (`@Target(ElementType.TYPE)`).
|
|||||||
### @ApiOperation
|
### @ApiOperation
|
||||||
|
|
||||||
```java
|
```java
|
||||||
@Route(method = HttpMethod.GET, path = "/api/blogs")
|
@GET("/api/blogs")
|
||||||
@ApiOperation(
|
@ApiOperation(
|
||||||
summary = "List all blogs",
|
summary = "List all blogs",
|
||||||
description = "Returns a paginated list of published blog posts.",
|
description = "Returns a paginated list of published blog posts.",
|
||||||
@@ -98,7 +99,7 @@ Repeatable — declare query, path, header, or cookie parameters explicitly.
|
|||||||
public class GetBlog extends JacksonHandler { ... }
|
public class GetBlog extends JacksonHandler { ... }
|
||||||
```
|
```
|
||||||
|
|
||||||
> Path parameters declared in `@Route(path = "/blogs/{id}")` are extracted and added automatically
|
> Path parameters in the route (e.g. `@GET("/blogs/{id}")` or `@Route(path = "/blogs/{id}")`) are extracted automatically
|
||||||
> as required path parameters — you only need `@ApiParam` for query / header / cookie params.
|
> as required path parameters — you only need `@ApiParam` for query / header / cookie params.
|
||||||
|
|
||||||
`@ApiParam` is repeatable. If you prefer grouping them, `@ApiParams({ @ApiParam(...), @ApiParam(...) })` is
|
`@ApiParam` is repeatable. If you prefer grouping them, `@ApiParams({ @ApiParam(...), @ApiParam(...) })` is
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
<dependencies>
|
<dependencies>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>dev.relism</groupId>
|
<groupId>dev.relism</groupId>
|
||||||
<artifactId>flash-ext-jackson</artifactId>
|
<artifactId>flash</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.fasterxml.jackson.dataformat</groupId>
|
<groupId>com.fasterxml.jackson.dataformat</groupId>
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@ import java.lang.annotation.Target;
|
|||||||
* Picked up by {@link OpenApiExtension} via {@link FlashApp#register}.
|
* Picked up by {@link OpenApiExtension} via {@link FlashApp#register}.
|
||||||
*
|
*
|
||||||
* <pre>{@code
|
* <pre>{@code
|
||||||
* @Route(method = HttpMethod.GET, path = "/api/blogs")
|
* @GET("/api/blogs")
|
||||||
* @ApiOperation(summary = "List all blogs", tags = {"blogs"})
|
* @ApiOperation(summary = "List all blogs", tags = {"blogs"})
|
||||||
* public class ListBlogs extends JacksonHandler { ... }
|
* public class ListBlogs extends JacksonHandler { ... }
|
||||||
* }</pre>
|
* }</pre>
|
||||||
|
|||||||
+1
@@ -20,4 +20,5 @@ public @interface ApiResponse {
|
|||||||
int status();
|
int status();
|
||||||
String description() default "";
|
String description() default "";
|
||||||
Class<?> schema() default Void.class;
|
Class<?> schema() default Void.class;
|
||||||
|
boolean useReturnType() default false;
|
||||||
}
|
}
|
||||||
|
|||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
package dev.relism.ext.openapi;
|
||||||
|
|
||||||
|
import java.lang.annotation.ElementType;
|
||||||
|
import java.lang.annotation.Retention;
|
||||||
|
import java.lang.annotation.RetentionPolicy;
|
||||||
|
import java.lang.annotation.Target;
|
||||||
|
|
||||||
|
/** Optional array-specific schema metadata. */
|
||||||
|
@Retention(RetentionPolicy.RUNTIME)
|
||||||
|
@Target({ElementType.FIELD, ElementType.METHOD})
|
||||||
|
public @interface ArraySchema {
|
||||||
|
Class<?> itemClass() default Void.class;
|
||||||
|
boolean uniqueItems() default false;
|
||||||
|
int minItems() default -1;
|
||||||
|
int maxItems() default -1;
|
||||||
|
}
|
||||||
+259
-61
@@ -1,15 +1,22 @@
|
|||||||
package dev.relism.ext.openapi;
|
package dev.relism.ext.openapi;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty.Access;
|
||||||
import dev.relism.routing.Route;
|
import dev.relism.routing.Route;
|
||||||
|
|
||||||
|
import java.lang.reflect.*;
|
||||||
|
import java.time.*;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Accumulates OpenAPI 3.0 operations and builds the spec document as a plain
|
* OpenAPI document assembler.
|
||||||
* {@code Map} for Jackson to serialize. Operations are added at registration time
|
*
|
||||||
* via {@link OpenApiExtension}'s {@link dev.relism.AnnotationProcessor}.
|
* Collects operation metadata at route registration time and renders an OpenAPI 3.0.3 map.
|
||||||
|
* Response schemas are resolved automatically into components.schemas.
|
||||||
*/
|
*/
|
||||||
public class OpenApiBuilder {
|
public final class OpenApiBuilder {
|
||||||
|
|
||||||
private String title = "API";
|
private String title = "API";
|
||||||
private String version = "1.0.0";
|
private String version = "1.0.0";
|
||||||
@@ -17,31 +24,23 @@ public class OpenApiBuilder {
|
|||||||
|
|
||||||
private final Map<String, Map<String, Object>> paths = new LinkedHashMap<>();
|
private final Map<String, Map<String, Object>> paths = new LinkedHashMap<>();
|
||||||
private final Map<String, Map<String, Class<?>>> operationHandlers = new LinkedHashMap<>();
|
private final Map<String, Map<String, Class<?>>> operationHandlers = new LinkedHashMap<>();
|
||||||
|
private final SchemaRegistry schemas = new SchemaRegistry();
|
||||||
private OpenApiSecurityRegistry securityRegistry;
|
private OpenApiSecurityRegistry securityRegistry;
|
||||||
|
// Build cache: OpenAPI is rendered only when the document revision changes.
|
||||||
// ── Configuration ─────────────────────────────────────────────────────────
|
private int revision;
|
||||||
|
private int builtRevision = -1;
|
||||||
|
private Map<String, Object> cachedSpec;
|
||||||
|
|
||||||
public OpenApiBuilder title(String title) { this.title = title; return this; }
|
public OpenApiBuilder title(String title) { this.title = title; return this; }
|
||||||
public OpenApiBuilder version(String version) { this.version = version; return this; }
|
public OpenApiBuilder version(String version) { this.version = version; return this; }
|
||||||
public OpenApiBuilder description(String description) { this.description = description; return this; }
|
public OpenApiBuilder description(String description) { this.description = description; return this; }
|
||||||
|
void setSecurityRegistry(OpenApiSecurityRegistry registry) { this.securityRegistry = registry; }
|
||||||
|
|
||||||
void setSecurityRegistry(OpenApiSecurityRegistry registry) {
|
|
||||||
this.securityRegistry = registry;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Operation registration ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Adds an operation derived from the handler's {@link Route}, {@link ApiOperation},
|
|
||||||
* {@link ApiResponse}, and {@link ApiParam} annotations.
|
|
||||||
*/
|
|
||||||
public void addOperation(Route route, ApiOperation op, Class<?> handlerClass) {
|
public void addOperation(Route route, ApiOperation op, Class<?> handlerClass) {
|
||||||
String path = normalizePath(route.path());
|
String path = normalizePath(route.path());
|
||||||
String method = route.method().name().toLowerCase();
|
String method = route.method().name().toLowerCase(Locale.ROOT);
|
||||||
|
|
||||||
Map<String, Object> pathItem = paths.computeIfAbsent(path, k -> new LinkedHashMap<>());
|
|
||||||
Map<String, Object> operation = new LinkedHashMap<>();
|
Map<String, Object> operation = new LinkedHashMap<>();
|
||||||
|
|
||||||
if (!op.operationId().isEmpty()) operation.put("operationId", op.operationId());
|
if (!op.operationId().isEmpty()) operation.put("operationId", op.operationId());
|
||||||
if (!op.summary().isEmpty()) operation.put("summary", op.summary());
|
if (!op.summary().isEmpty()) operation.put("summary", op.summary());
|
||||||
if (!op.description().isEmpty()) operation.put("description", op.description());
|
if (!op.description().isEmpty()) operation.put("description", op.description());
|
||||||
@@ -51,18 +50,16 @@ public class OpenApiBuilder {
|
|||||||
buildParameters(operation, handlerClass, route);
|
buildParameters(operation, handlerClass, route);
|
||||||
buildResponses(operation, handlerClass);
|
buildResponses(operation, handlerClass);
|
||||||
|
|
||||||
pathItem.put(method, operation);
|
paths.computeIfAbsent(path, k -> new LinkedHashMap<>()).put(method, operation);
|
||||||
operationHandlers.computeIfAbsent(path, k -> new LinkedHashMap<>()).put(method, handlerClass);
|
operationHandlers.computeIfAbsent(path, k -> new LinkedHashMap<>()).put(method, handlerClass);
|
||||||
|
revision++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Spec build ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the complete OpenAPI 3.0.3 spec as a plain map ready for JSON
|
|
||||||
* serialization. Called on each request to {@code /openapi.json} so that
|
|
||||||
* handlers registered after the extension is installed are included.
|
|
||||||
*/
|
|
||||||
public Map<String, Object> build() {
|
public Map<String, Object> build() {
|
||||||
|
int r = revision;
|
||||||
|
Map<String, Object> cached = cachedSpec;
|
||||||
|
if (cached != null && builtRevision == r) return cached;
|
||||||
|
|
||||||
Map<String, Object> info = new LinkedHashMap<>();
|
Map<String, Object> info = new LinkedHashMap<>();
|
||||||
info.put("title", title);
|
info.put("title", title);
|
||||||
info.put("version", version);
|
info.put("version", version);
|
||||||
@@ -71,8 +68,6 @@ public class OpenApiBuilder {
|
|||||||
List<OpenApiSecurityContributor> contributors = securityRegistry != null
|
List<OpenApiSecurityContributor> contributors = securityRegistry != null
|
||||||
? securityRegistry.contributors() : List.of();
|
? securityRegistry.contributors() : List.of();
|
||||||
|
|
||||||
// Build paths with security injected per-operation (fresh copy each time so
|
|
||||||
// repeated calls don't accumulate duplicate security entries)
|
|
||||||
Map<String, Object> renderedPaths = new LinkedHashMap<>();
|
Map<String, Object> renderedPaths = new LinkedHashMap<>();
|
||||||
for (var pathEntry : paths.entrySet()) {
|
for (var pathEntry : paths.entrySet()) {
|
||||||
Map<String, Object> renderedPathItem = new LinkedHashMap<>();
|
Map<String, Object> renderedPathItem = new LinkedHashMap<>();
|
||||||
@@ -80,7 +75,7 @@ public class OpenApiBuilder {
|
|||||||
for (var methodEntry : pathEntry.getValue().entrySet()) {
|
for (var methodEntry : pathEntry.getValue().entrySet()) {
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
Map<String, Object> original = (Map<String, Object>) methodEntry.getValue();
|
Map<String, Object> original = (Map<String, Object>) methodEntry.getValue();
|
||||||
Map<String, Object> op = new LinkedHashMap<>(original); // shallow copy
|
Map<String, Object> op = new LinkedHashMap<>(original);
|
||||||
Class<?> handler = handlers.get(methodEntry.getKey());
|
Class<?> handler = handlers.get(methodEntry.getKey());
|
||||||
if (handler != null && !contributors.isEmpty()) {
|
if (handler != null && !contributors.isEmpty()) {
|
||||||
List<Map<String, List<String>>> security = buildOperationSecurity(contributors, handler);
|
List<Map<String, List<String>>> security = buildOperationSecurity(contributors, handler);
|
||||||
@@ -96,23 +91,24 @@ public class OpenApiBuilder {
|
|||||||
spec.put("info", info);
|
spec.put("info", info);
|
||||||
spec.put("paths", renderedPaths);
|
spec.put("paths", renderedPaths);
|
||||||
|
|
||||||
|
Map<String, Object> components = new LinkedHashMap<>();
|
||||||
|
Map<String, Object> renderedSchemas = schemas.render();
|
||||||
|
if (!renderedSchemas.isEmpty()) components.put("schemas", renderedSchemas);
|
||||||
if (!contributors.isEmpty()) {
|
if (!contributors.isEmpty()) {
|
||||||
Map<String, Object> schemes = new LinkedHashMap<>();
|
Map<String, Object> securitySchemes = new LinkedHashMap<>();
|
||||||
for (OpenApiSecurityContributor c : contributors) {
|
for (OpenApiSecurityContributor c : contributors) securitySchemes.put(c.schemeName(), c.schemeDefinition());
|
||||||
schemes.put(c.schemeName(), c.schemeDefinition());
|
components.put("securitySchemes", securitySchemes);
|
||||||
}
|
|
||||||
spec.put("components", Map.of("securitySchemes", schemes));
|
|
||||||
}
|
}
|
||||||
|
if (!components.isEmpty()) spec.put("components", components);
|
||||||
|
|
||||||
|
cachedSpec = spec;
|
||||||
|
builtRevision = r;
|
||||||
return spec;
|
return spec;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Internals ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private void buildParameters(Map<String, Object> op, Class<?> cls, Route route) {
|
private void buildParameters(Map<String, Object> op, Class<?> cls, Route route) {
|
||||||
List<Map<String, Object>> params = new ArrayList<>();
|
List<Map<String, Object>> params = new ArrayList<>();
|
||||||
|
|
||||||
// Path params from @Route path — add them automatically as required
|
|
||||||
String path = route.path();
|
String path = route.path();
|
||||||
int i = 0;
|
int i = 0;
|
||||||
while (i < path.length()) {
|
while (i < path.length()) {
|
||||||
@@ -121,18 +117,16 @@ public class OpenApiBuilder {
|
|||||||
int close = path.indexOf('}', open);
|
int close = path.indexOf('}', open);
|
||||||
if (close < 0) break;
|
if (close < 0) break;
|
||||||
String name = path.substring(open + 1, close);
|
String name = path.substring(open + 1, close);
|
||||||
Map<String, Object> p = new LinkedHashMap<>();
|
params.add(new LinkedHashMap<>(Map.of(
|
||||||
p.put("name", name);
|
"name", name,
|
||||||
p.put("in", "path");
|
"in", "path",
|
||||||
p.put("required", true);
|
"required", true,
|
||||||
p.put("schema", Map.of("type", "string"));
|
"schema", Map.of("type", "string")
|
||||||
params.add(p);
|
)));
|
||||||
i = close + 1;
|
i = close + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Explicit @ApiParam annotations
|
for (ApiParam ann : cls.getAnnotationsByType(ApiParam.class)) {
|
||||||
ApiParam[] apiParams = cls.getAnnotationsByType(ApiParam.class);
|
|
||||||
for (ApiParam ann : apiParams) {
|
|
||||||
Map<String, Object> p = new LinkedHashMap<>();
|
Map<String, Object> p = new LinkedHashMap<>();
|
||||||
p.put("name", ann.name());
|
p.put("name", ann.name());
|
||||||
p.put("in", ann.in());
|
p.put("in", ann.in());
|
||||||
@@ -149,27 +143,46 @@ public class OpenApiBuilder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void buildResponses(Map<String, Object> op, Class<?> cls) {
|
private void buildResponses(Map<String, Object> op, Class<?> cls) {
|
||||||
ApiResponse[] annotations = cls.getAnnotationsByType(ApiResponse.class);
|
ApiResponse[] anns = cls.getAnnotationsByType(ApiResponse.class);
|
||||||
Map<String, Object> responses = new LinkedHashMap<>();
|
Map<String, Object> responses = new LinkedHashMap<>();
|
||||||
|
|
||||||
if (annotations.length == 0) {
|
if (anns.length == 0) {
|
||||||
responses.put("200", Map.of("description", "OK"));
|
responses.put("200", Map.of("description", "OK"));
|
||||||
} else {
|
op.put("responses", responses);
|
||||||
for (ApiResponse ann : annotations) {
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (ApiResponse ann : anns) {
|
||||||
Map<String, Object> r = new LinkedHashMap<>();
|
Map<String, Object> r = new LinkedHashMap<>();
|
||||||
r.put("description", ann.description().isEmpty() ? httpPhrase(ann.status()) : ann.description());
|
r.put("description", ann.description().isEmpty() ? httpPhrase(ann.status()) : ann.description());
|
||||||
if (ann.schema() != Void.class) {
|
|
||||||
|
Class<?> schemaType = resolveSchemaType(ann, cls);
|
||||||
|
if (schemaType != null && schemaType != Void.class) {
|
||||||
r.put("content", Map.of(
|
r.put("content", Map.of(
|
||||||
"application/json", Map.of(
|
"application/json", Map.of("schema", schemas.referenceFor(schemaType))
|
||||||
"schema", Map.of("$ref", "#/components/schemas/" + ann.schema().getSimpleName()))));
|
));
|
||||||
}
|
}
|
||||||
responses.put(String.valueOf(ann.status()), r);
|
responses.put(String.valueOf(ann.status()), r);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
op.put("responses", responses);
|
op.put("responses", responses);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Converts Flash path params ({id}) to OpenAPI path params ({id}) — already compatible. */
|
private static Class<?> resolveSchemaType(ApiResponse ann, Class<?> handlerClass) {
|
||||||
|
if (ann.schema() != Void.class) return ann.schema();
|
||||||
|
if (!ann.useReturnType()) return null;
|
||||||
|
try {
|
||||||
|
Method handle = handlerClass.getMethod("handle", dev.relism.models.Request.class, dev.relism.models.Response.class);
|
||||||
|
Type ret = handle.getGenericReturnType();
|
||||||
|
Class<?> raw = rawType(ret);
|
||||||
|
if (raw == null || raw == Object.class || raw == dev.relism.models.Response.class || raw == Void.class || raw == void.class)
|
||||||
|
return null;
|
||||||
|
return raw;
|
||||||
|
} catch (NoSuchMethodException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static String normalizePath(String path) {
|
private static String normalizePath(String path) {
|
||||||
return path.startsWith("/") ? path : "/" + path;
|
return path.startsWith("/") ? path : "/" + path;
|
||||||
}
|
}
|
||||||
@@ -190,15 +203,200 @@ public class OpenApiBuilder {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<Map<String, List<String>>> buildOperationSecurity(
|
private static List<Map<String, List<String>>> buildOperationSecurity(List<OpenApiSecurityContributor> contributors,
|
||||||
List<OpenApiSecurityContributor> contributors, Class<?> handlerClass) {
|
Class<?> handlerClass) {
|
||||||
List<Map<String, List<String>>> security = new ArrayList<>();
|
List<Map<String, List<String>>> security = new ArrayList<>();
|
||||||
for (OpenApiSecurityContributor c : contributors) {
|
for (OpenApiSecurityContributor c : contributors) {
|
||||||
List<String> scopes = c.requiredFor(handlerClass);
|
List<String> scopes = c.requiredFor(handlerClass);
|
||||||
if (scopes != null) {
|
if (scopes != null) security.add(Map.of(c.schemeName(), scopes));
|
||||||
security.add(Map.of(c.schemeName(), scopes));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return security;
|
return security;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static Class<?> rawType(Type type) {
|
||||||
|
if (type instanceof Class<?> c) return c;
|
||||||
|
if (type instanceof ParameterizedType p && p.getRawType() instanceof Class<?> c) return c;
|
||||||
|
if (type instanceof GenericArrayType a) {
|
||||||
|
Class<?> component = rawType(a.getGenericComponentType());
|
||||||
|
return component == null ? null : Array.newInstance(component, 0).getClass();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class SchemaRegistry {
|
||||||
|
private static final Set<Class<?>> SIMPLE = Set.of(
|
||||||
|
String.class, CharSequence.class,
|
||||||
|
Boolean.class, Byte.class, Short.class, Integer.class, Long.class, Float.class, Double.class,
|
||||||
|
boolean.class, byte.class, short.class, int.class, long.class, float.class, double.class,
|
||||||
|
UUID.class, LocalDate.class, LocalDateTime.class, OffsetDateTime.class, Instant.class
|
||||||
|
);
|
||||||
|
|
||||||
|
private final Map<Class<?>, String> names = new LinkedHashMap<>();
|
||||||
|
private final Map<String, Map<String, Object>> docs = new LinkedHashMap<>();
|
||||||
|
private final Set<Class<?>> resolving = new HashSet<>();
|
||||||
|
|
||||||
|
Map<String, Object> referenceFor(Class<?> type) {
|
||||||
|
return schemaFor(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object> render() {
|
||||||
|
Map<String, Object> out = new LinkedHashMap<>();
|
||||||
|
for (var e : docs.entrySet()) out.put(e.getKey(), e.getValue());
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> schemaFor(Type type) {
|
||||||
|
if (type instanceof ParameterizedType p) {
|
||||||
|
Class<?> raw = rawType(p);
|
||||||
|
if (raw != null && Collection.class.isAssignableFrom(raw)) {
|
||||||
|
Type item = p.getActualTypeArguments()[0];
|
||||||
|
return Map.of("type", "array", "items", schemaFor(item));
|
||||||
|
}
|
||||||
|
if (raw != null && Map.class.isAssignableFrom(raw)) {
|
||||||
|
Type value = p.getActualTypeArguments().length > 1 ? p.getActualTypeArguments()[1] : Object.class;
|
||||||
|
return Map.of("type", "object", "additionalProperties", schemaFor(value));
|
||||||
|
}
|
||||||
|
if (raw != null) return schemaFor(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
Class<?> cls = rawType(type);
|
||||||
|
if (cls == null || cls == Object.class) return Map.of("type", "object");
|
||||||
|
|
||||||
|
if (cls.isArray()) return Map.of("type", "array", "items", schemaFor(cls.getComponentType()));
|
||||||
|
if (Collection.class.isAssignableFrom(cls)) return Map.of("type", "array", "items", Map.of("type", "object"));
|
||||||
|
if (Map.class.isAssignableFrom(cls)) return Map.of("type", "object", "additionalProperties", Map.of("type", "object"));
|
||||||
|
|
||||||
|
Map<String, Object> simple = simpleSchema(cls);
|
||||||
|
if (simple != null) return simple;
|
||||||
|
|
||||||
|
return Map.of("$ref", "#/components/schemas/" + registerPojo(cls));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String registerPojo(Class<?> cls) {
|
||||||
|
String existing = names.get(cls);
|
||||||
|
if (existing != null) return existing;
|
||||||
|
|
||||||
|
String base = schemaName(cls);
|
||||||
|
String name = base;
|
||||||
|
int i = 2;
|
||||||
|
while (docs.containsKey(name)) name = base + i++;
|
||||||
|
names.put(cls, name);
|
||||||
|
|
||||||
|
if (resolving.contains(cls)) return name;
|
||||||
|
resolving.add(cls);
|
||||||
|
docs.put(name, buildPojoSchema(cls));
|
||||||
|
resolving.remove(cls);
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> buildPojoSchema(Class<?> cls) {
|
||||||
|
Schema typeSchema = cls.getAnnotation(Schema.class);
|
||||||
|
JsonIgnoreProperties ignoredType = cls.getAnnotation(JsonIgnoreProperties.class);
|
||||||
|
Set<String> ignored = ignoredType == null
|
||||||
|
? Set.of()
|
||||||
|
: new HashSet<>(Arrays.asList(ignoredType.value()));
|
||||||
|
|
||||||
|
Map<String, Object> out = new LinkedHashMap<>();
|
||||||
|
out.put("type", "object");
|
||||||
|
if (typeSchema != null && !typeSchema.description().isEmpty()) out.put("description", typeSchema.description());
|
||||||
|
|
||||||
|
Map<String, Object> properties = new LinkedHashMap<>();
|
||||||
|
List<String> required = new ArrayList<>();
|
||||||
|
|
||||||
|
for (Field f : cls.getDeclaredFields()) {
|
||||||
|
int mod = f.getModifiers();
|
||||||
|
if (Modifier.isStatic(mod) || Modifier.isTransient(mod)) continue;
|
||||||
|
if (f.isAnnotationPresent(JsonIgnore.class)) continue;
|
||||||
|
if (ignored.contains(f.getName())) continue;
|
||||||
|
|
||||||
|
String name = f.getName();
|
||||||
|
JsonProperty jp = f.getAnnotation(JsonProperty.class);
|
||||||
|
if (jp != null && !jp.value().isEmpty()) name = jp.value();
|
||||||
|
|
||||||
|
Schema ps = f.getAnnotation(Schema.class);
|
||||||
|
SchemaProperty sp = f.getAnnotation(SchemaProperty.class);
|
||||||
|
ArraySchema array = f.getAnnotation(ArraySchema.class);
|
||||||
|
if ((ps != null && ps.hidden()) || (sp != null && sp.hidden())) continue;
|
||||||
|
|
||||||
|
if (sp != null && !sp.name().isEmpty()) name = sp.name();
|
||||||
|
|
||||||
|
Map<String, Object> property = new LinkedHashMap<>(schemaFor(f.getGenericType()));
|
||||||
|
if (ps != null) applySchemaHints(property, ps);
|
||||||
|
if (sp != null) applySchemaHints(property, sp);
|
||||||
|
if (array != null) applyArrayHints(property, array);
|
||||||
|
if (jp != null) {
|
||||||
|
if (jp.access() == Access.READ_ONLY) property.put("readOnly", true);
|
||||||
|
if (jp.access() == Access.WRITE_ONLY) property.put("writeOnly", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
properties.put(name, property);
|
||||||
|
if ((ps != null && ps.required()) || (sp != null && sp.required()) || (jp != null && jp.required())) required.add(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!properties.isEmpty()) out.put("properties", properties);
|
||||||
|
if (!required.isEmpty()) out.put("required", required);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void applySchemaHints(Map<String, Object> property, Schema schema) {
|
||||||
|
if (!schema.description().isEmpty()) property.put("description", schema.description());
|
||||||
|
if (!schema.format().isEmpty()) property.put("format", schema.format());
|
||||||
|
if (!schema.example().isEmpty()) property.put("example", schema.example());
|
||||||
|
if (schema.nullable()) property.put("nullable", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void applySchemaHints(Map<String, Object> property, SchemaProperty schema) {
|
||||||
|
if (!schema.description().isEmpty()) property.put("description", schema.description());
|
||||||
|
if (!schema.format().isEmpty()) property.put("format", schema.format());
|
||||||
|
if (!schema.example().isEmpty()) property.put("example", schema.example());
|
||||||
|
if (schema.nullable()) property.put("nullable", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> withArrayType(Map<String, Object> property, ArraySchema array) {
|
||||||
|
if ("array".equals(property.get("type"))) return property;
|
||||||
|
Type itemType = array.itemClass() != Void.class ? array.itemClass() : Object.class;
|
||||||
|
Map<String, Object> wrapped = new LinkedHashMap<>();
|
||||||
|
wrapped.put("type", "array");
|
||||||
|
wrapped.put("items", schemaFor(itemType));
|
||||||
|
return wrapped;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void applyArrayHints(Map<String, Object> property, ArraySchema array) {
|
||||||
|
Map<String, Object> target = withArrayType(property, array);
|
||||||
|
if (target != property) {
|
||||||
|
property.clear();
|
||||||
|
property.putAll(target);
|
||||||
|
}
|
||||||
|
if (array.uniqueItems()) property.put("uniqueItems", true);
|
||||||
|
if (array.minItems() >= 0) property.put("minItems", array.minItems());
|
||||||
|
if (array.maxItems() >= 0) property.put("maxItems", array.maxItems());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String schemaName(Class<?> cls) {
|
||||||
|
Schema schema = cls.getAnnotation(Schema.class);
|
||||||
|
if (schema != null && !schema.name().isEmpty()) return schema.name();
|
||||||
|
return cls.getSimpleName();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> simpleSchema(Class<?> cls) {
|
||||||
|
if (cls == String.class || cls == CharSequence.class || cls == UUID.class) return Map.of("type", "string");
|
||||||
|
if (cls == boolean.class || cls == Boolean.class) return Map.of("type", "boolean");
|
||||||
|
if (cls == byte.class || cls == Byte.class || cls == short.class || cls == Short.class ||
|
||||||
|
cls == int.class || cls == Integer.class) return Map.of("type", "integer", "format", "int32");
|
||||||
|
if (cls == long.class || cls == Long.class) return Map.of("type", "integer", "format", "int64");
|
||||||
|
if (cls == float.class || cls == Float.class) return Map.of("type", "number", "format", "float");
|
||||||
|
if (cls == double.class || cls == Double.class) return Map.of("type", "number", "format", "double");
|
||||||
|
if (cls == LocalDate.class) return Map.of("type", "string", "format", "date");
|
||||||
|
if (cls == LocalDateTime.class || cls == OffsetDateTime.class || cls == Instant.class)
|
||||||
|
return Map.of("type", "string", "format", "date-time");
|
||||||
|
if (cls.isEnum()) {
|
||||||
|
Object[] constants = cls.getEnumConstants();
|
||||||
|
List<String> values = new ArrayList<>(constants.length);
|
||||||
|
for (Object c : constants) values.add(((Enum<?>) c).name());
|
||||||
|
return Map.of("type", "string", "enum", values);
|
||||||
|
}
|
||||||
|
if (!SIMPLE.contains(cls) && cls.getName().startsWith("java.")) return Map.of("type", "string");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+56
-40
@@ -1,13 +1,17 @@
|
|||||||
package dev.relism.ext.openapi;
|
package dev.relism.ext.openapi;
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.databind.json.JsonMapper;
|
||||||
import com.fasterxml.jackson.dataformat.yaml.YAMLMapper;
|
import com.fasterxml.jackson.dataformat.yaml.YAMLMapper;
|
||||||
import dev.relism.extension.FlashContext;
|
import dev.relism.extension.FlashContext;
|
||||||
import dev.relism.extension.FlashExtension;
|
import dev.relism.extension.FlashExtension;
|
||||||
import dev.relism.extension.FlashRegistrar;
|
import dev.relism.extension.FlashRegistrar;
|
||||||
import dev.relism.http.ContentType;
|
import dev.relism.http.ContentType;
|
||||||
|
import dev.relism.http.HttpMethod;
|
||||||
import dev.relism.routing.Route;
|
import dev.relism.routing.Route;
|
||||||
|
|
||||||
|
import java.lang.annotation.Annotation;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates and serves an OpenAPI 3.0 spec and Swagger UI under a configurable base path.
|
* Generates and serves an OpenAPI 3.0 spec and Swagger UI under a configurable base path.
|
||||||
*
|
*
|
||||||
@@ -18,17 +22,16 @@ import dev.relism.routing.Route;
|
|||||||
* <li>{@code GET /openapi/swagger} — Swagger UI pointing at {@code /openapi.json}</li>
|
* <li>{@code GET /openapi/swagger} — Swagger UI pointing at {@code /openapi.json}</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
*
|
*
|
||||||
* <p><b>Requires</b> {@code flash-ext-jackson} to be installed first (shares its
|
* <p>If {@code flash-ext-jackson} is installed, this extension reuses its
|
||||||
* {@link ObjectMapper}). The YAML endpoint uses its own {@link YAMLMapper} instance.
|
* {@link ObjectMapper}. Otherwise it uses a local default mapper.
|
||||||
*
|
*
|
||||||
* <p>Operations are collected automatically from handlers annotated with
|
* <p>Operations are collected at boot from handlers annotated with {@link ApiOperation}
|
||||||
* {@link ApiOperation} as they are registered via {@link FlashApp#register}.
|
* that also have route metadata ({@link Route} or shorthand verb annotations).
|
||||||
*
|
*
|
||||||
* <pre>{@code
|
* <pre>{@code
|
||||||
* FlashApp.of(new HttpServer(config))
|
* FlashApp.create(8080)
|
||||||
* .install(new JacksonExtension())
|
* .install(new JacksonExtension())
|
||||||
* .install(new OpenApiExtension("/openapi", "My API", "2.0.0"))
|
* .install(new OpenApiExtension("/openapi", "My API", "2.0.0"))
|
||||||
* .register(new BlogHandlers.Index())
|
|
||||||
* .start();
|
* .start();
|
||||||
* }</pre>
|
* }</pre>
|
||||||
*/
|
*/
|
||||||
@@ -60,54 +63,40 @@ public class OpenApiExtension implements FlashExtension {
|
|||||||
this.description = description;
|
this.description = description;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── FlashExtension ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void install(FlashRegistrar app, FlashContext ctx) {
|
public void provide(FlashContext ctx) {
|
||||||
ObjectMapper jsonMapper = ctx.require(ObjectMapper.class);
|
OpenApiBuilder builder = new OpenApiBuilder().title(title).version(version).description(description);
|
||||||
YAMLMapper yamlMapper = new YAMLMapper();
|
|
||||||
|
|
||||||
OpenApiBuilder builder = new OpenApiBuilder()
|
|
||||||
.title(title)
|
|
||||||
.version(version)
|
|
||||||
.description(description);
|
|
||||||
|
|
||||||
OpenApiSecurityRegistry secRegistry = new OpenApiSecurityRegistry();
|
OpenApiSecurityRegistry secRegistry = new OpenApiSecurityRegistry();
|
||||||
|
|
||||||
ctx.provide(OpenApiSecurityRegistry.class, secRegistry);
|
ctx.provide(OpenApiSecurityRegistry.class, secRegistry);
|
||||||
|
ctx.provide(OpenApiBuilder.class, builder);
|
||||||
builder.setSecurityRegistry(secRegistry);
|
builder.setSecurityRegistry(secRegistry);
|
||||||
|
|
||||||
ctx.provide(OpenApiBuilder.class, builder);
|
// Collect operation metadata at handler-registration time (no middleware injected).
|
||||||
|
|
||||||
// Collect operation metadata at handler-registration time (no middleware injected)
|
|
||||||
ctx.addAnnotationProcessor(handlerClass -> {
|
ctx.addAnnotationProcessor(handlerClass -> {
|
||||||
ApiOperation op = handlerClass.getAnnotation(ApiOperation.class);
|
ApiOperation op = handlerClass.getAnnotation(ApiOperation.class);
|
||||||
Route route = handlerClass.getAnnotation(Route.class);
|
Route route = routeOf(handlerClass);
|
||||||
if (op != null && route != null) {
|
if (op != null && route != null) builder.addOperation(route, op, handlerClass);
|
||||||
builder.addOperation(route, op, handlerClass);
|
|
||||||
}
|
|
||||||
return java.util.List.of();
|
return java.util.List.of();
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void routes(FlashRegistrar<?> app, FlashContext ctx) {
|
||||||
|
ObjectMapper jsonMapper = ctx.find(ObjectMapper.class).orElseGet(() -> JsonMapper.builder().build());
|
||||||
|
YAMLMapper yamlMapper = new YAMLMapper();
|
||||||
|
OpenApiBuilder builder = ctx.require(OpenApiBuilder.class);
|
||||||
|
|
||||||
String jsonPath = basePath + ".json";
|
String jsonPath = basePath + ".json";
|
||||||
String yamlPath = basePath + ".yaml";
|
String yamlPath = basePath + ".yaml";
|
||||||
String swaggerPath = basePath + "/swagger";
|
String swaggerPath = basePath + "/swagger";
|
||||||
|
|
||||||
// JSON spec
|
|
||||||
app.get(jsonPath, (req, res) -> {
|
|
||||||
res.setContentType(ContentType.JSON);
|
|
||||||
return jsonMapper.writeValueAsString(builder.build());
|
|
||||||
}).with();
|
|
||||||
|
|
||||||
// YAML spec
|
|
||||||
app.get(yamlPath, (req, res) -> {
|
|
||||||
res.type(YAML_CONTENT_TYPE);
|
|
||||||
return yamlMapper.writeValueAsString(builder.build());
|
|
||||||
}).with();
|
|
||||||
|
|
||||||
// Swagger UI — loads from CDN, points at the JSON spec
|
|
||||||
String swaggerHtml = buildSwaggerHtml(jsonPath);
|
String swaggerHtml = buildSwaggerHtml(jsonPath);
|
||||||
app.get(swaggerPath, (req, res) -> {
|
|
||||||
res.setContentType(ContentType.TEXT_HTML);
|
app.get(jsonPath, (req, res) -> { res.type(ContentType.JSON); return jsonMapper.writeValueAsString(builder.build()); });
|
||||||
return swaggerHtml;
|
app.get(yamlPath, (req, res) -> { res.type(YAML_CONTENT_TYPE); return yamlMapper.writeValueAsString(builder.build()); });
|
||||||
}).with();
|
app.get(swaggerPath, (req, res) -> { res.type(ContentType.TEXT_HTML); return swaggerHtml; });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Swagger UI HTML ───────────────────────────────────────────────────────
|
// ── Swagger UI HTML ───────────────────────────────────────────────────────
|
||||||
@@ -136,4 +125,31 @@ public class OpenApiExtension implements FlashExtension {
|
|||||||
"</body>\n" +
|
"</body>\n" +
|
||||||
"</html>";
|
"</html>";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static Route routeOf(Class<?> cls) {
|
||||||
|
Route direct = cls.getAnnotation(Route.class);
|
||||||
|
if (direct != null) return direct;
|
||||||
|
for (Annotation ann : cls.getAnnotations()) {
|
||||||
|
Route meta = ann.annotationType().getAnnotation(Route.class);
|
||||||
|
if (meta == null) continue;
|
||||||
|
String path = readPathValue(ann);
|
||||||
|
if (path == null) continue;
|
||||||
|
HttpMethod method = meta.method();
|
||||||
|
return new Route() {
|
||||||
|
@Override public HttpMethod method() { return method; }
|
||||||
|
@Override public String path() { return path; }
|
||||||
|
@Override public Class<? extends Annotation> annotationType() { return Route.class; }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String readPathValue(Annotation ann) {
|
||||||
|
try {
|
||||||
|
Object v = ann.annotationType().getMethod("value").invoke(ann);
|
||||||
|
return v instanceof String s ? s : null;
|
||||||
|
} catch (ReflectiveOperationException ignored) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package dev.relism.ext.openapi;
|
||||||
|
|
||||||
|
import java.lang.annotation.ElementType;
|
||||||
|
import java.lang.annotation.Retention;
|
||||||
|
import java.lang.annotation.RetentionPolicy;
|
||||||
|
import java.lang.annotation.Target;
|
||||||
|
|
||||||
|
/** Minimal OpenAPI schema metadata for model classes and properties. */
|
||||||
|
@Retention(RetentionPolicy.RUNTIME)
|
||||||
|
@Target({ElementType.TYPE, ElementType.FIELD, ElementType.METHOD})
|
||||||
|
public @interface Schema {
|
||||||
|
String name() default "";
|
||||||
|
String description() default "";
|
||||||
|
String format() default "";
|
||||||
|
String example() default "";
|
||||||
|
boolean nullable() default false;
|
||||||
|
boolean required() default false;
|
||||||
|
boolean hidden() default false;
|
||||||
|
}
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
package dev.relism.ext.openapi;
|
||||||
|
|
||||||
|
import java.lang.annotation.ElementType;
|
||||||
|
import java.lang.annotation.Retention;
|
||||||
|
import java.lang.annotation.RetentionPolicy;
|
||||||
|
import java.lang.annotation.Target;
|
||||||
|
|
||||||
|
/** Optional alias for property-level schema metadata. */
|
||||||
|
@Retention(RetentionPolicy.RUNTIME)
|
||||||
|
@Target({ElementType.FIELD, ElementType.METHOD})
|
||||||
|
public @interface SchemaProperty {
|
||||||
|
String name() default "";
|
||||||
|
String description() default "";
|
||||||
|
String format() default "";
|
||||||
|
String example() default "";
|
||||||
|
boolean nullable() default false;
|
||||||
|
boolean required() default false;
|
||||||
|
boolean hidden() default false;
|
||||||
|
}
|
||||||
+1
-1
@@ -23,7 +23,7 @@ class RouteViewerDataHandler {
|
|||||||
|
|
||||||
Object handle(Request req, Response res) {
|
Object handle(Request req, Response res) {
|
||||||
if (cachedJson == null) cachedJson = GraphSerializer.toJson(graph);
|
if (cachedJson == null) cachedJson = GraphSerializer.toJson(graph);
|
||||||
res.setContentType(ContentType.JSON);
|
res.type(ContentType.JSON);
|
||||||
res.header("Cache-Control", "no-cache");
|
res.header("Cache-Control", "no-cache");
|
||||||
return cachedJson;
|
return cachedJson;
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-23
@@ -1,6 +1,7 @@
|
|||||||
package dev.relism.ext.routeviewer;
|
package dev.relism.ext.routeviewer;
|
||||||
|
|
||||||
import dev.relism.ext.routeviewer.model.RouteGraph;
|
import dev.relism.ext.routeviewer.model.RouteGraph;
|
||||||
|
import dev.relism.extension.ExtensionPhase;
|
||||||
import dev.relism.extension.FlashContext;
|
import dev.relism.extension.FlashContext;
|
||||||
import dev.relism.extension.FlashExtension;
|
import dev.relism.extension.FlashExtension;
|
||||||
import dev.relism.extension.FlashRegistrar;
|
import dev.relism.extension.FlashRegistrar;
|
||||||
@@ -21,22 +22,17 @@ import dev.relism.http.ContentType;
|
|||||||
* <li>{@code GET <path>/data} — graph JSON consumed by the SPA</li>
|
* <li>{@code GET <path>/data} — graph JSON consumed by the SPA</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
*
|
*
|
||||||
* <h3>Install order</h3>
|
* <p>The route listener is registered during the {@link #provide} phase, before any routes
|
||||||
* Install <em>after</em> extensions that register annotation processors
|
* are compiled. Combined with the two-phase extension model, this guarantees all routes
|
||||||
* (e.g. {@code OidcExtension}) but <em>before</em> {@code scan()} or
|
* (including those from other extensions) are always captured — install order is irrelevant.
|
||||||
* {@code register()} calls so the listener captures all routes:
|
|
||||||
*
|
*
|
||||||
* <pre>{@code
|
* <pre>{@code
|
||||||
* FlashApp.create(8080)
|
* FlashApp.create(8080)
|
||||||
* .install(new OidcExtension(config))
|
* .install(new OidcExtension(config))
|
||||||
* .install(new JacksonExtension())
|
* .install(new RouteViewerExtension())
|
||||||
* .install(new RouteViewerExtension()) // before scan
|
|
||||||
* .scan("dev.example.handlers")
|
* .scan("dev.example.handlers")
|
||||||
* .start();
|
* .start();
|
||||||
* }</pre>
|
* }</pre>
|
||||||
*
|
|
||||||
* <p>All route metadata is collected once at boot time via
|
|
||||||
* {@link FlashContext#addRouteListener}. Zero overhead on the request hot-path.
|
|
||||||
*/
|
*/
|
||||||
public class RouteViewerExtension implements FlashExtension {
|
public class RouteViewerExtension implements FlashExtension {
|
||||||
|
|
||||||
@@ -45,6 +41,9 @@ public class RouteViewerExtension implements FlashExtension {
|
|||||||
private final String path;
|
private final String path;
|
||||||
private final RouteGraph graph = new RouteGraph();
|
private final RouteGraph graph = new RouteGraph();
|
||||||
|
|
||||||
|
/** Observability — runs last so the viewer sees the complete middleware chain. */
|
||||||
|
@Override public int priority() { return ExtensionPhase.LATE.value; }
|
||||||
|
|
||||||
/** Installs the viewer at {@value #DEFAULT_PATH}. */
|
/** Installs the viewer at {@value #DEFAULT_PATH}. */
|
||||||
public RouteViewerExtension() { this(DEFAULT_PATH); }
|
public RouteViewerExtension() { this(DEFAULT_PATH); }
|
||||||
|
|
||||||
@@ -55,20 +54,16 @@ public class RouteViewerExtension implements FlashExtension {
|
|||||||
public RouteViewerExtension(String path) { this.path = path; }
|
public RouteViewerExtension(String path) { this.path = path; }
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void install(FlashRegistrar app, FlashContext ctx) {
|
public void provide(FlashContext ctx) {
|
||||||
RouteViewerHandler shell = new RouteViewerHandler();
|
// Register listener before any routes compile — captures everything.
|
||||||
RouteViewerDataHandler data = new RouteViewerDataHandler(graph);
|
|
||||||
|
|
||||||
// Static assets (Vite build output, bundled in JAR)
|
|
||||||
app.get(path, shell::handle);
|
|
||||||
app.get(path + "/app.js", new RouteViewerStaticHandler("routeviewer/app.js", ContentType.TEXT_JAVASCRIPT)::handle);
|
|
||||||
app.get(path + "/app.css", new RouteViewerStaticHandler("routeviewer/app.css", ContentType.TEXT_CSS)::handle);
|
|
||||||
|
|
||||||
// Graph data API — must be registered before the listener so it is
|
|
||||||
// flushed and captured AFTER the listener is attached (shows in the graph)
|
|
||||||
app.get(path + "/data", data::handle);
|
|
||||||
|
|
||||||
// Start listening — routes registered after this point are captured
|
|
||||||
ctx.addRouteListener(graph::add);
|
ctx.addRouteListener(graph::add);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void routes(FlashRegistrar<?> app, FlashContext ctx) {
|
||||||
|
app.get(path, new RouteViewerHandler()::handle);
|
||||||
|
app.get(path + "/app.js", new RouteViewerStaticHandler("routeviewer/app.js", ContentType.TEXT_JAVASCRIPT)::handle);
|
||||||
|
app.get(path + "/app.css", new RouteViewerStaticHandler("routeviewer/app.css", ContentType.TEXT_CSS)::handle);
|
||||||
|
app.get(path + "/data", new RouteViewerDataHandler(graph)::handle);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -25,7 +25,7 @@ class RouteViewerHandler {
|
|||||||
|
|
||||||
Object handle(Request req, Response res) throws IOException {
|
Object handle(Request req, Response res) throws IOException {
|
||||||
if (cached == null) cached = load();
|
if (cached == null) cached = load();
|
||||||
res.setContentType(ContentType.TEXT_HTML);
|
res.type(ContentType.TEXT_HTML);
|
||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -28,11 +28,11 @@ class RouteViewerStaticHandler {
|
|||||||
Object handle(Request req, Response res) throws IOException {
|
Object handle(Request req, Response res) throws IOException {
|
||||||
if (cached == null) cached = load();
|
if (cached == null) cached = load();
|
||||||
if (cached == null) {
|
if (cached == null) {
|
||||||
res.setStatusCode(404);
|
res.status(404);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
res.setStatusCode(200);
|
res.status(200);
|
||||||
res.setContentType(contentType);
|
res.type(contentType);
|
||||||
res.header("Cache-Control", "public, max-age=3600");
|
res.header("Cache-Control", "public, max-age=3600");
|
||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
|
|||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
package dev.relism.ext.view;
|
||||||
|
|
||||||
|
public record EngineCapabilities(boolean supportsPartialSlot) {
|
||||||
|
public static final EngineCapabilities NONE = new EngineCapabilities(false);
|
||||||
|
}
|
||||||
+38
@@ -0,0 +1,38 @@
|
|||||||
|
package dev.relism.ext.view;
|
||||||
|
|
||||||
|
import dev.relism.http.ContentType;
|
||||||
|
import dev.relism.models.Request;
|
||||||
|
import dev.relism.models.Response;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
final class LegacyViewEngineAdapter implements ViewEngineAdapter {
|
||||||
|
private static final String DEFAULT_FRAGMENT_SLOT = "content";
|
||||||
|
|
||||||
|
private final ViewEngine engine;
|
||||||
|
|
||||||
|
LegacyViewEngineAdapter(ViewEngine engine) {
|
||||||
|
this.engine = engine;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public EngineCapabilities capabilities() {
|
||||||
|
return EngineCapabilities.NONE;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public RenderOutput render(ViewTarget target, Map<String, Object> model, Request req, Response res) throws Exception {
|
||||||
|
String template = target.template();
|
||||||
|
boolean fragment = target.kind() == ViewKind.PARTIAL;
|
||||||
|
|
||||||
|
if (fragment && target.slot() != null && !target.slot().isBlank()) {
|
||||||
|
template = template + " :: " + target.slot();
|
||||||
|
fragment = false;
|
||||||
|
} else if (fragment) {
|
||||||
|
template = template + " :: " + DEFAULT_FRAGMENT_SLOT;
|
||||||
|
fragment = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new RenderOutput(engine.render(template, model, fragment), ContentType.TEXT_HTML);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package dev.relism.ext.view;
|
||||||
|
|
||||||
|
import java.lang.annotation.ElementType;
|
||||||
|
import java.lang.annotation.Retention;
|
||||||
|
import java.lang.annotation.RetentionPolicy;
|
||||||
|
import java.lang.annotation.Target;
|
||||||
|
|
||||||
|
@Retention(RetentionPolicy.RUNTIME)
|
||||||
|
@Target(ElementType.TYPE)
|
||||||
|
public @interface Page {
|
||||||
|
String value();
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package dev.relism.ext.view;
|
||||||
|
|
||||||
|
import java.lang.annotation.ElementType;
|
||||||
|
import java.lang.annotation.Retention;
|
||||||
|
import java.lang.annotation.RetentionPolicy;
|
||||||
|
import java.lang.annotation.Target;
|
||||||
|
|
||||||
|
@Retention(RetentionPolicy.RUNTIME)
|
||||||
|
@Target(ElementType.TYPE)
|
||||||
|
public @interface Partial {
|
||||||
|
String template();
|
||||||
|
String slot() default "";
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package dev.relism.ext.view;
|
||||||
|
|
||||||
|
import dev.relism.http.ContentType;
|
||||||
|
|
||||||
|
public record RenderOutput(String body, ContentType contentType) {
|
||||||
|
public static RenderOutput html(String body) {
|
||||||
|
return new RenderOutput(body, ContentType.TEXT_HTML);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -54,7 +54,7 @@ public final class Renderer {
|
|||||||
* header to {@code type}, and returns the rendered string as the handler body.
|
* header to {@code type}, and returns the rendered string as the handler body.
|
||||||
*/
|
*/
|
||||||
public String view(Response res, String template, Object model, ContentType type) throws Exception {
|
public String view(Response res, String template, Object model, ContentType type) throws Exception {
|
||||||
res.setContentType(type);
|
res.type(type);
|
||||||
return engine.render(template, model);
|
return engine.render(template, model);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+22
-1
@@ -41,7 +41,7 @@ import java.util.Map;
|
|||||||
* <li>{@code null} model → empty context.</li>
|
* <li>{@code null} model → empty context.</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
*/
|
*/
|
||||||
final class ThymeleafEngine implements ViewEngine {
|
final class ThymeleafEngine implements ViewEngine, ViewEngineAdapter {
|
||||||
|
|
||||||
private static final String PREFIX = "/templates/";
|
private static final String PREFIX = "/templates/";
|
||||||
private static final String SUFFIX = ".html";
|
private static final String SUFFIX = ".html";
|
||||||
@@ -71,6 +71,27 @@ final class ThymeleafEngine implements ViewEngine {
|
|||||||
return engine.process(fragment ? template + FRAGMENT : template, ctx);
|
return engine.process(fragment ? template + FRAGMENT : template, ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public EngineCapabilities capabilities() {
|
||||||
|
return new EngineCapabilities(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public RenderOutput render(ViewTarget target, Map<String, Object> model,
|
||||||
|
dev.relism.models.Request req,
|
||||||
|
dev.relism.models.Response res) {
|
||||||
|
String selector;
|
||||||
|
if (target.kind() == ViewKind.PAGE) {
|
||||||
|
selector = target.template();
|
||||||
|
} else {
|
||||||
|
String slot = target.slot();
|
||||||
|
selector = target.template() + " :: " + ((slot == null || slot.isBlank()) ? "content" : slot);
|
||||||
|
}
|
||||||
|
Context ctx = new Context();
|
||||||
|
populateContext(ctx, model);
|
||||||
|
return RenderOutput.html(engine.process(selector, ctx));
|
||||||
|
}
|
||||||
|
|
||||||
private static void populateContext(Context ctx, Object model) {
|
private static void populateContext(Context ctx, Object model) {
|
||||||
if (model instanceof Map<?, ?> map) {
|
if (model instanceof Map<?, ?> map) {
|
||||||
map.forEach((k, v) -> ctx.setVariable(String.valueOf(k), v));
|
map.forEach((k, v) -> ctx.setVariable(String.valueOf(k), v));
|
||||||
|
|||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
package dev.relism.ext.view;
|
||||||
|
|
||||||
|
import dev.relism.models.Request;
|
||||||
|
import dev.relism.models.Response;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
public interface ViewEngineAdapter {
|
||||||
|
EngineCapabilities capabilities();
|
||||||
|
RenderOutput render(ViewTarget target, Map<String, Object> model, Request req, Response res) throws Exception;
|
||||||
|
}
|
||||||
+8
-2
@@ -53,15 +53,21 @@ public enum ViewEngineType {
|
|||||||
* @param cacheEnabled whether the engine should cache compiled templates
|
* @param cacheEnabled whether the engine should cache compiled templates
|
||||||
* @throws IllegalStateException if the required library is not on the classpath
|
* @throws IllegalStateException if the required library is not on the classpath
|
||||||
*/
|
*/
|
||||||
ViewEngine createEngine(boolean cacheEnabled) {
|
ViewEngineAdapter createAdapter(boolean cacheEnabled) {
|
||||||
return switch (this) {
|
return switch (this) {
|
||||||
case THYMELEAF -> createThymeleaf(cacheEnabled);
|
case THYMELEAF -> createThymeleaf(cacheEnabled);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ViewEngine createEngine(boolean cacheEnabled) {
|
||||||
|
ViewEngineAdapter adapter = createAdapter(cacheEnabled);
|
||||||
|
if (adapter instanceof ViewEngine engine) return engine;
|
||||||
|
throw new IllegalStateException("Selected engine does not expose legacy ViewEngine interface: " + this);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Engine factories ──────────────────────────────────────────────────────
|
// ── Engine factories ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
private static ViewEngine createThymeleaf(boolean cacheEnabled) {
|
private static ViewEngineAdapter createThymeleaf(boolean cacheEnabled) {
|
||||||
try {
|
try {
|
||||||
return new ThymeleafEngine(cacheEnabled);
|
return new ThymeleafEngine(cacheEnabled);
|
||||||
} catch (NoClassDefFoundError e) {
|
} catch (NoClassDefFoundError e) {
|
||||||
|
|||||||
+46
-131
@@ -1,166 +1,81 @@
|
|||||||
package dev.relism.ext.view;
|
package dev.relism.ext.view;
|
||||||
|
|
||||||
|
import dev.relism.Flash;
|
||||||
import dev.relism.extension.FlashContext;
|
import dev.relism.extension.FlashContext;
|
||||||
import dev.relism.extension.FlashExtension;
|
import dev.relism.extension.FlashExtension;
|
||||||
import dev.relism.extension.FlashRegistrar;
|
|
||||||
import dev.relism.http.ContentType;
|
import dev.relism.http.ContentType;
|
||||||
import dev.relism.routing.Middleware;
|
import dev.relism.routing.Middleware;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
|
||||||
/**
|
|
||||||
* Installs the view layer into a Flash application.
|
|
||||||
*
|
|
||||||
* <h3>Managed mode (recommended)</h3>
|
|
||||||
* Pass a {@link ViewEngineType} — the extension auto-configures the engine,
|
|
||||||
* detects dev mode for cache settings, and validates classpath dependencies at boot:
|
|
||||||
* <pre>{@code
|
|
||||||
* app.install(new ViewExtension(ViewEngineType.THYMELEAF));
|
|
||||||
* }</pre>
|
|
||||||
*
|
|
||||||
* <h3>Manual mode (BYOE)</h3>
|
|
||||||
* Supply your own {@link ViewEngine} implementation for full control:
|
|
||||||
* <pre>{@code
|
|
||||||
* ViewEngine myEngine = (template, model, fragment) -> { ... };
|
|
||||||
* app.install(new ViewExtension(myEngine));
|
|
||||||
* }</pre>
|
|
||||||
*
|
|
||||||
* <h3>What gets installed</h3>
|
|
||||||
* <ol>
|
|
||||||
* <li>{@link ViewEngine} and {@link Renderer} are bound in the {@link FlashContext} —
|
|
||||||
* any handler can retrieve them via {@code require(Renderer.class)}.</li>
|
|
||||||
* <li>An {@link dev.relism.extension.AnnotationProcessor} is registered: class-based
|
|
||||||
* handlers carrying {@link View @View} receive an injected rendering middleware
|
|
||||||
* that intercepts the handler return value, resolves the template + model, and
|
|
||||||
* delegates to the engine. No boilerplate required in the handler itself.</li>
|
|
||||||
* </ol>
|
|
||||||
*
|
|
||||||
* <h3>Handler patterns</h3>
|
|
||||||
* <pre>{@code
|
|
||||||
* // Declarative — annotation drives template selection
|
|
||||||
* @Route(method = HttpMethod.GET, path = "/")
|
|
||||||
* @View("home")
|
|
||||||
* public class HomeHandler extends RequestHandler {
|
|
||||||
* public Object handle(Request req, Response res) {
|
|
||||||
* return Map.of("posts", service.findAll()); // model → home.html
|
|
||||||
* }
|
|
||||||
* }
|
|
||||||
*
|
|
||||||
* // Dynamic override via Template signal
|
|
||||||
* @View("list")
|
|
||||||
* public class ListHandler extends RequestHandler {
|
|
||||||
* public Object handle(Request req, Response res) {
|
|
||||||
* if (error) return Template.of("error", Map.of("msg", "oops"));
|
|
||||||
* return data; // falls back to list.html
|
|
||||||
* }
|
|
||||||
* }
|
|
||||||
*
|
|
||||||
* // Imperative — explicit render call (lambda-friendly)
|
|
||||||
* Renderer renderer = app.ctx().require(Renderer.class);
|
|
||||||
* app.get("/about", (req, res) -> renderer.view(res, "about"));
|
|
||||||
* }</pre>
|
|
||||||
*
|
|
||||||
* <h3>Dev mode / cache</h3>
|
|
||||||
* In managed mode, template caching is disabled when the JVM property
|
|
||||||
* {@code flash.env=dev} or the environment variable {@code FLASH_ENV=dev} is set.
|
|
||||||
*
|
|
||||||
* <h3>Cross-extension integration</h3>
|
|
||||||
* <pre>{@code
|
|
||||||
* ctx.optional(ViewEngine.class).ifPresent(engine -> { ... });
|
|
||||||
* }</pre>
|
|
||||||
*/
|
|
||||||
public final class ViewExtension implements FlashExtension {
|
public final class ViewExtension implements FlashExtension {
|
||||||
|
|
||||||
private final ViewEngine engine;
|
private final ViewEngineAdapter adapter;
|
||||||
|
private final ViewEngine legacyEngine;
|
||||||
|
private final List<ViewGlobals> globals = new ArrayList<>();
|
||||||
|
|
||||||
// ── Constructors ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Managed mode — auto-configures the engine selected by {@code type}.
|
|
||||||
*
|
|
||||||
* <p>Template caching is enabled unless {@code flash.env=dev} (JVM property)
|
|
||||||
* or {@code FLASH_ENV=dev} (environment variable) is set.
|
|
||||||
*
|
|
||||||
* @param type the engine to use; must have its library on the runtime classpath
|
|
||||||
* @throws IllegalStateException at boot time if the library is missing
|
|
||||||
*/
|
|
||||||
public ViewExtension(ViewEngineType type) {
|
public ViewExtension(ViewEngineType type) {
|
||||||
this(type.createEngine(!isDevMode()));
|
this(type.createAdapter(!Flash.DEV));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Manual mode — use a pre-constructed {@link ViewEngine} implementation.
|
|
||||||
* Suitable for custom engines or engines that need non-default configuration.
|
|
||||||
*
|
|
||||||
* @param engine the engine implementation; must be thread-safe
|
|
||||||
*/
|
|
||||||
public ViewExtension(ViewEngine engine) {
|
public ViewExtension(ViewEngine engine) {
|
||||||
this.engine = Objects.requireNonNull(engine, "ViewEngine must not be null");
|
this(new LegacyViewEngineAdapter(engine), engine);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── FlashExtension ────────────────────────────────────────────────────────
|
public ViewExtension(ViewEngineAdapter adapter) {
|
||||||
|
this(adapter, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ViewExtension(ViewEngineAdapter adapter, ViewEngine legacyEngine) {
|
||||||
|
this.adapter = Objects.requireNonNull(adapter, "ViewEngineAdapter must not be null");
|
||||||
|
this.legacyEngine = legacyEngine;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ViewExtension addGlobals(ViewGlobals provider) {
|
||||||
|
globals.add(Objects.requireNonNull(provider, "ViewGlobals must not be null"));
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void install(FlashRegistrar app, FlashContext ctx) {
|
public void provide(FlashContext ctx) {
|
||||||
Renderer renderer = new Renderer(engine);
|
ViewRuntime runtime = new ViewRuntime(adapter, List.copyOf(globals));
|
||||||
|
ctx.provide(ViewEngineAdapter.class, adapter);
|
||||||
|
ctx.provide(ViewRuntime.class, runtime);
|
||||||
|
|
||||||
|
if (legacyEngine != null) {
|
||||||
|
ctx.provide(ViewEngine.class, legacyEngine);
|
||||||
|
ctx.provide(Renderer.class, new Renderer(legacyEngine));
|
||||||
|
} else if (adapter instanceof ViewEngine engine) {
|
||||||
ctx.provide(ViewEngine.class, engine);
|
ctx.provide(ViewEngine.class, engine);
|
||||||
ctx.provide(Renderer.class, renderer);
|
ctx.provide(Renderer.class, new Renderer(engine));
|
||||||
|
}
|
||||||
|
|
||||||
ctx.addAnnotationProcessor(handlerClass -> {
|
ctx.addAnnotationProcessor(handlerClass -> {
|
||||||
View view = findView(handlerClass);
|
if (ViewHandler.class.isAssignableFrom(handlerClass)) return List.of();
|
||||||
if (view == null) return List.of();
|
|
||||||
|
|
||||||
String defaultTemplate = view.value();
|
ViewTargetResolver.ResolvedView resolved = ViewTargetResolver.resolve(handlerClass, adapter.capabilities());
|
||||||
ContentType contentType = view.contentType();
|
if (resolved == null) return List.of();
|
||||||
boolean fragment = view.fragment();
|
|
||||||
|
|
||||||
// Injected once per handler at boot — zero overhead on the hot-path.
|
|
||||||
// Intercepts the return value: Template signal overrides name+model;
|
|
||||||
// any other value becomes the model for the annotation's template.
|
|
||||||
Middleware renderingMiddleware = next -> (req, res) -> {
|
Middleware renderingMiddleware = next -> (req, res) -> {
|
||||||
Object result = next.handle(req, res);
|
Object result = next.handle(req, res);
|
||||||
String tpl;
|
ViewModel local = ViewRuntime.legacyLocalModel(result);
|
||||||
Object model;
|
ViewModel merged = runtime.merge(req, local);
|
||||||
if (result instanceof Template t) {
|
RenderOutput out = adapter.render(resolved.target(), merged.toMap(), req, res);
|
||||||
tpl = t.name();
|
|
||||||
model = t.model();
|
if (resolved.legacy()) {
|
||||||
} else {
|
ContentType type = resolved.contentType();
|
||||||
tpl = defaultTemplate;
|
if (type != null) res.type(type);
|
||||||
model = result;
|
else if (out.contentType() != null) res.type(out.contentType());
|
||||||
|
} else if (out.contentType() != null) {
|
||||||
|
res.type(out.contentType());
|
||||||
}
|
}
|
||||||
res.setContentType(contentType);
|
return out.body();
|
||||||
return engine.render(tpl, model, fragment);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return List.of(renderingMiddleware);
|
return List.of(renderingMiddleware);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Walks the superclass chain to find {@link View @View}.
|
|
||||||
* Supports inheritance: a base handler can declare the view template and
|
|
||||||
* concrete subclasses inherit it without re-annotating.
|
|
||||||
*/
|
|
||||||
private static View findView(Class<?> cls) {
|
|
||||||
while (cls != null && !cls.equals(Object.class)) {
|
|
||||||
View v = cls.getAnnotation(View.class);
|
|
||||||
if (v != null) return v;
|
|
||||||
cls = cls.getSuperclass();
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns {@code true} when running in dev mode.
|
|
||||||
* Checks JVM property {@code flash.env} first, then env var {@code FLASH_ENV}.
|
|
||||||
*/
|
|
||||||
private static boolean isDevMode() {
|
|
||||||
String prop = System.getProperty("flash.env");
|
|
||||||
if (prop != null) return "dev".equalsIgnoreCase(prop);
|
|
||||||
String env = System.getenv("FLASH_ENV");
|
|
||||||
return "dev".equalsIgnoreCase(env);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package dev.relism.ext.view;
|
||||||
|
|
||||||
|
import dev.relism.models.Request;
|
||||||
|
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface ViewGlobals {
|
||||||
|
ViewModel provide(Request req);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package dev.relism.ext.view;
|
||||||
|
|
||||||
|
import dev.relism.models.Request;
|
||||||
|
import dev.relism.models.RequestHandler;
|
||||||
|
import dev.relism.models.Response;
|
||||||
|
|
||||||
|
public abstract class ViewHandler extends RequestHandler {
|
||||||
|
private ViewRuntime runtime;
|
||||||
|
private ViewTargetResolver.ResolvedView resolved;
|
||||||
|
|
||||||
|
public ViewModel render(Request req) throws Exception {
|
||||||
|
throw new UnsupportedOperationException("Override render(Request) or render(Request, Response)");
|
||||||
|
}
|
||||||
|
|
||||||
|
public ViewModel render(Request req, Response res) throws Exception {
|
||||||
|
return render(req);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected final void onInit() {
|
||||||
|
runtime = require(ViewRuntime.class);
|
||||||
|
resolved = runtime.resolve(getClass());
|
||||||
|
onViewInit();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected void onViewInit() {}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public final Object handle(Request request, Response response) throws Exception {
|
||||||
|
return runtime.render(this, resolved, request, response);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package dev.relism.ext.view;
|
||||||
|
|
||||||
|
public enum ViewKind {
|
||||||
|
PAGE,
|
||||||
|
PARTIAL
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package dev.relism.ext.view;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
public final class ViewModel {
|
||||||
|
private final LinkedHashMap<String, Object> values;
|
||||||
|
|
||||||
|
private ViewModel(LinkedHashMap<String, Object> values) {
|
||||||
|
this.values = values;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ViewModel empty() {
|
||||||
|
return new ViewModel(new LinkedHashMap<>());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ViewModel of(String key, Object value) {
|
||||||
|
return empty().with(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ViewModel with(String key, Object value) {
|
||||||
|
values.put(Objects.requireNonNull(key, "key"), unwrapValue(value));
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ViewModel withAll(Map<String, Object> values) {
|
||||||
|
if (values == null || values.isEmpty()) return this;
|
||||||
|
for (Map.Entry<String, Object> e : values.entrySet()) {
|
||||||
|
with(e.getKey(), e.getValue());
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ViewModel merge(ViewModel other) {
|
||||||
|
ViewModel merged = new ViewModel(new LinkedHashMap<>(this.values));
|
||||||
|
if (other != null && !other.values.isEmpty()) merged.values.putAll(other.values);
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, Object> toMap() {
|
||||||
|
return Collections.unmodifiableMap(values);
|
||||||
|
}
|
||||||
|
|
||||||
|
static ViewModel copyOf(ViewModel source) {
|
||||||
|
if (source == null || source.values.isEmpty()) return empty();
|
||||||
|
return new ViewModel(new LinkedHashMap<>(source.values));
|
||||||
|
}
|
||||||
|
|
||||||
|
static Object unwrapValue(Object value) {
|
||||||
|
if (value instanceof ViewModel vm) {
|
||||||
|
return vm.toMap();
|
||||||
|
}
|
||||||
|
if (value instanceof Map<?, ?> map) {
|
||||||
|
LinkedHashMap<String, Object> out = new LinkedHashMap<>();
|
||||||
|
for (Map.Entry<?, ?> e : map.entrySet()) {
|
||||||
|
out.put(String.valueOf(e.getKey()), unwrapValue(e.getValue()));
|
||||||
|
}
|
||||||
|
return Collections.unmodifiableMap(out);
|
||||||
|
}
|
||||||
|
if (value instanceof List<?> list) {
|
||||||
|
ArrayList<Object> out = new ArrayList<>(list.size());
|
||||||
|
for (Object item : list) out.add(unwrapValue(item));
|
||||||
|
return Collections.unmodifiableList(out);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package dev.relism.ext.view;
|
||||||
|
|
||||||
|
import dev.relism.models.Request;
|
||||||
|
import dev.relism.models.Response;
|
||||||
|
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
final class ViewRuntime {
|
||||||
|
private static final ViewModel EMPTY = ViewModel.empty();
|
||||||
|
|
||||||
|
private final ViewEngineAdapter adapter;
|
||||||
|
private final List<ViewGlobals> globals;
|
||||||
|
private final ConcurrentHashMap<Class<?>, ViewTargetResolver.ResolvedView> resolvedCache = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
ViewRuntime(ViewEngineAdapter adapter, List<ViewGlobals> globals) {
|
||||||
|
this.adapter = adapter;
|
||||||
|
this.globals = globals;
|
||||||
|
}
|
||||||
|
|
||||||
|
ViewTargetResolver.ResolvedView resolve(Class<?> handlerClass) {
|
||||||
|
ViewTargetResolver.ResolvedView cached = resolvedCache.get(handlerClass);
|
||||||
|
if (cached != null) return cached;
|
||||||
|
ViewTargetResolver.ResolvedView resolved = ViewTargetResolver.resolve(handlerClass, adapter.capabilities());
|
||||||
|
if (resolved == null) {
|
||||||
|
throw new IllegalStateException("ViewHandler " + handlerClass.getName()
|
||||||
|
+ " must declare @Page, @Partial, or @View");
|
||||||
|
}
|
||||||
|
resolvedCache.put(handlerClass, resolved);
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
Object render(ViewHandler handler, ViewTargetResolver.ResolvedView resolved, Request req, Response res) throws Exception {
|
||||||
|
if (resolved == null) {
|
||||||
|
throw new IllegalStateException("ViewHandler " + handler.getClass().getName()
|
||||||
|
+ " must declare @Page, @Partial, or @View");
|
||||||
|
}
|
||||||
|
|
||||||
|
ViewModel local = handler.render(req, res);
|
||||||
|
ViewModel merged = merge(req, local);
|
||||||
|
|
||||||
|
RenderOutput out = adapter.render(resolved.target(), merged.toMap(), req, res);
|
||||||
|
if (out.contentType() != null) {
|
||||||
|
res.type(out.contentType());
|
||||||
|
} else {
|
||||||
|
res.type(resolved.contentType());
|
||||||
|
}
|
||||||
|
return out.body();
|
||||||
|
}
|
||||||
|
|
||||||
|
ViewModel merge(Request req, ViewModel local) {
|
||||||
|
ViewModel global = computeGlobals(req);
|
||||||
|
return global.merge(local == null ? EMPTY : local);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ViewModel computeGlobals(Request req) {
|
||||||
|
if (globals.isEmpty()) return EMPTY;
|
||||||
|
ViewModel out = ViewModel.empty();
|
||||||
|
for (ViewGlobals provider : globals) {
|
||||||
|
ViewModel vm = provider.provide(req);
|
||||||
|
if (vm != null) out.withAll(vm.toMap());
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
static ViewModel legacyLocalModel(Object legacyModel) {
|
||||||
|
if (legacyModel == null) return ViewModel.empty();
|
||||||
|
if (legacyModel instanceof ViewModel vm) return ViewModel.copyOf(vm);
|
||||||
|
if (legacyModel instanceof Map<?, ?> map) {
|
||||||
|
ViewModel vm = ViewModel.empty();
|
||||||
|
for (Map.Entry<?, ?> e : map.entrySet()) {
|
||||||
|
vm.with(String.valueOf(e.getKey()), ViewModel.unwrapValue(e.getValue()));
|
||||||
|
}
|
||||||
|
return vm;
|
||||||
|
}
|
||||||
|
return ViewModel.of("it", legacyModel);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package dev.relism.ext.view;
|
||||||
|
|
||||||
|
public record ViewTarget(
|
||||||
|
ViewKind kind,
|
||||||
|
String template,
|
||||||
|
String slot
|
||||||
|
) {}
|
||||||
+62
@@ -0,0 +1,62 @@
|
|||||||
|
package dev.relism.ext.view;
|
||||||
|
|
||||||
|
import dev.relism.http.ContentType;
|
||||||
|
import dev.relism.routing.Route;
|
||||||
|
import dev.relism.routing.Routes;
|
||||||
|
|
||||||
|
final class ViewTargetResolver {
|
||||||
|
|
||||||
|
private ViewTargetResolver() {}
|
||||||
|
|
||||||
|
static ResolvedView resolve(Class<?> handlerClass, EngineCapabilities capabilities) {
|
||||||
|
Page page = find(handlerClass, Page.class);
|
||||||
|
Partial partial = find(handlerClass, Partial.class);
|
||||||
|
View legacy = find(handlerClass, View.class);
|
||||||
|
|
||||||
|
int count = (page != null ? 1 : 0) + (partial != null ? 1 : 0) + (legacy != null ? 1 : 0);
|
||||||
|
if (count == 0) return null;
|
||||||
|
|
||||||
|
Route route = Routes.of(handlerClass);
|
||||||
|
if (route == null) {
|
||||||
|
throw new IllegalStateException("View handler " + handlerClass.getName()
|
||||||
|
+ " has view annotation but no route annotation (@Route/@GET/@POST/...)");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count > 1) {
|
||||||
|
throw new IllegalStateException("Handler " + handlerClass.getName()
|
||||||
|
+ " route " + route.method() + " " + route.path()
|
||||||
|
+ " declares multiple view annotations. Use exactly one of @Page, @Partial, @View");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (page != null) {
|
||||||
|
return new ResolvedView(new ViewTarget(ViewKind.PAGE, page.value(), ""), ContentType.TEXT_HTML, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (partial != null) {
|
||||||
|
String slot = partial.slot() == null ? "" : partial.slot().trim();
|
||||||
|
if (!slot.isEmpty() && !capabilities.supportsPartialSlot()) {
|
||||||
|
throw new IllegalStateException("Handler " + handlerClass.getName()
|
||||||
|
+ " route " + route.method() + " " + route.path()
|
||||||
|
+ " uses @Partial(slot=\"" + slot + "\") but current engine does not support partial slots");
|
||||||
|
}
|
||||||
|
return new ResolvedView(new ViewTarget(ViewKind.PARTIAL, partial.template(), slot), ContentType.TEXT_HTML, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ResolvedView(
|
||||||
|
new ViewTarget(legacy.fragment() ? ViewKind.PARTIAL : ViewKind.PAGE, legacy.value(), ""),
|
||||||
|
legacy.contentType(),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static <A extends java.lang.annotation.Annotation> A find(Class<?> cls, Class<A> type) {
|
||||||
|
while (cls != null && !cls.equals(Object.class)) {
|
||||||
|
A a = cls.getAnnotation(type);
|
||||||
|
if (a != null) return a;
|
||||||
|
cls = cls.getSuperclass();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
record ResolvedView(ViewTarget target, ContentType contentType, boolean legacy) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package dev.relism.ext.view;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class ViewModelTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void ofWithAndWithAll_populateModel() {
|
||||||
|
ViewModel model = ViewModel.of("a", 1)
|
||||||
|
.with("b", "x")
|
||||||
|
.withAll(Map.of("c", true));
|
||||||
|
|
||||||
|
assertEquals(1, model.toMap().get("a"));
|
||||||
|
assertEquals("x", model.toMap().get("b"));
|
||||||
|
assertEquals(true, model.toMap().get("c"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void merge_otherWinsOnCollision() {
|
||||||
|
ViewModel global = ViewModel.of("nav", "global").with("app", "flash");
|
||||||
|
ViewModel local = ViewModel.of("nav", "local");
|
||||||
|
|
||||||
|
ViewModel merged = global.merge(local);
|
||||||
|
|
||||||
|
assertEquals("local", merged.toMap().get("nav"));
|
||||||
|
assertEquals("flash", merged.toMap().get("app"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void toMap_isReadOnly() {
|
||||||
|
ViewModel model = ViewModel.of("k", "v");
|
||||||
|
Map<String, Object> map = model.toMap();
|
||||||
|
|
||||||
|
assertThrows(UnsupportedOperationException.class, () -> map.put("x", 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void nestedViewModel_isUnwrappedToMapRecursively() {
|
||||||
|
ViewModel nested = ViewModel.of("name", "Flash");
|
||||||
|
ViewModel root = ViewModel.of("app", nested)
|
||||||
|
.with("list", List.of(ViewModel.of("n", 1), ViewModel.of("n", 2)));
|
||||||
|
|
||||||
|
Object app = root.toMap().get("app");
|
||||||
|
Object list = root.toMap().get("list");
|
||||||
|
|
||||||
|
assertTrue(app instanceof Map<?, ?>);
|
||||||
|
assertEquals("Flash", ((Map<?, ?>) app).get("name"));
|
||||||
|
assertTrue(list instanceof List<?>);
|
||||||
|
assertTrue(((List<?>) list).getFirst() instanceof Map<?, ?>);
|
||||||
|
}
|
||||||
|
}
|
||||||
+48
@@ -0,0 +1,48 @@
|
|||||||
|
package dev.relism.ext.view;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
|
||||||
|
class ViewRuntimeGlobalsTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void merge_globalsThenLocal_localWins() {
|
||||||
|
ViewRuntime runtime = new ViewRuntime(new NoopAdapter(), List.of(
|
||||||
|
req -> ViewModel.of("nav", "global").with("app", "flash"),
|
||||||
|
req -> ViewModel.of("nav", "global-2")
|
||||||
|
));
|
||||||
|
|
||||||
|
ViewModel merged = runtime.merge(null, ViewModel.of("nav", "local"));
|
||||||
|
|
||||||
|
assertEquals("local", merged.toMap().get("nav"));
|
||||||
|
assertEquals("flash", merged.toMap().get("app"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void merge_nullLocal_keepsGlobals() {
|
||||||
|
ViewRuntime runtime = new ViewRuntime(new NoopAdapter(), List.of(
|
||||||
|
req -> ViewModel.of("signedIn", true)
|
||||||
|
));
|
||||||
|
|
||||||
|
ViewModel merged = runtime.merge(null, null);
|
||||||
|
|
||||||
|
assertEquals(true, merged.toMap().get("signedIn"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class NoopAdapter implements ViewEngineAdapter {
|
||||||
|
@Override
|
||||||
|
public EngineCapabilities capabilities() {
|
||||||
|
return EngineCapabilities.NONE;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public RenderOutput render(ViewTarget target, java.util.Map<String, Object> model,
|
||||||
|
dev.relism.models.Request req,
|
||||||
|
dev.relism.models.Response res) {
|
||||||
|
return RenderOutput.html("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+95
@@ -0,0 +1,95 @@
|
|||||||
|
package dev.relism.ext.view;
|
||||||
|
|
||||||
|
import dev.relism.http.ContentType;
|
||||||
|
import dev.relism.models.Request;
|
||||||
|
import dev.relism.models.RequestHandler;
|
||||||
|
import dev.relism.models.Response;
|
||||||
|
import dev.relism.routing.GET;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class ViewTargetResolverTest {
|
||||||
|
|
||||||
|
@GET("/home")
|
||||||
|
@Page("pages/home")
|
||||||
|
static class PageHandler extends RequestHandler {
|
||||||
|
@Override
|
||||||
|
public Object handle(Request request, Response response) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@GET("/partial")
|
||||||
|
@Partial(template = "fragments/row", slot = "row")
|
||||||
|
static class SlotPartialHandler extends RequestHandler {
|
||||||
|
@Override
|
||||||
|
public Object handle(Request request, Response response) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@GET("/legacy")
|
||||||
|
@View(value = "legacy/home", contentType = ContentType.TEXT_PLAIN, fragment = true)
|
||||||
|
static class LegacyHandler extends RequestHandler {
|
||||||
|
@Override
|
||||||
|
public Object handle(Request request, Response response) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@GET("/bad")
|
||||||
|
@Page("a")
|
||||||
|
@Partial(template = "b")
|
||||||
|
static class ConflictingHandler extends RequestHandler {
|
||||||
|
@Override
|
||||||
|
public Object handle(Request request, Response response) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Page("pages/no-route")
|
||||||
|
static class NoRouteHandler extends RequestHandler {
|
||||||
|
@Override
|
||||||
|
public Object handle(Request request, Response response) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resolve_page_returnsPageTarget() {
|
||||||
|
ViewTargetResolver.ResolvedView resolved = ViewTargetResolver.resolve(PageHandler.class, new EngineCapabilities(true));
|
||||||
|
|
||||||
|
assertNotNull(resolved);
|
||||||
|
assertEquals(ViewKind.PAGE, resolved.target().kind());
|
||||||
|
assertEquals("pages/home", resolved.target().template());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resolve_slotPartial_withoutCapability_failsFast() {
|
||||||
|
assertThrows(IllegalStateException.class,
|
||||||
|
() -> ViewTargetResolver.resolve(SlotPartialHandler.class, EngineCapabilities.NONE));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resolve_legacyView_mapsToResolvedTarget() {
|
||||||
|
ViewTargetResolver.ResolvedView resolved = ViewTargetResolver.resolve(LegacyHandler.class, EngineCapabilities.NONE);
|
||||||
|
|
||||||
|
assertNotNull(resolved);
|
||||||
|
assertTrue(resolved.legacy());
|
||||||
|
assertEquals(ContentType.TEXT_PLAIN, resolved.contentType());
|
||||||
|
assertEquals(ViewKind.PARTIAL, resolved.target().kind());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resolve_multipleViewAnnotations_fails() {
|
||||||
|
assertThrows(IllegalStateException.class,
|
||||||
|
() -> ViewTargetResolver.resolve(ConflictingHandler.class, new EngineCapabilities(true)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resolve_viewAnnotationWithoutRoute_fails() {
|
||||||
|
assertThrows(IllegalStateException.class,
|
||||||
|
() -> ViewTargetResolver.resolve(NoRouteHandler.class, new EngineCapabilities(true)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# flash-web-bundler
|
||||||
|
|
||||||
|
`flash-web-bundler` integrates frontend tooling lifecycle into Flash with explicit, policy-driven behavior.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```java
|
||||||
|
FlashApp.create(8080)
|
||||||
|
.install(new WebBundlerExtension(
|
||||||
|
WebBundlerConfig.builder()
|
||||||
|
.webRoot(Path.of("web"))
|
||||||
|
.basePath("/")
|
||||||
|
.build()
|
||||||
|
))
|
||||||
|
.start();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Operating Model
|
||||||
|
|
||||||
|
- Dev: orchestrates frontend process lifecycle and health checks.
|
||||||
|
- Prod: serves prebuilt assets from `assetsSource` with ETag/cache/compression support.
|
||||||
|
- Route precedence: backend first, SPA fallback second (`GET`/`HEAD`).
|
||||||
|
|
||||||
|
See also: `asset-sources.md`.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Asset Sources
|
||||||
|
|
||||||
|
`flash-web-bundler` uses explicit source objects for production assets.
|
||||||
|
|
||||||
|
Supported sources:
|
||||||
|
|
||||||
|
- `FilesystemAssetsSource.of(Path)`
|
||||||
|
- `ClasspathAssetsSource.of(String rootPrefix)`
|
||||||
|
|
||||||
|
Builder shortcuts:
|
||||||
|
|
||||||
|
- `.assetsFromFilesystem(Path.of("dist"))`
|
||||||
|
- `.assetsFromClasspath("web/dist")`
|
||||||
|
|
||||||
|
Classpath source requires `asset-manifest.json` generated at build time.
|
||||||
|
The developer does not maintain this file manually.
|
||||||
|
|
||||||
|
Production startup is fail-fast if:
|
||||||
|
|
||||||
|
- manifest is missing
|
||||||
|
- manifest is invalid
|
||||||
|
- manifest points to missing resources
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Configuration
|
||||||
|
|
||||||
|
`WebBundlerConfig` is immutable and built with `WebBundlerConfig.builder()`.
|
||||||
|
|
||||||
|
Key fields:
|
||||||
|
|
||||||
|
- `runtimeMode`: `PROD`, `ENV`, `AUTODETECT`
|
||||||
|
- `operationMode`: `ORCHESTRATE_ONLY`, `MANAGED`
|
||||||
|
- `frontendType`: currently `VITE`
|
||||||
|
- `packageManager`: `NPM`, `PNPM`, `YARN`, `BUN`
|
||||||
|
- `installPolicy`: `AUTO_IF_LOCK_HASH_CHANGED`, `NEVER`
|
||||||
|
- `loggingMode`: `MERGED`, `SEPARATE`, `QUIET`, `VERBOSE`
|
||||||
|
- `commandSafetyMode`: `WARN`, `BLOCK`, `ALLOW`
|
||||||
|
- `webRoot`, `assetsSource`, `basePath`, `devHost`, `devPort`, `watchList`
|
||||||
|
|
||||||
|
Asset source is explicit and object-based:
|
||||||
|
|
||||||
|
- `assetsFromFilesystem(Path.of("dist"))`
|
||||||
|
- `assetsFromClasspath("web/dist")`
|
||||||
|
|
||||||
|
Or by direct source object:
|
||||||
|
|
||||||
|
- `assetsSource(FilesystemAssetsSource.of(Path.of("dist")))`
|
||||||
|
- `assetsSource(ClasspathAssetsSource.of("web/dist"))`
|
||||||
|
|
||||||
|
Validation is fail-fast:
|
||||||
|
|
||||||
|
- invalid `devPort`
|
||||||
|
- blank/invalid watch entries
|
||||||
|
- invalid `basePath`
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Dev Lifecycle
|
||||||
|
|
||||||
|
Boot flow in dev mode:
|
||||||
|
|
||||||
|
1. Resolve runtime mode.
|
||||||
|
2. Validate configuration and command safety.
|
||||||
|
3. Optionally install dependencies (hash-based lockfile cache).
|
||||||
|
4. Start dev server process.
|
||||||
|
5. Run healthcheck.
|
||||||
|
6. Start watchlist loop and restart dev process when tracked files change.
|
||||||
|
|
||||||
|
Failure conditions are fail-fast:
|
||||||
|
|
||||||
|
- occupied dev port
|
||||||
|
- command launch failure
|
||||||
|
- healthcheck timeout
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Frontend Selection
|
||||||
|
|
||||||
|
Frontend integration is explicit through `frontendType`.
|
||||||
|
|
||||||
|
- No heuristic detection in v1.
|
||||||
|
- Deterministic mapping: `FrontendType -> FrontendStrategy`.
|
||||||
|
- Current built-in strategy: `VITE`.
|
||||||
|
|
||||||
|
Extension points:
|
||||||
|
|
||||||
|
- register custom `FrontendStrategy` implementations in the resolver.
|
||||||
|
- override default commands per config.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Modes
|
||||||
|
|
||||||
|
## Runtime Mode
|
||||||
|
|
||||||
|
- `PROD`: production static serving only.
|
||||||
|
- `AUTODETECT`: uses `Flash.DEV`.
|
||||||
|
- `ENV`: uses `FLASH_WEB_BUNDLER_MODE` (`dev` => dev mode, else prod).
|
||||||
|
|
||||||
|
## Operation Mode
|
||||||
|
|
||||||
|
- `ORCHESTRATE_ONLY`: only orchestrates dev tooling.
|
||||||
|
- `MANAGED`: enables production serving + SPA fallback routes.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Package Managers
|
||||||
|
|
||||||
|
Package manager selection is explicit via `packageManager`.
|
||||||
|
|
||||||
|
- `NPM` -> `package-lock.json`
|
||||||
|
- `PNPM` -> `pnpm-lock.yaml`
|
||||||
|
- `YARN` -> `yarn.lock`
|
||||||
|
- `BUN` -> `bun.lockb`
|
||||||
|
|
||||||
|
Install policy:
|
||||||
|
|
||||||
|
- `AUTO_IF_LOCK_HASH_CHANGED`: install only when lock hash changes.
|
||||||
|
- `NEVER`: never auto-install.
|
||||||
|
|
||||||
|
Custom commands can override defaults via:
|
||||||
|
|
||||||
|
- `installCommand`
|
||||||
|
- `devCommand`
|
||||||
|
- `buildCommand`
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Performance Notes
|
||||||
|
|
||||||
|
Current v1 optimizations:
|
||||||
|
|
||||||
|
- startup preloading of static assets
|
||||||
|
- precomputed ETags and MIME lookup
|
||||||
|
- compressed payload selection based on `Accept-Encoding`
|
||||||
|
- lockfile-hash install gate to avoid redundant installs
|
||||||
|
|
||||||
|
Future performance work:
|
||||||
|
|
||||||
|
- streaming/sliced file serving for large assets
|
||||||
|
- benchmark suite with p50/p95/p99 latency and allocation profiling
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Production Serving
|
||||||
|
|
||||||
|
In production mode, `flash-web-bundler` serves only prebuilt assets.
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
|
||||||
|
- preloads files from `assetsSource` (filesystem or classpath)
|
||||||
|
- resolves MIME by extension
|
||||||
|
- supports precompressed siblings (`.br`, `.gz`)
|
||||||
|
- sets `Cache-Control` and `ETag`
|
||||||
|
- responds `304 Not Modified` when `If-None-Match` matches
|
||||||
|
|
||||||
|
Classpath mode details:
|
||||||
|
|
||||||
|
- reads entries from `asset-manifest.json` in classpath root
|
||||||
|
- validates referenced resources at startup
|
||||||
|
- fails fast in production if manifest or resources are inconsistent
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# Routing Fallback
|
||||||
|
|
||||||
|
Fallback policy:
|
||||||
|
|
||||||
|
- backend routes always have precedence
|
||||||
|
- fallback route is wildcard under configured `basePath`
|
||||||
|
- fallback applies to `GET` and `HEAD` only
|
||||||
|
- if no static asset matches, serve SPA `indexFile`
|
||||||
|
|
||||||
|
Namespace collisions should be avoided at app design level (for example, keep API under `/api`).
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Security Policies
|
||||||
|
|
||||||
|
Command safety is enforced before process execution.
|
||||||
|
|
||||||
|
Modes:
|
||||||
|
|
||||||
|
- `WARN`: allow unknown binary and log warning.
|
||||||
|
- `BLOCK`: reject unknown binary.
|
||||||
|
- `ALLOW`: skip safe-registry checks.
|
||||||
|
|
||||||
|
Safe registry defaults include common package-manager executables and is configurable.
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<parent>
|
||||||
|
<groupId>dev.relism</groupId>
|
||||||
|
<artifactId>flash-extensions</artifactId>
|
||||||
|
<version>1.0-SNAPSHOT</version>
|
||||||
|
</parent>
|
||||||
|
|
||||||
|
<artifactId>flash-web-bundler</artifactId>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>dev.relism</groupId>
|
||||||
|
<artifactId>flash</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.slf4j</groupId>
|
||||||
|
<artifactId>slf4j-api</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.fasterxml.jackson.core</groupId>
|
||||||
|
<artifactId>jackson-databind</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.junit.jupiter</groupId>
|
||||||
|
<artifactId>junit-jupiter</artifactId>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
</project>
|
||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
package dev.relism.ext.webbundler;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
public final class AssetCatalog {
|
||||||
|
private final Map<String, AssetEntry> byRoutePath;
|
||||||
|
private final AssetEntry index;
|
||||||
|
|
||||||
|
AssetCatalog(Map<String, AssetEntry> byRoutePath, AssetEntry index) {
|
||||||
|
this.byRoutePath = Map.copyOf(byRoutePath);
|
||||||
|
this.index = index;
|
||||||
|
}
|
||||||
|
|
||||||
|
AssetEntry find(String routePath) {
|
||||||
|
return byRoutePath.get(routePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
AssetEntry index() {
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AssetEntry asset(String routePath) {
|
||||||
|
return find(routePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
public AssetEntry indexAsset() {
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int size() {
|
||||||
|
return byRoutePath.size();
|
||||||
|
}
|
||||||
|
}
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
package dev.relism.ext.webbundler;
|
||||||
|
|
||||||
|
public record AssetEntry(
|
||||||
|
byte[] raw,
|
||||||
|
byte[] br,
|
||||||
|
byte[] gz,
|
||||||
|
String etag,
|
||||||
|
String mimeType,
|
||||||
|
boolean immutable
|
||||||
|
) {
|
||||||
|
}
|
||||||
+93
@@ -0,0 +1,93 @@
|
|||||||
|
package dev.relism.ext.webbundler;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
|
||||||
|
final class AssetIo {
|
||||||
|
private AssetIo() {
|
||||||
|
}
|
||||||
|
|
||||||
|
static byte[] read(Path path) {
|
||||||
|
try {
|
||||||
|
return Files.readAllBytes(path);
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new IllegalStateException("Failed reading asset " + path, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static byte[] readClasspath(String path) {
|
||||||
|
try (InputStream in = AssetIo.class.getClassLoader().getResourceAsStream(stripLeadingSlash(path))) {
|
||||||
|
if (in == null) throw new IllegalStateException("Missing classpath resource: " + path);
|
||||||
|
return in.readAllBytes();
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new IllegalStateException("Failed reading classpath resource: " + path, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static byte[] readClasspathOrNull(String path) {
|
||||||
|
try (InputStream in = AssetIo.class.getClassLoader().getResourceAsStream(stripLeadingSlash(path))) {
|
||||||
|
if (in == null) return null;
|
||||||
|
return in.readAllBytes();
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new IllegalStateException("Failed reading classpath resource: " + path, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static String quotedSha1(byte[] data) {
|
||||||
|
return "\"" + sha1Hex(data) + "\"";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String sha1Hex(byte[] data) {
|
||||||
|
try {
|
||||||
|
MessageDigest md = MessageDigest.getInstance("SHA-1");
|
||||||
|
byte[] hash = md.digest(data);
|
||||||
|
StringBuilder out = new StringBuilder(hash.length * 2);
|
||||||
|
for (byte b : hash) {
|
||||||
|
out.append(String.format("%02x", b));
|
||||||
|
}
|
||||||
|
return out.toString();
|
||||||
|
} catch (NoSuchAlgorithmException e) {
|
||||||
|
throw new IllegalStateException("SHA-1 unavailable", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static String stripLeadingSlash(String path) {
|
||||||
|
if (path == null || path.isBlank()) return path;
|
||||||
|
return path.startsWith("/") ? path.substring(1) : path;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String normalizePath(String path) {
|
||||||
|
if (path == null || path.isBlank()) return "/";
|
||||||
|
String p = path.replace('\\', '/');
|
||||||
|
if (!p.startsWith("/")) p = "/" + p;
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String stripBrGzSuffix(String path) {
|
||||||
|
if (path.endsWith(".br")) return path.substring(0, path.length() - 3);
|
||||||
|
if (path.endsWith(".gz")) return path.substring(0, path.length() - 3);
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
static boolean isFingerprinted(String path) {
|
||||||
|
String file = path;
|
||||||
|
int slash = file.lastIndexOf('/');
|
||||||
|
if (slash >= 0) file = file.substring(slash + 1);
|
||||||
|
int dot = file.lastIndexOf('.');
|
||||||
|
if (dot <= 0) return false;
|
||||||
|
int prevDot = file.lastIndexOf('.', dot - 1);
|
||||||
|
if (prevDot <= 0) return false;
|
||||||
|
String token = file.substring(prevDot + 1, dot);
|
||||||
|
if (token.length() < 8) return false;
|
||||||
|
for (int i = 0; i < token.length(); i++) {
|
||||||
|
char c = token.charAt(i);
|
||||||
|
boolean hex = (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F');
|
||||||
|
if (!hex) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
package dev.relism.ext.webbundler;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
public record AssetLoadRequest(String basePath, String indexFile, RuntimeEnvironment environment, Path webRoot) {
|
||||||
|
public AssetLoadRequest {
|
||||||
|
if (basePath == null || basePath.isBlank()) throw new IllegalArgumentException("basePath is required");
|
||||||
|
if (indexFile == null || indexFile.isBlank()) throw new IllegalArgumentException("indexFile is required");
|
||||||
|
if (environment == null) throw new IllegalArgumentException("environment is required");
|
||||||
|
if (webRoot == null) throw new IllegalArgumentException("webRoot is required");
|
||||||
|
}
|
||||||
|
}
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
package dev.relism.ext.webbundler;
|
||||||
|
|
||||||
|
record AssetMetadata(
|
||||||
|
String routePath,
|
||||||
|
String logicalPath,
|
||||||
|
String mimeType,
|
||||||
|
String etag,
|
||||||
|
boolean immutable
|
||||||
|
) {
|
||||||
|
AssetMetadata {
|
||||||
|
if (routePath == null || routePath.isBlank()) throw new IllegalArgumentException("routePath is required");
|
||||||
|
if (logicalPath == null || logicalPath.isBlank()) throw new IllegalArgumentException("logicalPath is required");
|
||||||
|
if (mimeType == null || mimeType.isBlank()) throw new IllegalArgumentException("mimeType is required");
|
||||||
|
if (etag == null || etag.isBlank()) throw new IllegalArgumentException("etag is required");
|
||||||
|
}
|
||||||
|
}
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
package dev.relism.ext.webbundler;
|
||||||
|
|
||||||
|
final class AssetPaths {
|
||||||
|
private AssetPaths() {
|
||||||
|
}
|
||||||
|
|
||||||
|
static String joinBase(String basePath, String routePath) {
|
||||||
|
String base = "/".equals(basePath) ? "" : basePath;
|
||||||
|
String route = AssetIo.normalizePath(routePath);
|
||||||
|
return base + route;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String normalizePrefix(String prefix) {
|
||||||
|
if (prefix == null || prefix.isBlank()) return "";
|
||||||
|
String p = prefix.replace('\\', '/');
|
||||||
|
if (p.startsWith("/")) p = p.substring(1);
|
||||||
|
while (p.endsWith("/")) p = p.substring(0, p.length() - 1);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String classpathJoin(String prefix, String relative) {
|
||||||
|
String p = normalizePrefix(prefix);
|
||||||
|
String r = AssetIo.stripLeadingSlash(relative).replace('\\', '/');
|
||||||
|
return p.isEmpty() ? r : p + "/" + r;
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
package dev.relism.ext.webbundler;
|
||||||
|
|
||||||
|
public interface AssetsSource {
|
||||||
|
AssetCatalog load(AssetLoadRequest request);
|
||||||
|
}
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
package dev.relism.ext.webbundler;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
public final class AssetsSources {
|
||||||
|
private AssetsSources() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static AssetsSource filesystem(Path distDir) {
|
||||||
|
return FilesystemAssetsSource.of(distDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static AssetsSource classpath(String rootPrefix) {
|
||||||
|
return ClasspathAssetsSource.of(rootPrefix);
|
||||||
|
}
|
||||||
|
}
|
||||||
+6
@@ -0,0 +1,6 @@
|
|||||||
|
package dev.relism.ext.webbundler;
|
||||||
|
|
||||||
|
public enum BasePathEnforcementMode {
|
||||||
|
WARN_ONLY,
|
||||||
|
STRICT
|
||||||
|
}
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
package dev.relism.ext.webbundler;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
record ClasspathAssetManifest(@JsonProperty("assets") List<Entry> assets) {
|
||||||
|
record Entry(
|
||||||
|
@JsonProperty("routePath") String routePath,
|
||||||
|
@JsonProperty("resourcePath") String resourcePath,
|
||||||
|
@JsonProperty("mimeType") String mimeType,
|
||||||
|
@JsonProperty("etag") String etag,
|
||||||
|
@JsonProperty("immutable") boolean immutable
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
+91
@@ -0,0 +1,91 @@
|
|||||||
|
package dev.relism.ext.webbundler;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
|
||||||
|
public final class ClasspathAssetsSource implements AssetsSource {
|
||||||
|
private static final ObjectMapper JSON = new ObjectMapper();
|
||||||
|
|
||||||
|
private final String rootPrefix;
|
||||||
|
private final String manifestResource;
|
||||||
|
|
||||||
|
private ClasspathAssetsSource(String rootPrefix, String manifestResource) {
|
||||||
|
this.rootPrefix = AssetPaths.normalizePrefix(rootPrefix);
|
||||||
|
this.manifestResource = AssetIo.stripLeadingSlash(manifestResource);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ClasspathAssetsSource of(String rootPrefix) {
|
||||||
|
if (rootPrefix == null || rootPrefix.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("rootPrefix cannot be blank");
|
||||||
|
}
|
||||||
|
String normalized = AssetPaths.normalizePrefix(rootPrefix);
|
||||||
|
return new ClasspathAssetsSource(normalized, normalized + "/asset-manifest.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ClasspathAssetsSource of(String rootPrefix, String manifestResource) {
|
||||||
|
if (rootPrefix == null || rootPrefix.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("rootPrefix cannot be blank");
|
||||||
|
}
|
||||||
|
if (manifestResource == null || manifestResource.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("manifestResource cannot be blank");
|
||||||
|
}
|
||||||
|
return new ClasspathAssetsSource(rootPrefix, manifestResource);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public AssetCatalog load(AssetLoadRequest request) {
|
||||||
|
ClasspathAssetManifest manifest = readManifest();
|
||||||
|
if (manifest.assets() == null || manifest.assets().isEmpty()) {
|
||||||
|
throw new IllegalStateException("Classpath asset manifest has no assets: " + manifestResource);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, AssetEntry> byRoute = new HashMap<>();
|
||||||
|
for (ClasspathAssetManifest.Entry entry : manifest.assets()) {
|
||||||
|
AssetMetadata meta = toMeta(entry);
|
||||||
|
byte[] raw = AssetIo.readClasspath(resourcePath(meta.logicalPath()));
|
||||||
|
byte[] br = AssetIo.readClasspathOrNull(resourcePath(meta.logicalPath() + ".br"));
|
||||||
|
byte[] gz = AssetIo.readClasspathOrNull(resourcePath(meta.logicalPath() + ".gz"));
|
||||||
|
String routePath = AssetPaths.joinBase(request.basePath(), meta.routePath());
|
||||||
|
byRoute.put(routePath, new AssetEntry(raw, br, gz, meta.etag(), meta.mimeType(), meta.immutable()));
|
||||||
|
}
|
||||||
|
|
||||||
|
String indexRoute = AssetPaths.joinBase(request.basePath(), "/" + request.indexFile());
|
||||||
|
AssetEntry index = byRoute.get(indexRoute);
|
||||||
|
if (index == null) {
|
||||||
|
throw new IllegalStateException("Missing SPA fallback file in classpath manifest: " + indexRoute);
|
||||||
|
}
|
||||||
|
return new AssetCatalog(byRoute, index);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ClasspathAssetManifest readManifest() {
|
||||||
|
try (InputStream in = ClasspathAssetsSource.class.getClassLoader().getResourceAsStream(manifestResource)) {
|
||||||
|
if (in == null) throw new IllegalStateException("Missing classpath manifest: " + manifestResource);
|
||||||
|
String json = new String(in.readAllBytes(), StandardCharsets.UTF_8);
|
||||||
|
return JSON.readValue(json, ClasspathAssetManifest.class);
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new IllegalStateException("Invalid classpath manifest: " + manifestResource, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private AssetMetadata toMeta(ClasspathAssetManifest.Entry entry) {
|
||||||
|
if (entry == null) throw new IllegalStateException("Manifest entry cannot be null");
|
||||||
|
String routePath = AssetIo.normalizePath(entry.routePath());
|
||||||
|
String logicalPath = entry.resourcePath();
|
||||||
|
if (logicalPath == null || logicalPath.isBlank()) {
|
||||||
|
throw new IllegalStateException("Manifest entry resourcePath is required");
|
||||||
|
}
|
||||||
|
return new AssetMetadata(routePath, logicalPath.trim(), entry.mimeType(), entry.etag(), entry.immutable());
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resourcePath(String logicalPath) {
|
||||||
|
if (logicalPath.startsWith("/")) {
|
||||||
|
return logicalPath.substring(1);
|
||||||
|
}
|
||||||
|
return AssetPaths.classpathJoin(rootPrefix, logicalPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
+187
@@ -0,0 +1,187 @@
|
|||||||
|
package dev.relism.ext.webbundler;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import java.io.BufferedReader;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStreamReader;
|
||||||
|
import java.net.HttpURLConnection;
|
||||||
|
import java.net.InetSocketAddress;
|
||||||
|
import java.net.Socket;
|
||||||
|
import java.net.URL;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.ThreadFactory;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
final class CommandOrchestrator implements AutoCloseable {
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(CommandOrchestrator.class);
|
||||||
|
|
||||||
|
private final WebBundlerConfig config;
|
||||||
|
private final CommandSafetyPolicy safetyPolicy;
|
||||||
|
private final ExecutorService logPool = Executors.newCachedThreadPool(daemonThreadFactory("flash-web-bundler-log"));
|
||||||
|
private Process devProcess;
|
||||||
|
|
||||||
|
CommandOrchestrator(WebBundlerConfig config, CommandSafetyPolicy safetyPolicy) {
|
||||||
|
this.config = config;
|
||||||
|
this.safetyPolicy = safetyPolicy;
|
||||||
|
}
|
||||||
|
|
||||||
|
void runBlocking(List<String> command, Path cwd) {
|
||||||
|
safetyPolicy.check(command);
|
||||||
|
Process process = start(command, cwd);
|
||||||
|
try {
|
||||||
|
int code = process.waitFor();
|
||||||
|
if (code != 0) {
|
||||||
|
throw new IllegalStateException("Command failed (" + code + "): " + String.join(" ", command));
|
||||||
|
}
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
throw new IllegalStateException("Interrupted while waiting process", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void startDevServer(List<String> command, Path cwd) {
|
||||||
|
if (devProcess != null && devProcess.isAlive()) return;
|
||||||
|
if (isPortOccupied(config.devHost(), config.devPort())) {
|
||||||
|
throw new IllegalStateException("Dev server port already occupied: " + config.devHost() + ":" + config.devPort());
|
||||||
|
}
|
||||||
|
safetyPolicy.check(command);
|
||||||
|
devProcess = start(command, cwd);
|
||||||
|
}
|
||||||
|
|
||||||
|
void healthcheck(String healthPath, Duration timeout) {
|
||||||
|
long deadline = System.currentTimeMillis() + timeout.toMillis();
|
||||||
|
String url = "http://" + config.devHost() + ":" + config.devPort() + healthPath;
|
||||||
|
while (System.currentTimeMillis() < deadline) {
|
||||||
|
if (devProcess == null || !devProcess.isAlive()) {
|
||||||
|
throw new IllegalStateException("Dev process exited before becoming healthy");
|
||||||
|
}
|
||||||
|
if (ping(url)) return;
|
||||||
|
sleep(200L);
|
||||||
|
}
|
||||||
|
throw new IllegalStateException("Dev server healthcheck timeout for " + url);
|
||||||
|
}
|
||||||
|
|
||||||
|
void restartDevServer(List<String> command, Path cwd) {
|
||||||
|
stopDevServer();
|
||||||
|
startDevServer(command, cwd);
|
||||||
|
}
|
||||||
|
|
||||||
|
void stopDevServer() {
|
||||||
|
if (devProcess == null) return;
|
||||||
|
destroyProcessTree(devProcess, 5);
|
||||||
|
devProcess = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() {
|
||||||
|
stopDevServer();
|
||||||
|
logPool.shutdownNow();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Process start(List<String> command, Path cwd) {
|
||||||
|
ProcessBuilder pb = new ProcessBuilder(command);
|
||||||
|
pb.directory(cwd.toFile());
|
||||||
|
try {
|
||||||
|
Process process = pb.start();
|
||||||
|
attachLogs(process, command.get(0));
|
||||||
|
return process;
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new IllegalStateException("Failed to launch process: " + String.join(" ", command), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void attachLogs(Process process, String name) {
|
||||||
|
if (config.loggingMode() == LoggingMode.QUIET) return;
|
||||||
|
boolean merge = config.loggingMode() == LoggingMode.MERGED || config.loggingMode() == LoggingMode.VERBOSE;
|
||||||
|
if (merge) {
|
||||||
|
logPool.submit(() -> readStream(process.getInputStream(), "[" + name + "] "));
|
||||||
|
logPool.submit(() -> readStream(process.getErrorStream(), "[" + name + "] "));
|
||||||
|
} else {
|
||||||
|
logPool.submit(() -> readStream(process.getInputStream(), "[" + name + ":out] "));
|
||||||
|
logPool.submit(() -> readStream(process.getErrorStream(), "[" + name + ":err] "));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void readStream(java.io.InputStream in, String prefix) {
|
||||||
|
try (BufferedReader r = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) {
|
||||||
|
String line;
|
||||||
|
while ((line = r.readLine()) != null) {
|
||||||
|
if (config.loggingMode() == LoggingMode.VERBOSE) log.info("{}{}", prefix, line);
|
||||||
|
else log.debug("{}{}", prefix, line);
|
||||||
|
}
|
||||||
|
} catch (IOException ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean ping(String rawUrl) {
|
||||||
|
try {
|
||||||
|
HttpURLConnection conn = (HttpURLConnection) new URL(rawUrl).openConnection();
|
||||||
|
conn.setConnectTimeout(500);
|
||||||
|
conn.setReadTimeout(500);
|
||||||
|
conn.setRequestMethod("GET");
|
||||||
|
int code = conn.getResponseCode();
|
||||||
|
return code >= 200 && code < 500;
|
||||||
|
} catch (IOException e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isPortOccupied(String host, int port) {
|
||||||
|
try (Socket socket = new Socket()) {
|
||||||
|
socket.connect(new InetSocketAddress(host, port), 250);
|
||||||
|
return true;
|
||||||
|
} catch (IOException e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void sleep(long millis) {
|
||||||
|
try {
|
||||||
|
Thread.sleep(millis);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ThreadFactory daemonThreadFactory(String namePrefix) {
|
||||||
|
return runnable -> {
|
||||||
|
Thread thread = new Thread(runnable, namePrefix);
|
||||||
|
thread.setDaemon(true);
|
||||||
|
return thread;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void destroyProcessTree(Process process, int gracefulSeconds) {
|
||||||
|
ProcessHandle root = process.toHandle();
|
||||||
|
List<ProcessHandle> descendants = root.descendants().toList();
|
||||||
|
|
||||||
|
// First pass: graceful termination for wrapper + children.
|
||||||
|
for (ProcessHandle child : descendants) child.destroy();
|
||||||
|
root.destroy();
|
||||||
|
|
||||||
|
waitForExit(root, gracefulSeconds);
|
||||||
|
|
||||||
|
// Second pass: force-kill anything still alive (important on Windows cmd wrappers).
|
||||||
|
for (ProcessHandle child : descendants) {
|
||||||
|
if (child.isAlive()) child.destroyForcibly();
|
||||||
|
}
|
||||||
|
if (root.isAlive()) root.destroyForcibly();
|
||||||
|
|
||||||
|
waitForExit(root, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void waitForExit(ProcessHandle handle, int timeoutSeconds) {
|
||||||
|
try {
|
||||||
|
handle.onExit().get(timeoutSeconds, TimeUnit.SECONDS);
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
// Best-effort shutdown; callers handle remaining state.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
package dev.relism.ext.webbundler;
|
||||||
|
|
||||||
|
public enum CommandSafetyMode {
|
||||||
|
WARN,
|
||||||
|
BLOCK,
|
||||||
|
ALLOW
|
||||||
|
}
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
package dev.relism.ext.webbundler;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
final class CommandSafetyPolicy {
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(CommandSafetyPolicy.class);
|
||||||
|
|
||||||
|
private final WebBundlerConfig config;
|
||||||
|
|
||||||
|
CommandSafetyPolicy(WebBundlerConfig config) {
|
||||||
|
this.config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
void check(List<String> command) {
|
||||||
|
if (command.isEmpty()) throw new IllegalArgumentException("Command cannot be empty");
|
||||||
|
String binary = command.get(0);
|
||||||
|
boolean safe = config.safeCommandRegistry().contains(binary);
|
||||||
|
if (safe || config.commandSafetyMode() == CommandSafetyMode.ALLOW) return;
|
||||||
|
if (config.commandSafetyMode() == CommandSafetyMode.BLOCK) {
|
||||||
|
throw new IllegalStateException("Blocked unsafe command: " + String.join(" ", command));
|
||||||
|
}
|
||||||
|
log.warn("Executing command outside safe registry: {}", String.join(" ", command));
|
||||||
|
}
|
||||||
|
}
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
package dev.relism.ext.webbundler;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
final class CommandTokens {
|
||||||
|
private CommandTokens() {}
|
||||||
|
|
||||||
|
static List<String> split(String command) {
|
||||||
|
return Arrays.stream(command.trim().split("\\s+"))
|
||||||
|
.filter(s -> !s.isBlank())
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
+72
@@ -0,0 +1,72 @@
|
|||||||
|
package dev.relism.ext.webbundler;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
public final class FilesystemAssetsSource implements AssetsSource {
|
||||||
|
private final Path distDir;
|
||||||
|
|
||||||
|
private FilesystemAssetsSource(Path distDir) {
|
||||||
|
this.distDir = distDir.normalize();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static FilesystemAssetsSource of(Path distDir) {
|
||||||
|
if (distDir == null) throw new IllegalArgumentException("distDir cannot be null");
|
||||||
|
return new FilesystemAssetsSource(distDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public AssetCatalog load(AssetLoadRequest request) {
|
||||||
|
Path root = request.webRoot().resolve(distDir).normalize();
|
||||||
|
if (!Files.exists(root)) {
|
||||||
|
throw new IllegalStateException("distDir does not exist: " + root);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, AssetEntryBuilder> builders = new HashMap<>();
|
||||||
|
try (var walk = Files.walk(root)) {
|
||||||
|
walk.filter(Files::isRegularFile).forEach(file -> {
|
||||||
|
String rel = "/" + root.relativize(file).toString().replace('\\', '/');
|
||||||
|
String canonical = AssetIo.stripBrGzSuffix(rel);
|
||||||
|
String routePath = AssetPaths.joinBase(request.basePath(), canonical);
|
||||||
|
AssetEntryBuilder b = builders.computeIfAbsent(routePath, k -> new AssetEntryBuilder(canonical));
|
||||||
|
byte[] bytes = AssetIo.read(file);
|
||||||
|
if (rel.endsWith(".br")) b.br = bytes;
|
||||||
|
else if (rel.endsWith(".gz")) b.gz = bytes;
|
||||||
|
else b.raw = bytes;
|
||||||
|
});
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new IllegalStateException("Failed to preload assets from " + root, e);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, AssetEntry> byRoute = new HashMap<>();
|
||||||
|
for (Map.Entry<String, AssetEntryBuilder> e : builders.entrySet()) {
|
||||||
|
AssetEntryBuilder b = e.getValue();
|
||||||
|
if (b.raw == null) continue;
|
||||||
|
String etag = AssetIo.quotedSha1(b.raw);
|
||||||
|
String mime = MimeTypes.byPath(b.canonicalPath);
|
||||||
|
boolean immutable = AssetIo.isFingerprinted(b.canonicalPath);
|
||||||
|
byRoute.put(e.getKey(), new AssetEntry(b.raw, b.br, b.gz, etag, mime, immutable));
|
||||||
|
}
|
||||||
|
|
||||||
|
String indexRoute = AssetPaths.joinBase(request.basePath(), "/" + request.indexFile());
|
||||||
|
AssetEntry index = byRoute.get(indexRoute);
|
||||||
|
if (index == null) {
|
||||||
|
throw new IllegalStateException("Missing SPA fallback file: " + indexRoute + " (from " + root + ")");
|
||||||
|
}
|
||||||
|
return new AssetCatalog(byRoute, index);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class AssetEntryBuilder {
|
||||||
|
private final String canonicalPath;
|
||||||
|
private byte[] raw;
|
||||||
|
private byte[] br;
|
||||||
|
private byte[] gz;
|
||||||
|
|
||||||
|
private AssetEntryBuilder(String canonicalPath) {
|
||||||
|
this.canonicalPath = canonicalPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
package dev.relism.ext.webbundler;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
interface FrontendStrategy {
|
||||||
|
FrontendType type();
|
||||||
|
List<String> devCommand(WebBundlerConfig config, PackageManagerAdapter adapter);
|
||||||
|
List<String> buildCommand(WebBundlerConfig config, PackageManagerAdapter adapter);
|
||||||
|
default String healthcheckPath() {
|
||||||
|
return "/";
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
package dev.relism.ext.webbundler;
|
||||||
|
|
||||||
|
public enum FrontendType {
|
||||||
|
VITE
|
||||||
|
}
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
package dev.relism.ext.webbundler;
|
||||||
|
|
||||||
|
import java.util.EnumMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
final class FrontendTypeResolver {
|
||||||
|
private final Map<FrontendType, FrontendStrategy> strategies = new EnumMap<>(FrontendType.class);
|
||||||
|
|
||||||
|
FrontendTypeResolver() {
|
||||||
|
register(new ViteFrontendStrategy());
|
||||||
|
}
|
||||||
|
|
||||||
|
void register(FrontendStrategy strategy) {
|
||||||
|
strategies.put(strategy.type(), strategy);
|
||||||
|
}
|
||||||
|
|
||||||
|
FrontendStrategy resolve(FrontendType type) {
|
||||||
|
FrontendStrategy strategy = strategies.get(type);
|
||||||
|
if (strategy == null) {
|
||||||
|
throw new IllegalArgumentException("Unsupported frontend type: " + type);
|
||||||
|
}
|
||||||
|
return strategy;
|
||||||
|
}
|
||||||
|
}
|
||||||
+57
@@ -0,0 +1,57 @@
|
|||||||
|
package dev.relism.ext.webbundler;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
|
||||||
|
final class InstallCache {
|
||||||
|
private final Path cacheFile;
|
||||||
|
|
||||||
|
InstallCache(Path webRoot) {
|
||||||
|
this.cacheFile = webRoot.resolve(".flash-web-bundler/install.hash");
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean lockChanged(Path lockfile) {
|
||||||
|
if (!Files.exists(lockfile)) return true;
|
||||||
|
String current = digest(lockfile);
|
||||||
|
String previous = readCached();
|
||||||
|
return !current.equals(previous);
|
||||||
|
}
|
||||||
|
|
||||||
|
void writeLockHash(Path lockfile) {
|
||||||
|
if (!Files.exists(lockfile)) return;
|
||||||
|
try {
|
||||||
|
Files.createDirectories(cacheFile.getParent());
|
||||||
|
Files.writeString(cacheFile, digest(lockfile), StandardCharsets.UTF_8);
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new IllegalStateException("Failed to write install cache hash", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String readCached() {
|
||||||
|
if (!Files.exists(cacheFile)) return "";
|
||||||
|
try {
|
||||||
|
return Files.readString(cacheFile, StandardCharsets.UTF_8).trim();
|
||||||
|
} catch (IOException e) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String digest(Path file) {
|
||||||
|
try {
|
||||||
|
MessageDigest md = MessageDigest.getInstance("SHA-256");
|
||||||
|
byte[] bytes = Files.readAllBytes(file);
|
||||||
|
byte[] hash = md.digest(bytes);
|
||||||
|
StringBuilder sb = new StringBuilder(hash.length * 2);
|
||||||
|
for (byte b : hash) {
|
||||||
|
sb.append(String.format("%02x", b));
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
} catch (NoSuchAlgorithmException | IOException e) {
|
||||||
|
throw new IllegalStateException("Unable to hash lockfile " + file, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+6
@@ -0,0 +1,6 @@
|
|||||||
|
package dev.relism.ext.webbundler;
|
||||||
|
|
||||||
|
public enum InstallPolicy {
|
||||||
|
AUTO_IF_LOCK_HASH_CHANGED,
|
||||||
|
NEVER
|
||||||
|
}
|
||||||
+8
@@ -0,0 +1,8 @@
|
|||||||
|
package dev.relism.ext.webbundler;
|
||||||
|
|
||||||
|
public enum LoggingMode {
|
||||||
|
MERGED,
|
||||||
|
SEPARATE,
|
||||||
|
QUIET,
|
||||||
|
VERBOSE
|
||||||
|
}
|
||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
package dev.relism.ext.webbundler;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
final class MimeTypes {
|
||||||
|
private static final Map<String, String> EXT = Map.ofEntries(
|
||||||
|
Map.entry("html", "text/html"),
|
||||||
|
Map.entry("js", "text/javascript"),
|
||||||
|
Map.entry("css", "text/css"),
|
||||||
|
Map.entry("json", "application/json"),
|
||||||
|
Map.entry("svg", "image/svg+xml"),
|
||||||
|
Map.entry("png", "image/png"),
|
||||||
|
Map.entry("jpg", "image/jpeg"),
|
||||||
|
Map.entry("jpeg", "image/jpeg"),
|
||||||
|
Map.entry("gif", "image/gif"),
|
||||||
|
Map.entry("ico", "image/x-icon"),
|
||||||
|
Map.entry("woff2", "font/woff2"),
|
||||||
|
Map.entry("ttf", "font/ttf"),
|
||||||
|
Map.entry("txt", "text/plain")
|
||||||
|
);
|
||||||
|
|
||||||
|
private MimeTypes() {}
|
||||||
|
|
||||||
|
static String byPath(String path) {
|
||||||
|
int idx = path.lastIndexOf('.');
|
||||||
|
if (idx < 0 || idx == path.length() - 1) return "application/octet-stream";
|
||||||
|
String ext = path.substring(idx + 1).toLowerCase();
|
||||||
|
return EXT.getOrDefault(ext, "application/octet-stream");
|
||||||
|
}
|
||||||
|
}
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
package dev.relism.ext.webbundler;
|
||||||
|
|
||||||
|
import dev.relism.Flash;
|
||||||
|
|
||||||
|
final class ModeResolver {
|
||||||
|
RuntimeEnvironment resolve(WebBundlerConfig config) {
|
||||||
|
return switch (config.runtimeMode()) {
|
||||||
|
case PROD -> RuntimeEnvironment.PROD;
|
||||||
|
case AUTODETECT -> Flash.DEV ? RuntimeEnvironment.DEV : RuntimeEnvironment.PROD;
|
||||||
|
case ENV -> fromEnvironment(config.envRuntimeVariable());
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private RuntimeEnvironment fromEnvironment(String key) {
|
||||||
|
String value = System.getenv(key);
|
||||||
|
if (value == null || value.isBlank()) return RuntimeEnvironment.PROD;
|
||||||
|
return "dev".equalsIgnoreCase(value) ? RuntimeEnvironment.DEV : RuntimeEnvironment.PROD;
|
||||||
|
}
|
||||||
|
}
|
||||||
+6
@@ -0,0 +1,6 @@
|
|||||||
|
package dev.relism.ext.webbundler;
|
||||||
|
|
||||||
|
public enum OperationMode {
|
||||||
|
ORCHESTRATE_ONLY,
|
||||||
|
MANAGED
|
||||||
|
}
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
package dev.relism.ext.webbundler;
|
||||||
|
|
||||||
|
public enum PackageManager {
|
||||||
|
NPM("npm", "package-lock.json"),
|
||||||
|
PNPM("pnpm", "pnpm-lock.yaml"),
|
||||||
|
YARN("yarn", "yarn.lock"),
|
||||||
|
BUN("bun", "bun.lockb");
|
||||||
|
|
||||||
|
private final String binary;
|
||||||
|
private final String lockfileName;
|
||||||
|
|
||||||
|
PackageManager(String binary, String lockfileName) {
|
||||||
|
this.binary = binary;
|
||||||
|
this.lockfileName = lockfileName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String binary() {
|
||||||
|
return binary;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String lockfileName() {
|
||||||
|
return lockfileName;
|
||||||
|
}
|
||||||
|
}
|
||||||
+44
@@ -0,0 +1,44 @@
|
|||||||
|
package dev.relism.ext.webbundler;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
final class PackageManagerAdapter {
|
||||||
|
private final WebBundlerConfig config;
|
||||||
|
private final boolean windows = System.getProperty("os.name", "").toLowerCase().contains("win");
|
||||||
|
|
||||||
|
PackageManagerAdapter(WebBundlerConfig config) {
|
||||||
|
this.config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> installCommand() {
|
||||||
|
if (config.installCommand() != null) return CommandTokens.split(config.installCommand());
|
||||||
|
return switch (config.packageManager()) {
|
||||||
|
case NPM -> List.of(bin("npm"), "install");
|
||||||
|
case PNPM -> List.of(bin("pnpm"), "install", "--frozen-lockfile");
|
||||||
|
case YARN -> List.of(bin("yarn"), "install", "--frozen-lockfile");
|
||||||
|
case BUN -> List.of(bin("bun"), "install", "--frozen-lockfile");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> devCommand(String host, int port) {
|
||||||
|
return switch (config.packageManager()) {
|
||||||
|
case NPM -> List.of(bin("npm"), "run", "dev", "--", "--host", host, "--port", String.valueOf(port));
|
||||||
|
case PNPM -> List.of(bin("pnpm"), "dev", "--host", host, "--port", String.valueOf(port));
|
||||||
|
case YARN -> List.of(bin("yarn"), "dev", "--host", host, "--port", String.valueOf(port));
|
||||||
|
case BUN -> List.of(bin("bun"), "run", "dev", "--host", host, "--port", String.valueOf(port));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> buildCommand() {
|
||||||
|
return switch (config.packageManager()) {
|
||||||
|
case NPM -> List.of(bin("npm"), "run", "build");
|
||||||
|
case PNPM -> List.of(bin("pnpm"), "build");
|
||||||
|
case YARN -> List.of(bin("yarn"), "build");
|
||||||
|
case BUN -> List.of(bin("bun"), "run", "build");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private String bin(String name) {
|
||||||
|
return windows ? name + ".cmd" : name;
|
||||||
|
}
|
||||||
|
}
|
||||||
+6
@@ -0,0 +1,6 @@
|
|||||||
|
package dev.relism.ext.webbundler;
|
||||||
|
|
||||||
|
enum RuntimeEnvironment {
|
||||||
|
DEV,
|
||||||
|
PROD
|
||||||
|
}
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
package dev.relism.ext.webbundler;
|
||||||
|
|
||||||
|
public enum RuntimeMode {
|
||||||
|
PROD,
|
||||||
|
ENV,
|
||||||
|
AUTODETECT
|
||||||
|
}
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
package dev.relism.ext.webbundler;
|
||||||
|
|
||||||
|
import dev.relism.models.Response;
|
||||||
|
|
||||||
|
final class SpaFallbackPolicy {
|
||||||
|
private final byte[] html;
|
||||||
|
|
||||||
|
SpaFallbackPolicy(AssetCatalog catalog) {
|
||||||
|
this.html = catalog.index().raw();
|
||||||
|
}
|
||||||
|
|
||||||
|
void apply(Response response) {
|
||||||
|
response.type("text/html");
|
||||||
|
response.header("Cache-Control", "no-cache");
|
||||||
|
response.body(html);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user