implement OpenAPI contributor integration for rate limiting and response headers

This commit is contained in:
Relism
2026-04-19 23:42:50 +02:00
parent e161497f2c
commit 34fd74068a
26 changed files with 703 additions and 331 deletions
View File
+5
View File
@@ -13,6 +13,11 @@
<option value="$PROJECT_DIR$/flash-bench/pom.xml" /> <option value="$PROJECT_DIR$/flash-bench/pom.xml" />
</list> </list>
</option> </option>
<option name="ignoredFiles">
<set>
<option value="$PROJECT_DIR$/flash-bench/pom.xml" />
</set>
</option>
</component> </component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="21" project-jdk-type="JavaSDK"> <component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="21" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/out" /> <output url="file://$PROJECT_DIR$/out" />
+76 -109
View File
@@ -5,61 +5,26 @@
</component> </component>
<component name="ChangeListManager"> <component name="ChangeListManager">
<list default="true" id="fc757130-fe3e-4ea9-8d69-c26ffb8545a4" name="Changes" comment="preparing for another refactoring..."> <list default="true" id="fc757130-fe3e-4ea9-8d69-c26ffb8545a4" name="Changes" comment="preparing for another refactoring...">
<change beforePath="$PROJECT_DIR$/.idea/misc.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/misc.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" afterPath="$PROJECT_DIR$/.idea/workspace.xml" 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-limiter/docs/annotation.md" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/docs/annotation.md" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-jackson/pom.xml" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-jackson/pom.xml" afterDir="false" /> <change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/docs/http-headers.md" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/docs/http-headers.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-limiter/pom.xml" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/pom.xml" afterDir="false" /> <change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/pom.xml" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/pom.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/pom.xml" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/pom.xml" 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-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/test/java/dev/relism/ext/oidc/OidcOpenApiInteropTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/ext/oidc/OidcOpenApiInteropTest.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/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/ApiParam.java" beforeDir="false" /> <change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/ApiResponses.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/APIResponses.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/ApiParams.java" beforeDir="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/ApiResponses.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/ApiResponses.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/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-openapi/src/main/java/dev/relism/ext/openapi/OpenApiSecurityContributor.java" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/Schema.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/Schema.java" afterDir="false" /> <change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/OpenApiSecurityRegistry.java" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/SchemaProperty.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/SchemaProperty.java" afterDir="false" /> <change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/test/java/dev/relism/ext/openapi/OpenApiBuilderTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/test/java/dev/relism/ext/openapi/OpenApiBuilderTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/pom.xml" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/pom.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/resources/routeviewer/app.css" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/resources/routeviewer/app.css" 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/resources/routeviewer/index.html" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/resources/routeviewer/index.html" afterDir="false" /> <change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/resources/routeviewer/index.html" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/resources/routeviewer/index.html" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view/pom.xml" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view/pom.xml" afterDir="false" /> <change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view/README.md" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view/README.md" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/EngineCapabilities.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/EngineCapabilities.java" afterDir="false" /> <change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view/docs/migration-from-legacy-view.md" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/LegacyViewEngineAdapter.java" beforeDir="false" /> <change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/test/java/dev/relism/ext/webbundler/WebBundlerExtensionIntegrationTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/test/java/dev/relism/ext/webbundler/WebBundlerExtensionIntegrationTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/Page.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/Page.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/Partial.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/Partial.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/RenderOutput.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/RenderOutput.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/Renderer.java" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/Template.java" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/ThymeleafEngine.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/ThymeleafEngine.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/View.java" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/ViewEngine.java" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/ViewEngineAdapter.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/ViewEngineAdapter.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/ViewEngineType.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/ViewEngineType.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/flash-ext-view/src/main/java/dev/relism/ext/view/ViewGlobals.java" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/ViewHandler.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/ViewHandler.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/ViewKind.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/ViewKind.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/ViewModel.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/ViewModel.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/ViewRuntime.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/ViewRuntime.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/ViewTarget.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/ViewTarget.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/ViewTargetResolver.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/ViewTargetResolver.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view/src/test/java/dev/relism/ext/view/ViewModelTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view/src/test/java/dev/relism/ext/view/ViewModelTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view/src/test/java/dev/relism/ext/view/ViewRuntimeGlobalsTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view/src/test/java/dev/relism/ext/view/ViewRuntimeGlobalsTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view/src/test/java/dev/relism/ext/view/ViewTargetResolverTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view/src/test/java/dev/relism/ext/view/ViewTargetResolverTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/pom.xml" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/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/http/ContentType.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/http/ContentType.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/http/HttpStatus.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/http/HttpStatus.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/test/java/dev/relism/http/ContentTypeTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/http/ContentTypeTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/http/HttpStatusTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/http/HttpStatusTest.java" 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" />
<option name="HIGHLIGHT_CONFLICTS" value="true" /> <option name="HIGHLIGHT_CONFLICTS" value="true" />
@@ -74,7 +39,7 @@
</persistenceIdMap> </persistenceIdMap>
</component> </component>
<component name="EmbeddingIndexingInfo"> <component name="EmbeddingIndexingInfo">
<option name="cachedIndexableFilesCount" value="287" /> <option name="cachedIndexableFilesCount" value="352" />
<option name="fileBasedEmbeddingIndicesEnabled" value="true" /> <option name="fileBasedEmbeddingIndicesEnabled" value="true" />
</component> </component>
<component name="FileTemplateManagerImpl"> <component name="FileTemplateManagerImpl">
@@ -95,12 +60,12 @@
&quot;assignee&quot;: &quot;Relism&quot; &quot;assignee&quot;: &quot;Relism&quot;
} }
}</component> }</component>
<component name="GithubPullRequestsUISettings"><![CDATA[{ <component name="GithubPullRequestsUISettings">{
"selectedUrlAndAccountId": { &quot;selectedUrlAndAccountId&quot;: {
"url": "https://github.com/Relism/Flash5", &quot;url&quot;: &quot;https://github.com/Relism/Flash5.git&quot;,
"accountId": "4bebe1ce-b73c-44c8-9942-79b632bc7632" &quot;accountId&quot;: &quot;86d8a39c-af27-4b79-8d5d-dc74375c3348&quot;
} }
}]]></component> }</component>
<component name="McpProjectServerCommands"> <component name="McpProjectServerCommands">
<commands /> <commands />
<urls /> <urls />
@@ -116,62 +81,62 @@
<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"><![CDATA[{ <component name="PropertiesComponent">{
"keyToString": { &quot;keyToString&quot;: {
"Application.(dev) flash-bench.executor": "Run", &quot;Application.(dev) flash-bench.executor&quot;: &quot;Run&quot;,
"Application.ExternalBenchmark (1).executor": "Run", &quot;Application.ExternalBenchmark (1).executor&quot;: &quot;Run&quot;,
"Application.ExternalBenchmark.executor": "Run", &quot;Application.ExternalBenchmark.executor&quot;: &quot;Run&quot;,
"Application.Main.executor": "Run", &quot;Application.Main.executor&quot;: &quot;Run&quot;,
"Application.MainAlt.executor": "Run", &quot;Application.MainAlt.executor&quot;: &quot;Run&quot;,
"Application.dev.relism.bench.Main.executor": "Run", &quot;Application.dev.relism.bench.Main.executor&quot;: &quot;Run&quot;,
"JUnit.RequestParserTest.executor": "Run", &quot;JUnit.RequestParserTest.executor&quot;: &quot;Run&quot;,
"JUnit.RequestParserTest.headers_caseInsensitive.executor": "Debug", &quot;JUnit.RequestParserTest.headers_caseInsensitive.executor&quot;: &quot;Debug&quot;,
"Maven.FlashPractice [test].executor": "Run", &quot;Maven.FlashPractice [test].executor&quot;: &quot;Run&quot;,
"Maven.flash [compile].executor": "Run", &quot;Maven.flash [compile].executor&quot;: &quot;Run&quot;,
"Maven.flash [install].executor": "Run", &quot;Maven.flash [install].executor&quot;: &quot;Run&quot;,
"Maven.flash [test].executor": "Run", &quot;Maven.flash [test].executor&quot;: &quot;Run&quot;,
"Maven.flash [verify].executor": "Run", &quot;Maven.flash [verify].executor&quot;: &quot;Run&quot;,
"Maven.flash-bench [clean].executor": "Run", &quot;Maven.flash-bench [clean].executor&quot;: &quot;Run&quot;,
"Maven.flash-bench [install].executor": "Run", &quot;Maven.flash-bench [install].executor&quot;: &quot;Run&quot;,
"Maven.flash-bench [package].executor": "Run", &quot;Maven.flash-bench [package].executor&quot;: &quot;Run&quot;,
"Maven.flash-bench [validate].executor": "Run", &quot;Maven.flash-bench [validate].executor&quot;: &quot;Run&quot;,
"Maven.flash-ext-limiter [install].executor": "Run", &quot;Maven.flash-ext-limiter [install].executor&quot;: &quot;Run&quot;,
"Maven.flash-ext-limiter [package].executor": "Run", &quot;Maven.flash-ext-limiter [package].executor&quot;: &quot;Run&quot;,
"Maven.flash-ext-view [verify].executor": "Run", &quot;Maven.flash-ext-view [verify].executor&quot;: &quot;Run&quot;,
"Maven.flash-parent [clean].executor": "Run", &quot;Maven.flash-parent [clean].executor&quot;: &quot;Run&quot;,
"Maven.flash-parent [compile].executor": "Run", &quot;Maven.flash-parent [compile].executor&quot;: &quot;Run&quot;,
"Maven.flash-parent [deploy].executor": "Run", &quot;Maven.flash-parent [deploy].executor&quot;: &quot;Run&quot;,
"Maven.flash-parent [install].executor": "Run", &quot;Maven.flash-parent [install].executor&quot;: &quot;Run&quot;,
"Maven.flash-parent [package].executor": "Run", &quot;Maven.flash-parent [package].executor&quot;: &quot;Run&quot;,
"Maven.flash-parent [verify].executor": "Run", &quot;Maven.flash-parent [verify].executor&quot;: &quot;Run&quot;,
"Maven.flash-web-bundler [test].executor": "Run", &quot;Maven.flash-web-bundler [test].executor&quot;: &quot;Run&quot;,
"ModuleVcsDetector.initialDetectionPerformed": "true", &quot;ModuleVcsDetector.initialDetectionPerformed&quot;: &quot;true&quot;,
"RunOnceActivity.MCP Project settings loaded": "true", &quot;RunOnceActivity.MCP Project settings loaded&quot;: &quot;true&quot;,
"RunOnceActivity.ShowReadmeOnStart": "true", &quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;,
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true", &quot;RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252&quot;: &quot;true&quot;,
"RunOnceActivity.git.unshallow": "true", &quot;RunOnceActivity.git.unshallow&quot;: &quot;true&quot;,
"RunOnceActivity.typescript.service.memoryLimit.init": "true", &quot;RunOnceActivity.typescript.service.memoryLimit.init&quot;: &quot;true&quot;,
"SHARE_PROJECT_CONFIGURATION_FILES": "true", &quot;SHARE_PROJECT_CONFIGURATION_FILES&quot;: &quot;true&quot;,
"codeWithMe.voiceChat.enabledByDefault": "false", &quot;codeWithMe.voiceChat.enabledByDefault&quot;: &quot;false&quot;,
"git-widget-placeholder": "master", &quot;git-widget-placeholder&quot;: &quot;master&quot;,
"ignore.virus.scanning.warn.message": "true", &quot;ignore.virus.scanning.warn.message&quot;: &quot;true&quot;,
"kotlin-language-version-configured": "true", &quot;kotlin-language-version-configured&quot;: &quot;true&quot;,
"last_opened_file_path": "C:/Users/elorc/Documents/Coding/Java/practice/Flash", &quot;last_opened_file_path&quot;: &quot;C:/Users/elorc/Documents/Coding/Java/practice/Flash&quot;,
"node.js.detected.package.eslint": "true", &quot;node.js.detected.package.eslint&quot;: &quot;true&quot;,
"node.js.detected.package.tslint": "true", &quot;node.js.detected.package.tslint&quot;: &quot;true&quot;,
"node.js.selected.package.eslint": "(autodetect)", &quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;,
"node.js.selected.package.tslint": "(autodetect)", &quot;node.js.selected.package.tslint&quot;: &quot;(autodetect)&quot;,
"nodejs_package_manager_path": "npm", &quot;nodejs_package_manager_path&quot;: &quot;npm&quot;,
"npm.build.executor": "Run", &quot;npm.build.executor&quot;: &quot;Run&quot;,
"onboarding.tips.debug.path": "C:/Users/elorc/Documents/Coding/Java/practice/FlashPractice/flash-bench/src/main/java/dev/relism/Main.java", &quot;onboarding.tips.debug.path&quot;: &quot;C:/Users/elorc/Documents/Coding/Java/practice/FlashPractice/flash-bench/src/main/java/dev/relism/Main.java&quot;,
"project.structure.last.edited": "Modules", &quot;project.structure.last.edited&quot;: &quot;Modules&quot;,
"project.structure.proportion": "0.15", &quot;project.structure.proportion&quot;: &quot;0.15&quot;,
"project.structure.side.proportion": "0.1150748", &quot;project.structure.side.proportion&quot;: &quot;0.1150748&quot;,
"settings.editor.selected.configurable": "project.propVCSSupport.DirectoryMappings", &quot;settings.editor.selected.configurable&quot;: &quot;project.propVCSSupport.DirectoryMappings&quot;,
"ts.external.directory.path": "C:\\Users\\elorc\\Documents\\Coding\\Java\\practice\\Flash\\nuxt-shadcn-dashboard\\node_modules\\typescript\\lib", &quot;ts.external.directory.path&quot;: &quot;C:\\Users\\elorc\\Documents\\Coding\\Java\\practice\\Flash\\nuxt-shadcn-dashboard\\node_modules\\typescript\\lib&quot;,
"vue.rearranger.settings.migration": "true" &quot;vue.rearranger.settings.migration&quot;: &quot;true&quot;
} }
}]]></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" />
@@ -299,6 +264,8 @@
<workItem from="1776198526994" duration="4187000" /> <workItem from="1776198526994" duration="4187000" />
<workItem from="1776240695245" duration="14368000" /> <workItem from="1776240695245" duration="14368000" />
<workItem from="1776410282788" duration="22340000" /> <workItem from="1776410282788" duration="22340000" />
<workItem from="1776515579524" duration="1172000" />
<workItem from="1776626614705" duration="3750000" />
</task> </task>
<task id="LOCAL-00001" summary="Initial"> <task id="LOCAL-00001" summary="Initial">
<option name="closed" value="true" /> <option name="closed" value="true" />
@@ -92,6 +92,9 @@ public class PriceHandler extends RequestHandler { ... }
Each annotation is processed by its own processor; Flash collects all middleware and Each annotation is processed by its own processor; Flash collects all middleware and
composes them in processor registration order. composes them in processor registration order.
When `flash-ext-openapi` is installed, `@Limit` also contributes OpenAPI response
headers (`X-RateLimit-*`) and `Retry-After` on `429` automatically.
```java ```java
@Route(method = HttpMethod.DELETE, path = "/admin/users/{id}") @Route(method = HttpMethod.DELETE, path = "/admin/users/{id}")
@Limit(key = "auth_user", requests = 5, window = 1, windowUnit = TimeUnit.MINUTES) @Limit(key = "auth_user", requests = 5, window = 1, windowUnit = TimeUnit.MINUTES)
@@ -117,6 +117,12 @@ and **instead of** calling it on rejected requests. This means:
## Integration with Swagger UI (flash-ext-openapi) ## Integration with Swagger UI (flash-ext-openapi)
Rate-limit headers are not currently injected into the OpenAPI spec. If you want to When `flash-ext-openapi` is installed, handlers annotated with `@Limit` automatically
document them, add them manually via `@ApiOperation` on the handler class using the contribute rate-limit response headers to generated OpenAPI responses:
response headers section of the OpenAPI spec.
- `X-RateLimit-Limit`
- `X-RateLimit-Remaining`
- `X-RateLimit-Reset`
- `Retry-After` on `429`
If `429` is not manually declared, OpenAPI auto-adds `429 Too Many Requests`.
@@ -17,6 +17,11 @@
<groupId>dev.relism</groupId> <groupId>dev.relism</groupId>
<artifactId>flash</artifactId> <artifactId>flash</artifactId>
</dependency> </dependency>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-ext-openapi</artifactId>
<optional>true</optional>
</dependency>
<dependency> <dependency>
<groupId>org.projectlombok</groupId> <groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId> <artifactId>lombok</artifactId>
@@ -1,13 +1,18 @@
package dev.relism.ext.limiter; package dev.relism.ext.limiter;
import dev.relism.ext.openapi.OpenApiContributorRegistry;
import dev.relism.ext.openapi.OpenApiOperationContribution;
import dev.relism.ext.openapi.OpenApiResponseContribution;
import dev.relism.extension.ExtensionPhase; 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.nio.charset.StandardCharsets;
import java.util.List; import java.util.List;
import java.util.Map;
/** /**
* Rate-limiting extension for Flash. * Rate-limiting extension for Flash.
@@ -87,6 +92,15 @@ public final class LimiterExtension implements FlashExtension {
}); });
} }
@Override
public void routes(FlashRegistrar<?> app, FlashContext ctx) {
try {
OpenApiIntegration.register(ctx);
} catch (NoClassDefFoundError ignored) {
// flash-ext-openapi not available — OpenAPI integration disabled
}
}
// ── Package-private helper — shared with Guard ──────────────────────────── // ── Package-private helper — shared with Guard ────────────────────────────
/** /**
@@ -126,4 +140,57 @@ public final class LimiterExtension implements FlashExtension {
return next.handle(req, res); return next.handle(req, res);
}; };
} }
private static final class OpenApiIntegration {
private static final Map<String, Object> INTEGER_SCHEMA = Map.of("type", "integer");
private static final Map<String, Object> LIMIT_HEADER = Map.of(
"description", "Maximum requests allowed in current window",
"schema", INTEGER_SCHEMA
);
private static final Map<String, Object> REMAINING_HEADER = Map.of(
"description", "Requests remaining in current window",
"schema", INTEGER_SCHEMA
);
private static final Map<String, Object> RESET_HEADER = Map.of(
"description", "Unix epoch seconds when quota resets or next token arrives",
"schema", INTEGER_SCHEMA
);
private static final Map<String, Object> RETRY_AFTER_HEADER = Map.of(
"description", "Seconds to wait before retrying",
"schema", INTEGER_SCHEMA
);
static void register(FlashContext ctx) {
ctx.find(OpenApiContributorRegistry.class)
.ifPresent(registry -> registry.add(new dev.relism.ext.openapi.OpenApiContributor() {
@Override
public dev.relism.ext.openapi.OpenApiOperationContribution operationFor(Class<?> handlerClass) {
if (handlerClass.getAnnotation(Limit.class) == null) {
return OpenApiOperationContribution.builder().build();
}
OpenApiResponseContribution common =
OpenApiResponseContribution.builder()
.header("X-RateLimit-Limit", LIMIT_HEADER)
.header("X-RateLimit-Remaining", REMAINING_HEADER)
.header("X-RateLimit-Reset", RESET_HEADER)
.build();
OpenApiResponseContribution tooManyRequests =
OpenApiResponseContribution.builder()
.description("Too Many Requests")
.header("X-RateLimit-Limit", LIMIT_HEADER)
.header("X-RateLimit-Remaining", REMAINING_HEADER)
.header("X-RateLimit-Reset", RESET_HEADER)
.header("Retry-After", RETRY_AFTER_HEADER)
.build();
return OpenApiOperationContribution.builder()
.allResponses(common)
.response(429, tooManyRequests)
.build();
}
}));
}
}
} }
@@ -0,0 +1,84 @@
package dev.relism.ext.limiter;
import dev.relism.ext.openapi.OpenApiContributor;
import dev.relism.ext.openapi.OpenApiContributorRegistry;
import dev.relism.ext.openapi.OpenApiOperationContribution;
import dev.relism.ext.openapi.OpenApiResponseContribution;
import dev.relism.extension.FlashContext;
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.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
class LimiterOpenApiInteropTest {
@GET("/limited")
@Limit(requests = 10, window = 1)
static class LimitedHandler extends RequestHandler {
@Override
public Object handle(Request request, Response response) {
return null;
}
}
@GET("/plain")
static class PlainHandler extends RequestHandler {
@Override
public Object handle(Request request, Response response) {
return null;
}
}
@Test
void registersContributor_whenOpenApiRegistryExists() {
FlashContext ctx = new FlashContext();
OpenApiContributorRegistry registry = new OpenApiContributorRegistry();
ctx.provide(OpenApiContributorRegistry.class, registry);
new LimiterExtension().routes(null, ctx);
assertEquals(1, registry.contributors().size());
}
@Test
void limitedHandler_contributesHeadersAnd429() {
FlashContext ctx = new FlashContext();
OpenApiContributorRegistry registry = new OpenApiContributorRegistry();
ctx.provide(OpenApiContributorRegistry.class, registry);
new LimiterExtension().routes(null, ctx);
OpenApiContributor contributor = registry.contributors().getFirst();
OpenApiOperationContribution operation = contributor.operationFor(LimitedHandler.class);
OpenApiResponseContribution all = operation.allResponses();
assertNotNull(all);
assertTrue(all.headers().containsKey("X-RateLimit-Limit"));
assertTrue(all.headers().containsKey("X-RateLimit-Remaining"));
assertTrue(all.headers().containsKey("X-RateLimit-Reset"));
OpenApiResponseContribution tooMany = operation.responses().get(429);
assertNotNull(tooMany);
assertEquals("Too Many Requests", tooMany.description());
assertTrue(tooMany.headers().containsKey("Retry-After"));
}
@Test
void plainHandler_hasNoContribution() {
FlashContext ctx = new FlashContext();
OpenApiContributorRegistry registry = new OpenApiContributorRegistry();
ctx.provide(OpenApiContributorRegistry.class, registry);
new LimiterExtension().routes(null, ctx);
OpenApiContributor contributor = registry.contributors().getFirst();
OpenApiOperationContribution operation = contributor.operationFor(PlainHandler.class);
assertTrue(operation.isEmpty());
assertFalse(operation.responses().containsKey(429));
}
}
@@ -263,19 +263,16 @@ 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.OpenApiContributorRegistry} is actually on the classpath.
*/ */
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,
OidcConfig config, OidcProviderMetadata meta) { OidcConfig config, OidcProviderMetadata meta) {
ctx.find(dev.relism.ext.openapi.OpenApiSecurityRegistry.class) ctx.find(dev.relism.ext.openapi.OpenApiContributorRegistry.class)
.ifPresent(registry -> registry.add(new dev.relism.ext.openapi.OpenApiSecurityContributor() { .ifPresent(registry -> registry.add(new dev.relism.ext.openapi.OpenApiContributor() {
@Override @Override
public String schemeName() { return config.schemeName(); } public java.util.Map<String, Object> componentContributions() {
@Override
public java.util.Map<String, Object> schemeDefinition() {
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);
@@ -288,36 +285,41 @@ public class OidcExtension implements FlashExtension {
java.util.Map<String, Object> scheme = new java.util.LinkedHashMap<>(); java.util.Map<String, Object> scheme = new java.util.LinkedHashMap<>();
scheme.put("type", "oauth2"); scheme.put("type", "oauth2");
scheme.put("flows", java.util.Map.of("authorizationCode", flow)); scheme.put("flows", java.util.Map.of("authorizationCode", flow));
return scheme;
java.util.Map<String, Object> securitySchemes = new java.util.LinkedHashMap<>();
securitySchemes.put(config.schemeName(), scheme);
return java.util.Map.of("securitySchemes", securitySchemes);
} }
@Override @Override
public java.util.List<String> requiredFor(Class<?> handlerClass) { public dev.relism.ext.openapi.OpenApiOperationContribution operationFor(Class<?> handlerClass) {
return OidcAuthPolicy.openApiScopesFor(handlerClass); dev.relism.ext.openapi.OpenApiOperationContribution.Builder out =
dev.relism.ext.openapi.OpenApiOperationContribution.builder();
java.util.List<String> operationScopes = OidcAuthPolicy.openApiScopesFor(handlerClass);
if (operationScopes != null) {
out.security(config.schemeName(), operationScopes);
} }
@Override
public java.util.Map<Integer, String> autoResponsesFor(Class<?> handlerClass) {
OidcAuthPolicy policy = OidcAuthPolicy.compileFromAnnotations(handlerClass); OidcAuthPolicy policy = OidcAuthPolicy.compileFromAnnotations(handlerClass);
if (policy == null || policy.optionalAuth()) return java.util.Map.of(); if (policy == null || policy.optionalAuth()) return out.build();
java.util.LinkedHashMap<Integer, String> out = new java.util.LinkedHashMap<>(); out.response(401, dev.relism.ext.openapi.OpenApiResponseContribution.of("Authentication required"));
out.put(401, "Authentication required");
String[] roles = policy.requiredRoles(); String[] roles = policy.requiredRoles();
String[] scopes = policy.requiredScopes(); String[] scopes = policy.requiredScopes();
if (roles.length == 0 && scopes.length == 0) return out; if (roles.length == 0 && scopes.length == 0) return out.build();
String roleMessage = roles.length == 0 ? null : roleRequiredMessage(roles); String roleMessage = roles.length == 0 ? null : roleRequiredMessage(roles);
String scopeMessage = scopes.length == 0 ? null : scopeRequiredMessage(scopes); String scopeMessage = scopes.length == 0 ? null : scopeRequiredMessage(scopes);
if (roleMessage != null && scopeMessage != null) { if (roleMessage != null && scopeMessage != null) {
out.put(403, roleMessage + "; " + scopeMessage); out.response(403, dev.relism.ext.openapi.OpenApiResponseContribution.of(roleMessage + "; " + scopeMessage));
} else if (roleMessage != null) { } else if (roleMessage != null) {
out.put(403, roleMessage); out.response(403, dev.relism.ext.openapi.OpenApiResponseContribution.of(roleMessage));
} else { } else {
out.put(403, scopeMessage); out.response(403, dev.relism.ext.openapi.OpenApiResponseContribution.of(scopeMessage));
} }
return out; return out.build();
} }
})); }));
} }
@@ -1,10 +1,11 @@
package dev.relism.ext.oidc; package dev.relism.ext.oidc;
import dev.relism.ext.openapi.OpenApiSecurityContributor; import dev.relism.ext.openapi.OpenApiContributor;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import java.lang.reflect.Constructor; import java.lang.reflect.Constructor;
import java.lang.reflect.Method; import java.lang.reflect.Method;
import java.util.List;
import java.util.Map; import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -37,49 +38,57 @@ class OidcOpenApiInteropTest {
@Test @Test
void autoResponses_authOnly() throws Exception { void autoResponses_authOnly() throws Exception {
Map<Integer, String> responses = contributor().autoResponsesFor(AuthOnly.class); Map<Integer, String> responses = responses(AuthOnly.class);
assertEquals("Authentication required", responses.get(401)); assertEquals("Authentication required", responses.get(401));
assertFalse(responses.containsKey(403)); assertFalse(responses.containsKey(403));
} }
@Test @Test
void autoResponses_optionalAuth_addsNothing() throws Exception { void autoResponses_optionalAuth_addsNothing() throws Exception {
Map<Integer, String> responses = contributor().autoResponsesFor(AuthOptional.class); Map<Integer, String> responses = responses(AuthOptional.class);
assertTrue(responses.isEmpty()); assertTrue(responses.isEmpty());
} }
@Test @Test
void autoResponses_oneRole_formatsSingular() throws Exception { void autoResponses_oneRole_formatsSingular() throws Exception {
Map<Integer, String> responses = contributor().autoResponsesFor(OneRole.class); Map<Integer, String> responses = responses(OneRole.class);
assertEquals("Authentication required", responses.get(401)); assertEquals("Authentication required", responses.get(401));
assertEquals("\"admin\" role required", responses.get(403)); assertEquals("\"admin\" role required", responses.get(403));
} }
@Test @Test
void autoResponses_multiRoles_formatsPlural() throws Exception { void autoResponses_multiRoles_formatsPlural() throws Exception {
Map<Integer, String> responses = contributor().autoResponsesFor(MultiRole.class); Map<Integer, String> responses = responses(MultiRole.class);
assertEquals("Roles \"admin, operator\" are required", responses.get(403)); assertEquals("Roles \"admin, operator\" are required", responses.get(403));
} }
@Test @Test
void autoResponses_oneScope_formatsSingular() throws Exception { void autoResponses_oneScope_formatsSingular() throws Exception {
Map<Integer, String> responses = contributor().autoResponsesFor(OneScope.class); Map<Integer, String> responses = responses(OneScope.class);
assertEquals("\"orders:write\" scope required", responses.get(403)); assertEquals("\"orders:write\" scope required", responses.get(403));
} }
@Test @Test
void autoResponses_multiScopes_formatsPlural() throws Exception { void autoResponses_multiScopes_formatsPlural() throws Exception {
Map<Integer, String> responses = contributor().autoResponsesFor(MultiScope.class); Map<Integer, String> responses = responses(MultiScope.class);
assertEquals("Scopes \"orders:write, payments:write\" are required", responses.get(403)); assertEquals("Scopes \"orders:write, payments:write\" are required", responses.get(403));
} }
@Test @Test
void autoResponses_roleAndScope_combinesMessages() throws Exception { void autoResponses_roleAndScope_combinesMessages() throws Exception {
Map<Integer, String> responses = contributor().autoResponsesFor(RoleAndScope.class); Map<Integer, String> responses = responses(RoleAndScope.class);
assertEquals("\"admin\" role required; \"orders:write\" scope required", responses.get(403)); assertEquals("\"admin\" role required; \"orders:write\" scope required", responses.get(403));
} }
private static OpenApiSecurityContributor contributor() throws Exception { @Test
void securityContribution_presentForAuthenticatedHandler() throws Exception {
dev.relism.ext.openapi.OpenApiOperationContribution operation = contributor().operationFor(AuthOnly.class);
List<Map<String, List<String>>> security = operation.security();
assertEquals(1, security.size());
assertTrue(security.getFirst().containsKey("issuer"));
}
private static OpenApiContributor contributor() throws Exception {
Class<?> clazz = Class.forName("dev.relism.ext.oidc.OidcExtension$OpenApiIntegration"); Class<?> clazz = Class.forName("dev.relism.ext.oidc.OidcExtension$OpenApiIntegration");
Constructor<?> ctor = clazz.getDeclaredConstructor(); Constructor<?> ctor = clazz.getDeclaredConstructor();
ctor.setAccessible(true); ctor.setAccessible(true);
@@ -89,8 +98,8 @@ class OidcOpenApiInteropTest {
m.setAccessible(true); m.setAccessible(true);
dev.relism.extension.FlashContext ctx = new dev.relism.extension.FlashContext(); dev.relism.extension.FlashContext ctx = new dev.relism.extension.FlashContext();
dev.relism.ext.openapi.OpenApiSecurityRegistry registry = new dev.relism.ext.openapi.OpenApiSecurityRegistry(); dev.relism.ext.openapi.OpenApiContributorRegistry registry = new dev.relism.ext.openapi.OpenApiContributorRegistry();
ctx.provide(dev.relism.ext.openapi.OpenApiSecurityRegistry.class, registry); ctx.provide(dev.relism.ext.openapi.OpenApiContributorRegistry.class, registry);
OidcConfig config = OidcConfig.builder("https://issuer", "c", "s", "/cb").build(); OidcConfig config = OidcConfig.builder("https://issuer", "c", "s", "/cb").build();
OidcProviderMetadata meta = new OidcProviderMetadata("a", "t", "u", "j", "e"); OidcProviderMetadata meta = new OidcProviderMetadata("a", "t", "u", "j", "e");
@@ -98,4 +107,13 @@ class OidcOpenApiInteropTest {
return registry.contributors().getFirst(); return registry.contributors().getFirst();
} }
private static Map<Integer, String> responses(Class<?> cls) throws Exception {
Map<Integer, dev.relism.ext.openapi.OpenApiResponseContribution> byCode = contributor().operationFor(cls).responses();
java.util.LinkedHashMap<Integer, String> out = new java.util.LinkedHashMap<>();
for (Map.Entry<Integer, dev.relism.ext.openapi.OpenApiResponseContribution> e : byCode.entrySet()) {
out.put(e.getKey(), e.getValue().description());
}
return out;
}
} }
@@ -105,6 +105,22 @@ Supported field-level exclusion:
- `@JsonIgnoreProperties(...)` - `@JsonIgnoreProperties(...)`
- `transient` / `static` - `transient` / `static`
## Contributor API
OpenAPI is extension-agnostic. Other extensions contribute with `OpenApiContributor` via
`OpenApiContributorRegistry`.
Supported contribution surfaces:
- `components` fragments (merged with last-wins)
- operation `security` requirements (additive)
- operation `responses` and response `headers` (additive)
Merge policy:
- contributor collisions use **last-wins**
- manual `@APIResponse` description always wins over contributors for the same status
## OIDC interop ## OIDC interop
When `flash-ext-oidc` is installed, OpenAPI integrates automatically: When `flash-ext-oidc` is installed, OpenAPI integrates automatically:
@@ -117,6 +133,18 @@ When `flash-ext-oidc` is installed, OpenAPI integrates automatically:
Manual `@APIResponse` for the same status code always wins. Manual `@APIResponse` for the same status code always wins.
## Limiter interop
When `flash-ext-limiter` is installed, handlers with `@Limit` automatically get response
headers documented in OpenAPI:
- `X-RateLimit-Limit`
- `X-RateLimit-Remaining`
- `X-RateLimit-Reset`
- `Retry-After` on `429`
If `429` is missing, it is auto-added as `Too Many Requests`.
## Notes ## Notes
- Operations are collected from final boot-time routes for class-based handlers with `@ApiOperation`. - Operations are collected from final boot-time routes for class-based handlers with `@ApiOperation`.
@@ -51,7 +51,7 @@ public final 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 final SchemaRegistry schemas = new SchemaRegistry();
private OpenApiSecurityRegistry securityRegistry; private OpenApiContributorRegistry contributorRegistry;
private int revision; private int revision;
private int builtRevision = -1; private int builtRevision = -1;
private Map<String, Object> cachedSpec; private Map<String, Object> cachedSpec;
@@ -59,7 +59,7 @@ public final class OpenApiBuilder {
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 setContributorRegistry(OpenApiContributorRegistry registry) { this.contributorRegistry = registry; }
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());
@@ -90,8 +90,8 @@ public final class OpenApiBuilder {
info.put("version", version); info.put("version", version);
if (!description.isEmpty()) info.put("description", description); if (!description.isEmpty()) info.put("description", description);
List<OpenApiSecurityContributor> contributors = securityRegistry != null List<OpenApiContributor> contributors = contributorRegistry != null
? securityRegistry.contributors() : List.of(); ? contributorRegistry.contributors() : List.of();
Map<String, Object> renderedPaths = new LinkedHashMap<>(); Map<String, Object> renderedPaths = new LinkedHashMap<>();
for (var pathEntry : paths.entrySet()) { for (var pathEntry : paths.entrySet()) {
@@ -103,8 +103,7 @@ public final class OpenApiBuilder {
Map<String, Object> op = new LinkedHashMap<>(original); 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); applyContributorOperation(op, handler, contributors);
if (!security.isEmpty()) op.put("security", security);
} }
renderedPathItem.put(methodEntry.getKey(), op); renderedPathItem.put(methodEntry.getKey(), op);
} }
@@ -119,11 +118,7 @@ public final class OpenApiBuilder {
Map<String, Object> components = new LinkedHashMap<>(); Map<String, Object> components = new LinkedHashMap<>();
Map<String, Object> renderedSchemas = schemas.render(); Map<String, Object> renderedSchemas = schemas.render();
if (!renderedSchemas.isEmpty()) components.put("schemas", renderedSchemas); if (!renderedSchemas.isEmpty()) components.put("schemas", renderedSchemas);
if (!contributors.isEmpty()) { if (!contributors.isEmpty()) applyContributorComponents(components, contributors);
Map<String, Object> securitySchemes = new LinkedHashMap<>();
for (OpenApiSecurityContributor c : contributors) securitySchemes.put(c.schemeName(), c.schemeDefinition());
components.put("securitySchemes", securitySchemes);
}
if (!components.isEmpty()) spec.put("components", components); if (!components.isEmpty()) spec.put("components", components);
cachedSpec = spec; cachedSpec = spec;
@@ -181,16 +176,12 @@ public final class OpenApiBuilder {
responseByCode.put(code, buildAnnotatedResponse(code, ann, cls)); responseByCode.put(code, buildAnnotatedResponse(code, ann, cls));
} }
for (OpenApiSecurityContributor c : securityContributors()) {
for (var auto : c.autoResponsesFor(cls).entrySet()) {
responseByCode.putIfAbsent(auto.getKey(), Map.of("description", auto.getValue()));
}
}
if (responseByCode.isEmpty()) { if (responseByCode.isEmpty()) {
responseByCode.put(200, Map.of("description", "OK")); responseByCode.put(200, Map.of("description", "OK"));
} }
applyContributorResponses(responseByCode, cls);
Map<String, Object> responses = new LinkedHashMap<>(); Map<String, Object> responses = new LinkedHashMap<>();
responseByCode.entrySet().stream() responseByCode.entrySet().stream()
.sorted(Map.Entry.comparingByKey(Comparator.naturalOrder())) .sorted(Map.Entry.comparingByKey(Comparator.naturalOrder()))
@@ -198,8 +189,97 @@ public final class OpenApiBuilder {
op.put("responses", responses); op.put("responses", responses);
} }
private List<OpenApiSecurityContributor> securityContributors() { private List<OpenApiContributor> contributors() {
return securityRegistry != null ? securityRegistry.contributors() : List.of(); return contributorRegistry != null ? contributorRegistry.contributors() : List.of();
}
private void applyContributorResponses(Map<Integer, Map<String, Object>> responseByCode, Class<?> handlerClass) {
APIResponse[] manual = handlerClass.getAnnotationsByType(APIResponse.class);
Set<Integer> manualStatusCodes = new HashSet<>();
for (APIResponse ann : manual) {
manualStatusCodes.add(parseStatus(ann.responseCode()));
}
for (OpenApiContributor contributor : contributors()) {
OpenApiOperationContribution contribution = contributor.operationFor(handlerClass);
if (contribution == null) continue;
for (Map.Entry<Integer, OpenApiResponseContribution> entry : contribution.responses().entrySet()) {
int status = entry.getKey();
OpenApiResponseContribution responseContribution = entry.getValue();
if (responseContribution == null) continue;
Map<String, Object> response = responseByCode.computeIfAbsent(status, __ -> new LinkedHashMap<>());
mergeContributorResponse(response, responseContribution, status, manualStatusCodes);
}
OpenApiResponseContribution allResponses = contribution.allResponses();
if (allResponses != null) {
for (Map.Entry<Integer, Map<String, Object>> entry : responseByCode.entrySet()) {
mergeContributorResponse(entry.getValue(), allResponses, entry.getKey(), manualStatusCodes);
}
}
}
}
private static void mergeContributorResponse(Map<String, Object> response,
OpenApiResponseContribution contribution,
int status,
Set<Integer> manualStatusCodes) {
String desc = contribution.description();
if (!manualStatusCodes.contains(status) && desc != null && !desc.isBlank()) {
response.put("description", desc);
}
Map<String, Map<String, Object>> headerContributions = contribution.headers();
if (!headerContributions.isEmpty()) {
@SuppressWarnings("unchecked")
Map<String, Object> headers = (Map<String, Object>) response.computeIfAbsent("headers", __ -> new LinkedHashMap<>());
for (Map.Entry<String, Map<String, Object>> h : headerContributions.entrySet()) {
headers.put(h.getKey(), new LinkedHashMap<>(h.getValue()));
}
}
if (!response.containsKey("description")) {
response.put("description", defaultDescription(status));
}
}
private static void applyContributorComponents(Map<String, Object> components, List<OpenApiContributor> contributors) {
for (OpenApiContributor contributor : contributors) {
Map<String, Object> c = contributor.componentContributions();
if (c == null || c.isEmpty()) continue;
deepMergeLastWins(components, c);
}
}
@SuppressWarnings("unchecked")
private static void deepMergeLastWins(Map<String, Object> target, Map<String, Object> incoming) {
for (Map.Entry<String, Object> e : incoming.entrySet()) {
Object existing = target.get(e.getKey());
Object value = e.getValue();
if (existing instanceof Map<?, ?> em && value instanceof Map<?, ?> vm) {
Map<String, Object> merged = new LinkedHashMap<>((Map<String, Object>) em);
deepMergeLastWins(merged, (Map<String, Object>) vm);
target.put(e.getKey(), merged);
} else {
target.put(e.getKey(), value);
}
}
}
private void applyContributorOperation(Map<String, Object> op, Class<?> handlerClass, List<OpenApiContributor> contributors) {
for (OpenApiContributor contributor : contributors) {
OpenApiOperationContribution contribution = contributor.operationFor(handlerClass);
if (contribution == null || contribution.isEmpty()) continue;
if (!contribution.security().isEmpty()) {
@SuppressWarnings("unchecked")
List<Map<String, List<String>>> security = (List<Map<String, List<String>>>) op
.computeIfAbsent("security", __ -> new ArrayList<>());
security.addAll(contribution.security());
}
}
} }
private Map<String, Object> buildAnnotatedResponse(int code, APIResponse ann, Class<?> handlerClass) { private Map<String, Object> buildAnnotatedResponse(int code, APIResponse ann, Class<?> handlerClass) {
@@ -272,16 +352,6 @@ public final class OpenApiBuilder {
return reason == null ? "" : reason; return reason == null ? "" : reason;
} }
private static List<Map<String, List<String>>> buildOperationSecurity(List<OpenApiSecurityContributor> contributors,
Class<?> handlerClass) {
List<Map<String, List<String>>> security = new ArrayList<>();
for (OpenApiSecurityContributor c : contributors) {
List<String> scopes = c.requiredFor(handlerClass);
if (scopes != null) security.add(Map.of(c.schemeName(), scopes));
}
return security;
}
private static Class<?> rawType(Type type) { private static Class<?> rawType(Type type) {
if (type instanceof Class<?> c) return c; if (type instanceof Class<?> c) return c;
if (type instanceof ParameterizedType p && p.getRawType() instanceof Class<?> c) return c; if (type instanceof ParameterizedType p && p.getRawType() instanceof Class<?> c) return c;
@@ -0,0 +1,14 @@
package dev.relism.ext.openapi;
import java.util.Map;
public interface OpenApiContributor {
default Map<String, Object> componentContributions() {
return Map.of();
}
default OpenApiOperationContribution operationFor(Class<?> handlerClass) {
return OpenApiOperationContribution.empty();
}
}
@@ -0,0 +1,17 @@
package dev.relism.ext.openapi;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
public final class OpenApiContributorRegistry {
private final List<OpenApiContributor> contributors = new CopyOnWriteArrayList<>();
public void add(OpenApiContributor contributor) {
contributors.add(contributor);
}
public List<OpenApiContributor> contributors() {
return Collections.unmodifiableList(contributors);
}
}
@@ -66,11 +66,11 @@ public class OpenApiExtension implements FlashExtension {
@Override @Override
public void provide(FlashContext ctx) { public void provide(FlashContext ctx) {
OpenApiBuilder builder = new OpenApiBuilder().title(title).version(version).description(description); OpenApiBuilder builder = new OpenApiBuilder().title(title).version(version).description(description);
OpenApiSecurityRegistry secRegistry = new OpenApiSecurityRegistry(); OpenApiContributorRegistry registry = new OpenApiContributorRegistry();
ctx.provide(OpenApiSecurityRegistry.class, secRegistry); ctx.provide(OpenApiContributorRegistry.class, registry);
ctx.provide(OpenApiBuilder.class, builder); ctx.provide(OpenApiBuilder.class, builder);
builder.setSecurityRegistry(secRegistry); builder.setContributorRegistry(registry);
// Collect operation metadata from final compiled routes. // Collect operation metadata from final compiled routes.
// This guarantees full runtime paths (namespaces/prefixes/rewrites) in the spec. // This guarantees full runtime paths (namespaces/prefixes/rewrites) in the spec.
@@ -0,0 +1,71 @@
package dev.relism.ext.openapi;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
public final class OpenApiOperationContribution {
private static final OpenApiOperationContribution EMPTY = new OpenApiOperationContribution(List.of(), Map.of(), null);
private final List<Map<String, List<String>>> security;
private final Map<Integer, OpenApiResponseContribution> responses;
private final OpenApiResponseContribution allResponses;
private OpenApiOperationContribution(List<Map<String, List<String>>> security,
Map<Integer, OpenApiResponseContribution> responses,
OpenApiResponseContribution allResponses) {
this.security = List.copyOf(security);
this.responses = Map.copyOf(responses);
this.allResponses = allResponses;
}
public List<Map<String, List<String>>> security() {
return security;
}
public Map<Integer, OpenApiResponseContribution> responses() {
return responses;
}
public OpenApiResponseContribution allResponses() {
return allResponses;
}
public boolean isEmpty() {
return security.isEmpty() && responses.isEmpty() && allResponses == null;
}
public static OpenApiOperationContribution empty() {
return EMPTY;
}
public static Builder builder() {
return new Builder();
}
public static final class Builder {
private final List<Map<String, List<String>>> security = new ArrayList<>();
private final Map<Integer, OpenApiResponseContribution> responses = new LinkedHashMap<>();
private OpenApiResponseContribution allResponses;
public Builder security(String schemeName, List<String> scopes) {
security.add(Map.of(schemeName, List.copyOf(scopes)));
return this;
}
public Builder response(int statusCode, OpenApiResponseContribution response) {
responses.put(statusCode, response);
return this;
}
public Builder allResponses(OpenApiResponseContribution response) {
this.allResponses = response;
return this;
}
public OpenApiOperationContribution build() {
return new OpenApiOperationContribution(security, responses, allResponses);
}
}
}
@@ -0,0 +1,49 @@
package dev.relism.ext.openapi;
import java.util.LinkedHashMap;
import java.util.Map;
public final class OpenApiResponseContribution {
private final String description;
private final Map<String, Map<String, Object>> headers;
private OpenApiResponseContribution(String description, Map<String, Map<String, Object>> headers) {
this.description = description;
this.headers = Map.copyOf(headers);
}
public String description() {
return description;
}
public Map<String, Map<String, Object>> headers() {
return headers;
}
public static Builder builder() {
return new Builder();
}
public static OpenApiResponseContribution of(String description) {
return builder().description(description).build();
}
public static final class Builder {
private String description;
private final Map<String, Map<String, Object>> headers = new LinkedHashMap<>();
public Builder description(String description) {
this.description = description;
return this;
}
public Builder header(String name, Map<String, Object> headerObject) {
headers.put(name, Map.copyOf(headerObject));
return this;
}
public OpenApiResponseContribution build() {
return new OpenApiResponseContribution(description, headers);
}
}
}
@@ -1,57 +0,0 @@
package dev.relism.ext.openapi;
import java.util.List;
import java.util.Map;
/**
* Pluggable security scheme contributor for the OpenAPI spec.
*
* <p>Extensions that enforce authentication (e.g. {@code flash-ext-oidc}) implement
* this interface and register an instance into {@link OpenApiSecurityRegistry} via the
* {@link dev.relism.extension.FlashContext}. {@link OpenApiExtension} picks it up
* at spec-generation time — no coupling between the two extensions at install time.
*
* <p>Multi-tenant: multiple contributors may coexist. For handlers secured by
* {@code @Authenticated}/{@code @RolesAllowed}, each matching contributor adds its
* own entry to the operation's {@code security} array (OpenAPI OR semantics).
*/
public interface OpenApiSecurityContributor {
/**
* Unique scheme name used as a key in {@code components.securitySchemes}
* and referenced from each operation's {@code security} array.
*/
String schemeName();
/**
* The OpenAPI security scheme definition object placed under
* {@code components.securitySchemes.<schemeName>}.
*
* <p>Example for OIDC:
* <pre>{@code
* Map.of("type", "openIdConnect",
* "openIdConnectUrl", "https://idp.example.com/.well-known/openid-configuration")
* }</pre>
*/
Map<String, Object> schemeDefinition();
/**
* Returns the scopes/roles required for the given handler class under this scheme,
* or {@code null} if this contributor does not secure the handler.
*
* <ul>
* <li>{@code null} — handler is not secured by this contributor (skip)</li>
* <li>empty list — handler requires authentication, no specific scopes</li>
* <li>non-empty list — handler requires these scopes/roles</li>
* </ul>
*/
List<String> requiredFor(Class<?> handlerClass);
/**
* Optional auto-injected operation responses for handlers secured by this contributor.
* Key = HTTP status code, value = description.
*/
default Map<Integer, String> autoResponsesFor(Class<?> handlerClass) {
return Map.of();
}
}
@@ -1,31 +0,0 @@
package dev.relism.ext.openapi;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
/**
* Mutable registry of {@link OpenApiSecurityContributor}s.
*
* <p>Created and provided to the {@link dev.relism.extension.FlashContext} by
* {@link OpenApiExtension} at install time. Other extensions (e.g. {@code flash-ext-oidc})
* retrieve it via {@code ctx.find(OpenApiSecurityRegistry.class)} and register their
* contributor — the OpenAPI extension then picks it up lazily at spec-generation time.
*
* <p>Thread-safe: {@link CopyOnWriteArrayList} allows concurrent reads during spec
* generation without blocking registration.
*/
public final class OpenApiSecurityRegistry {
private final List<OpenApiSecurityContributor> contributors = new CopyOnWriteArrayList<>();
/** Registers a contributor. Safe to call concurrently. */
public void add(OpenApiSecurityContributor contributor) {
contributors.add(contributor);
}
/** Returns an unmodifiable snapshot of all registered contributors. */
public List<OpenApiSecurityContributor> contributors() {
return Collections.unmodifiableList(contributors);
}
}
@@ -88,6 +88,16 @@ class OpenApiBuilderTest {
} }
} }
@GET("/merge")
@ApiOperation(summary = "Merge")
@APIResponse(responseCode = "200", description = "Manual 200")
static class MergeHandler extends dev.relism.models.RequestHandler {
@Override
public Object handle(dev.relism.models.Request request, dev.relism.models.Response response) {
return null;
}
}
@Schema(name = "UserDTO", title = "User model", description = "DTO", deprecated = true) @Schema(name = "UserDTO", title = "User model", description = "DTO", deprecated = true)
@JsonIgnoreProperties({"ignoredByType"}) @JsonIgnoreProperties({"ignoredByType"})
static class UserDto { static class UserDto {
@@ -177,16 +187,23 @@ class OpenApiBuilderTest {
@Test @Test
void auto_security_responses_are_added_and_manual_wins_sorted() { void auto_security_responses_are_added_and_manual_wins_sorted() {
OpenApiBuilder b = new OpenApiBuilder(); OpenApiBuilder b = new OpenApiBuilder();
OpenApiSecurityRegistry registry = new OpenApiSecurityRegistry(); OpenApiContributorRegistry registry = new OpenApiContributorRegistry();
registry.add(new OpenApiSecurityContributor() { registry.add(new OpenApiContributor() {
@Override public String schemeName() { return "oidc"; } @Override
@Override public Map<String, Object> schemeDefinition() { return Map.of("type", "oauth2"); } public Map<String, Object> componentContributions() {
@Override public List<String> requiredFor(Class<?> handlerClass) { return List.of(); } return Map.of("securitySchemes", Map.of("oidc", Map.of("type", "oauth2")));
@Override public Map<Integer, String> autoResponsesFor(Class<?> handlerClass) { }
return Map.of(401, "Authentication required", 403, "Auto forbidden");
@Override
public OpenApiOperationContribution operationFor(Class<?> handlerClass) {
return OpenApiOperationContribution.builder()
.security("oidc", List.of())
.response(401, OpenApiResponseContribution.of("Authentication required"))
.response(403, OpenApiResponseContribution.of("Auto forbidden"))
.build();
} }
}); });
b.setSecurityRegistry(registry); b.setContributorRegistry(registry);
b.addOperation(OpenApiBuilder.routeOf(SecureHandler.class), SecureHandler.class.getAnnotation(ApiOperation.class), SecureHandler.class); b.addOperation(OpenApiBuilder.routeOf(SecureHandler.class), SecureHandler.class.getAnnotation(ApiOperation.class), SecureHandler.class);
Map<String, Object> spec = b.build(); Map<String, Object> spec = b.build();
@@ -250,6 +267,77 @@ class OpenApiBuilderTest {
assertEquals("#/components/schemas/UserDTO", additionalProperties.get("$ref")); assertEquals("#/components/schemas/UserDTO", additionalProperties.get("$ref"));
} }
@Test
void contributor_response_merge_appliesAllResponses_andManualDescriptionWins() {
OpenApiBuilder b = new OpenApiBuilder();
OpenApiContributorRegistry registry = new OpenApiContributorRegistry();
registry.add(new OpenApiContributor() {
@Override
public OpenApiOperationContribution operationFor(Class<?> handlerClass) {
return OpenApiOperationContribution.builder()
.allResponses(OpenApiResponseContribution.builder()
.header("X-Trace", Map.of("schema", Map.of("type", "string")))
.build())
.response(200, OpenApiResponseContribution.of("Auto 200"))
.response(429, OpenApiResponseContribution.of("Auto 429"))
.build();
}
});
b.setContributorRegistry(registry);
b.addOperation(OpenApiBuilder.routeOf(MergeHandler.class), MergeHandler.class.getAnnotation(ApiOperation.class), MergeHandler.class);
Map<String, Object> spec = b.build();
Map<String, Object> get = getOperation(spec, "/merge", "get");
Map<String, Object> responses = cast(get.get("responses"));
Map<String, Object> resp200 = cast(responses.get("200"));
assertEquals("Manual 200", resp200.get("description"));
Map<String, Object> headers200 = cast(resp200.get("headers"));
assertTrue(headers200.containsKey("X-Trace"));
Map<String, Object> resp429 = cast(responses.get("429"));
assertEquals("Auto 429", resp429.get("description"));
Map<String, Object> headers429 = cast(resp429.get("headers"));
assertTrue(headers429.containsKey("X-Trace"));
}
@Test
void contributor_lastWins_forResponseHeaderCollisions() {
OpenApiBuilder b = new OpenApiBuilder();
OpenApiContributorRegistry registry = new OpenApiContributorRegistry();
registry.add(new OpenApiContributor() {
@Override
public OpenApiOperationContribution operationFor(Class<?> handlerClass) {
return OpenApiOperationContribution.builder()
.response(200, OpenApiResponseContribution.builder()
.header("X-RateLimit-Limit", Map.of("description", "old", "schema", Map.of("type", "integer")))
.build())
.build();
}
});
registry.add(new OpenApiContributor() {
@Override
public OpenApiOperationContribution operationFor(Class<?> handlerClass) {
return OpenApiOperationContribution.builder()
.response(200, OpenApiResponseContribution.builder()
.header("X-RateLimit-Limit", Map.of("description", "new", "schema", Map.of("type", "integer")))
.build())
.build();
}
});
b.setContributorRegistry(registry);
b.addOperation(OpenApiBuilder.routeOf(MergeHandler.class), MergeHandler.class.getAnnotation(ApiOperation.class), MergeHandler.class);
Map<String, Object> spec = b.build();
Map<String, Object> get = getOperation(spec, "/merge", "get");
Map<String, Object> responses = cast(get.get("responses"));
Map<String, Object> resp200 = cast(responses.get("200"));
Map<String, Object> headers = cast(resp200.get("headers"));
Map<String, Object> header = cast(headers.get("X-RateLimit-Limit"));
assertEquals("new", header.get("description"));
}
private static Map<String, Object> getOperation(Map<String, Object> spec, String path, String method) { private static Map<String, Object> getOperation(Map<String, Object> spec, String path, String method) {
Map<String, Object> paths = cast(spec.get("paths")); Map<String, Object> paths = cast(spec.get("paths"));
Map<String, Object> pathItem = cast(paths.get(path)); Map<String, Object> pathItem = cast(paths.get(path));
@@ -52,4 +52,3 @@ public final class HomePage extends ViewHandler {
- `docs/partials.md` - `docs/partials.md`
- `docs/adapters.md` - `docs/adapters.md`
- `docs/performance.md` - `docs/performance.md`
- `docs/migration-from-legacy-view.md`
@@ -1,33 +0,0 @@
# Migration from Legacy View API
Legacy API (`@View`, `ViewEngine`, `Renderer`, `Template`) has been removed.
## Replace annotations
- `@View("page")` -> `@Page("page")`
- `@View(value = "x", fragment = true)` -> `@Partial(template = "x")`
`@Page` and `@Partial` must be declared on classes extending `ViewHandler`.
## Replace handler return contract
Before (legacy):
```java
public Object handle(Request req, Response res) {
return Map.of("name", "Flash");
}
```
Now:
```java
public ViewModel render(Request req) {
return ViewModel.of("name", "Flash");
}
```
## Replace engine integration
- Implement `ViewEngineAdapter` directly.
- Or use `ViewEngineType.THYMELEAF`.
@@ -49,7 +49,7 @@ class WebBundlerExtensionIntegrationTest {
.basePath("/app") .basePath("/app")
.build(); .build();
app = FlashApp.create(FlashConfiguration.builder().port(port).host("127.0.0.1").build()); app = FlashApp.create(port);
app.install(new WebBundlerExtension(config)); app.install(new WebBundlerExtension(config));
app.get("/api/ping", (req, res) -> "pong"); app.get("/api/ping", (req, res) -> "pong");
app.start(); app.start();