[SPEC-FIX] Revise swagger.md Section 5: Use absolute route paths with empty resourcePath under CompositeScalatraFilter
Problem
The current doc/swagger/swagger.md Section 5 states:
"The route literal must not start with /api/v3"
This guidance was implemented in the reverted PR #64 (commits 027c410..87678f9). Changing route path literals from absolute to relative broke all API routing under CompositeScalatraFilter, causing every API endpoint to return 404. The revert commit (fb61938) preserved only doc/swagger/ and .opencode/.
Root Cause Analysis
GitBucket's ApiController is mounted inside CompositeScalatraFilter at /api/v3. Under this filter:
CompositeScalatraFilter.process() receives the full request URI (e.g., /api/v3/repos/owner/repo/issues/).
- Routes with absolute paths (e.g.,
"/api/v3/repos/:owner/:repository/issues") match correctly because Scalatra resolves them against the full URI.
- Routes with relative paths (e.g.,
"/repos/:owner/:repository/issues") do NOT match because Scalatra applies them in the filter context but the catch-all handlers in ApiControllerBase (get("/api/v3/*") { NotFound() }) use absolute paths and match first.
- The catch-all 404 handlers CANNOT be changed to relative paths (
"/*") without affecting ALL non-API routes in the same filter, and removing them would change 404 behavior for unhandled API requests.
The spec's guidance to use relative path literals is incompatible with CompositeScalatraFilter dispatch when absolute catch-all handlers exist in the same trait.
Research Findings
Exhaustive research into Scalatra-Swagger source code (SwaggerSupport.scala, Swagger.scala, SwaggerBase.scala on the 2.8.x branch) and alternative OpenAPI generation tools reveals:
Path Construction in endpoints() (verified source)
// From SwaggerSupport.scala
val pth = if (basePath endsWith "/") basePath else basePath + "/"
val nm = if (name startsWith "/") name.substring(1) else name
new Endpoint(pth + nm, ...)
Where basePath = resourcePath (from swagger.register()). The final path in swagger.json = resourcePath + "/" + routePath.
- If
resourcePath = "/api/v3" and route path = /api/v3/repos/:owner/:repository/issues → double prefix: /api/v3/api/v3/repos/{owner}/{repository}/issues
- If
resourcePath = "" and route path = /api/v3/repos/:owner/:repository/issues → correct: /api/v3/repos/{owner}/{repository}/issues
- If
resourcePath = "/api/v3" and route path = /repos/:owner/:repository/issues → correct paths in JSON but broken runtime routing (the reverted approach)
Alternative OpenAPI Generation Approaches Evaluated
| Approach |
Maintained? |
Scalatra Compatible? |
Route Path Changes? |
Risk |
| Scalatra-Swagger with empty resourcePath |
✅ (scalatra-swagger-javax 3.1.2, May 2025) |
✅ Native |
None — keep absolute /api/v3/... paths |
Low — only infrastructure changes |
| Tapir endpoint descriptions + OpenAPIDocsInterpreter |
✅ (v1.13.19, May 2026) |
❌ Cannot scan Scalatra routes |
N/A — parallel definitions |
High — dual route maintenance |
| sttp-apispec openapi-model (programmatic) |
✅ (v0.11.10, Jun 2025) |
✅ No framework dependency |
None — but no auto-discovery |
Medium — must build spec manually |
| swagger-core v3 (programmatic Java API) |
✅ (v2.2.50, May 2026) |
✅ No framework dependency |
None — but Java API, not idiomatic |
Medium — Java API ergonomics |
| Scalatra-Swagger with relative paths (current spec) |
✅ |
✅ But breaks routing |
All routes → broken |
BROKEN — reverted in fb61938 |
Override Points in Scalatra-Swagger (verified)
| Mechanism |
What It Controls |
Override Method |
resourcePath in swagger.register() |
Path prefix for endpoint paths in swagger.json |
Pass "" instead of "/api/v3" |
SwaggerBase.bathPath |
basePath field in swagger.json output |
Override to return Some("/api/v3") |
SwaggerEngine.baseUrlIncludeContextPath |
Whether basePath includes context path |
Override to false |
SwaggerEngine.baseUrlIncludeServletPath |
Whether basePath includes servlet path |
Override to true |
Swagger.renderSwagger2() |
Full JSON output |
Override for post-processing |
GitBucketSwagger subclass |
Swagger instance configuration |
Custom subclass with fixed host, basePath, etc. |
Proposed Solution
Approach: Absolute Route Paths + Empty ResourcePath
Keep all route path literals absolute (no changes to any route definition). Register the Swagger resource with an empty resourcePath so Scalatra-Swagger does NOT prepend /api/v3 to paths that already contain it.
Changes Required
1. ApiControllerBase.initialize — change swagger.register() call:
// BEFORE (spec's current guidance, which caused double-prefixing):
swagger.register("api", "/api/v3", Some(applicationDescription), this, ...)
// AFTER (empty resourcePath — route paths already include /api/v3):
swagger.register("api", "", Some(applicationDescription), this, ...)
2. SwaggerBase subclass (SwaggerResourcesApp) — override bathPath to emit /api/v3 in the spec:
Since resourcePath is empty, Scalatra-Swagger won't auto-detect basePath correctly. Override bathPath (note: this is the actual method name in Scalatra-Swagger — it's a typo in the upstream source) to return Some("/api/v3"):
class SwaggerResourcesApp(implicit override val swagger: Swagger)
extends ScalatraServlet with JacksonSwaggerBase {
override protected def bathPath: Option[String] = Some("/api/v3")
}
3. All route path literals remain absolute — no changes needed to any Api*ControllerBase trait:
// Existing routes stay exactly as they are:
get("/api/v3/repos/:owner/:repository/issues")(referrersOnly { repository =>
// ... unchanged action body ...
})
// Swagger annotation is added as a route transformer, path stays absolute:
val listIssuesOp =
apiOperation[List[ApiIssue]]("listIssues")
.summary("List issues for a repository")
.parameters(
pathParam[String]("owner").description("Repository owner"),
pathParam[String]("repository").description("Repository name"),
// ... query params ...
)
get("/api/v3/repos/:owner/:repository/issues", operation(listIssuesOp))(referrersOnly { repository =>
// ... unchanged action body ...
})
4. Catch-all 404 handlers remain absolute — no changes:
// These stay as-is in ApiControllerBase:
get("/api/v3/*") { NotFound() }
post("/api/v3/*") { NotFound() }
// etc.
The catch-alls are NOT annotated with apiOperation and will NOT appear in swagger.json. They are dispatch handlers, not API documentation targets.
Why This Works
| Component |
Value |
Why |
| Route path literals |
Absolute (/api/v3/repos/...) |
Matches full URIs under CompositeScalatraFilter; coexists with absolute catch-all handlers |
swagger.register() resourcePath |
"" (empty) |
Prevents double-prefixing: "" + /api/v3/repos/{owner} = /api/v3/repos/{owner} |
bathPath override |
/api/v3 |
Sets basePath in swagger.json so the spec is correct even with empty resourcePath |
| Catch-all 404 handlers |
Absolute, unannotated |
Not in swagger.json (no apiOperation transformer); still handle unmatched API requests |
What Changes From Current Spec
| Current Spec (Section 5) |
Revised Approach |
Why |
| "Route literals must be relative" |
Route literals remain absolute |
Relative paths break routing under CompositeScalatraFilter with absolute catch-alls |
resourcePath = "/api/v3" in swagger.register() |
resourcePath = "" |
Prevents double-prefix when routes are absolute |
No bathPath override needed |
Override bathPath to return /api/v3 |
Required when resourcePath is empty — Scalatra-Swagger can't derive it from filter context |
Route example: get("/repos/:owner/:repository/issues", ...) |
Route example: get("/api/v3/repos/:owner/:repository/issues", ...) |
Absolute paths match the full URI under CompositeScaltraFilter |
Anti-Pattern Updates
| Current Anti-Pattern |
Revised |
"Do NOT put apiOperation[...] anywhere except in the transformers list" |
KEEP — unchanged |
"Route literals must NOT start with /api/v3" |
REMOVE — route literals MUST start with /api/v3 under CompositeScalatraFilter |
"If swagger.json shows /api/v3/api/v3/..., the route literal still has the prefix — fix the literal" |
REVISE — If swagger.json shows double-prefixing, the resourcePath is wrong, not the route literal. Fix the resourcePath to be empty. |
Success Criteria
| ID |
Criterion |
Evidence Type |
Verification |
| SC-1 |
GET /api-docs/swagger.json returns valid Swagger 2.0 JSON with 200 status |
behavioral |
curl -s http://localhost:8080/api-docs/swagger.json | jq .swagger returns "2.0" |
| SC-2 |
swagger.json has basePath: "/api/v3" |
string |
jq '.basePath' swagger.json returns "/api/v3" |
| SC-3 |
No double-prefixing in swagger.json paths |
behavioral |
jq '[.paths | keys[] | select(startswith("/api/v3/api/v3"))] | length' swagger.json returns 0 |
| SC-4 |
Annotated endpoint appears with correct path in swagger.json |
behavioral |
jq '.paths["/api/v3/repos/{owner}/{repository}/issues"]' swagger.json is non-null |
| SC-5 |
All existing API endpoints return correct status codes (no regression) |
behavioral |
Smoke tests F4-F10 from swagger.md Section 7 pass |
| SC-6 |
Catch-all 404 handlers do NOT appear in swagger.json |
string |
jq '.paths["/api/v3/*"]' swagger.json returns null for all HTTP methods |
Affected Files
| File |
Change |
doc/swagger/swagger.md |
Revise Section 5 (path literals), Section 9 (anti-patterns), Section 3 (register call), add empty-resourcePath explanation |
doc/swagger/swagger.md |
Add new section documenting the CompositeScalatraFilter routing constraint and why absolute paths are required |
doc/swagger/swagger.md |
Update code examples in Section 5 and Section 6 to use absolute paths |
| (No source code changes in this spec — implementation is in Phase 0+1) |
|
Dependencies
- Phase 0+1 must implement the empty
resourcePath approach in ApiControllerBase.initialize and the bathPath override in SwaggerResourcesApp
- This spec-FIX updates documentation only; source code changes belong in Phase 0+1 PRs
Risk
| Risk | Impact | Mitigation |
|--------------|------------|
| Empty resourcePath causes Scalatra-Swagger to malfunction | Swagger JSON missing or malformed | Verify in Phase 1 proof-of-concept before annotating any routes |
| bathPath override not picked up by renderSwagger2 | basePath field incorrect or absent in JSON | Override is on SwaggerBase which is the rendering servlet — verify with smoke test F1-F3 |
| Catch-all 404 handlers appear in swagger.json | Spec clutter with wildcard routes | These routes have NO apiOperation transformer, so Scalatra-Swagger will not include them. Verify with SC-6. |
🤖 Co-authored with AI: OpenCode (ollama-cloud/glm-5.1)
[SPEC-FIX] Revise swagger.md Section 5: Use absolute route paths with empty resourcePath under CompositeScalatraFilter
Problem
The current
doc/swagger/swagger.mdSection 5 states:This guidance was implemented in the reverted PR #64 (commits 027c410..87678f9). Changing route path literals from absolute to relative broke all API routing under
CompositeScalatraFilter, causing every API endpoint to return 404. The revert commit (fb61938) preserved onlydoc/swagger/and.opencode/.Root Cause Analysis
GitBucket's
ApiControlleris mounted insideCompositeScalatraFilterat/api/v3. Under this filter:CompositeScalatraFilter.process()receives the full request URI (e.g.,/api/v3/repos/owner/repo/issues/)."/api/v3/repos/:owner/:repository/issues") match correctly because Scalatra resolves them against the full URI."/repos/:owner/:repository/issues") do NOT match because Scalatra applies them in the filter context but the catch-all handlers inApiControllerBase(get("/api/v3/*") { NotFound() }) use absolute paths and match first."/*") without affecting ALL non-API routes in the same filter, and removing them would change 404 behavior for unhandled API requests.The spec's guidance to use relative path literals is incompatible with
CompositeScalatraFilterdispatch when absolute catch-all handlers exist in the same trait.Research Findings
Exhaustive research into Scalatra-Swagger source code (
SwaggerSupport.scala,Swagger.scala,SwaggerBase.scalaon the2.8.xbranch) and alternative OpenAPI generation tools reveals:Path Construction in
endpoints()(verified source)Where
basePath=resourcePath(fromswagger.register()). The final path inswagger.json=resourcePath + "/" + routePath.resourcePath = "/api/v3"and route path =/api/v3/repos/:owner/:repository/issues→ double prefix:/api/v3/api/v3/repos/{owner}/{repository}/issuesresourcePath = ""and route path =/api/v3/repos/:owner/:repository/issues→ correct:/api/v3/repos/{owner}/{repository}/issuesresourcePath = "/api/v3"and route path =/repos/:owner/:repository/issues→ correct paths in JSON but broken runtime routing (the reverted approach)Alternative OpenAPI Generation Approaches Evaluated
/api/v3/...pathsOverride Points in Scalatra-Swagger (verified)
resourcePathinswagger.register()""instead of"/api/v3"SwaggerBase.bathPathbasePathfield in swagger.json outputSome("/api/v3")SwaggerEngine.baseUrlIncludeContextPathfalseSwaggerEngine.baseUrlIncludeServletPathtrueSwagger.renderSwagger2()GitBucketSwaggersubclassProposed Solution
Approach: Absolute Route Paths + Empty ResourcePath
Keep all route path literals absolute (no changes to any route definition). Register the Swagger resource with an empty
resourcePathso Scalatra-Swagger does NOT prepend/api/v3to paths that already contain it.Changes Required
1.
ApiControllerBase.initialize— changeswagger.register()call:2.
SwaggerBasesubclass (SwaggerResourcesApp) — overridebathPathto emit/api/v3in the spec:Since
resourcePathis empty, Scalatra-Swagger won't auto-detectbasePathcorrectly. OverridebathPath(note: this is the actual method name in Scalatra-Swagger — it's a typo in the upstream source) to returnSome("/api/v3"):3. All route path literals remain absolute — no changes needed to any
Api*ControllerBasetrait:4. Catch-all 404 handlers remain absolute — no changes:
The catch-alls are NOT annotated with
apiOperationand will NOT appear inswagger.json. They are dispatch handlers, not API documentation targets.Why This Works
/api/v3/repos/...)CompositeScalatraFilter; coexists with absolute catch-all handlersswagger.register()resourcePath""(empty)""+/api/v3/repos/{owner}=/api/v3/repos/{owner}bathPathoverride/api/v3basePathin swagger.json so the spec is correct even with empty resourcePathapiOperationtransformer); still handle unmatched API requestsWhat Changes From Current Spec
CompositeScalatraFilterwith absolute catch-allsresourcePath = "/api/v3"inswagger.register()resourcePath = ""bathPathoverride neededbathPathto return/api/v3resourcePathis empty — Scalatra-Swagger can't derive it from filter contextget("/repos/:owner/:repository/issues", ...)get("/api/v3/repos/:owner/:repository/issues", ...)CompositeScaltraFilterAnti-Pattern Updates
apiOperation[...]anywhere except in thetransformerslist"/api/v3"/api/v3underCompositeScalatraFilterswagger.jsonshows/api/v3/api/v3/..., the route literal still has the prefix — fix the literal"swagger.jsonshows double-prefixing, theresourcePathis wrong, not the route literal. Fix theresourcePathto be empty.Success Criteria
GET /api-docs/swagger.jsonreturns valid Swagger 2.0 JSON with200statuscurl -s http://localhost:8080/api-docs/swagger.json | jq .swaggerreturns"2.0"swagger.jsonhasbasePath: "/api/v3"jq '.basePath' swagger.jsonreturns"/api/v3"jq '[.paths | keys[] | select(startswith("/api/v3/api/v3"))] | length' swagger.jsonreturns0jq '.paths["/api/v3/repos/{owner}/{repository}/issues"]' swagger.jsonis non-nulljq '.paths["/api/v3/*"]' swagger.jsonreturnsnullfor all HTTP methodsAffected Files
doc/swagger/swagger.mddoc/swagger/swagger.mdCompositeScalatraFilterrouting constraint and why absolute paths are requireddoc/swagger/swagger.mdDependencies
resourcePathapproach inApiControllerBase.initializeand thebathPathoverride inSwaggerResourcesAppRisk
| Risk | Impact | Mitigation |
|--------------|------------|
| Empty
resourcePathcauses Scalatra-Swagger to malfunction | Swagger JSON missing or malformed | Verify in Phase 1 proof-of-concept before annotating any routes ||
bathPathoverride not picked up byrenderSwagger2|basePathfield incorrect or absent in JSON | Override is onSwaggerBasewhich is the rendering servlet — verify with smoke test F1-F3 || Catch-all 404 handlers appear in swagger.json | Spec clutter with wildcard routes | These routes have NO
apiOperationtransformer, so Scalatra-Swagger will not include them. Verify with SC-6. |🤖 Co-authored with AI: OpenCode (ollama-cloud/glm-5.1)