Skip to content

[SPEC-FIX] Revise swagger.md Section 5: Use absolute route paths with empty resourcePath under CompositeScalatraFilter #84

@michael-conrad

Description

@michael-conrad

[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:

  1. CompositeScalatraFilter.process() receives the full request URI (e.g., /api/v3/repos/owner/repo/issues/).
  2. Routes with absolute paths (e.g., "/api/v3/repos/:owner/:repository/issues") match correctly because Scalatra resolves them against the full URI.
  3. 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.
  4. 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/issuesdouble prefix: /api/v3/api/v3/repos/{owner}/{repository}/issues
  • If resourcePath = "" and route path = /api/v3/repos/:owner/:repository/issuescorrect: /api/v3/repos/{owner}/{repository}/issues
  • If resourcePath = "/api/v3" and route path = /repos/:owner/:repository/issuescorrect 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)

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions