diff --git a/.tasks/routing-refactoring/README.md b/.tasks/routing-refactoring/README.md new file mode 100644 index 0000000..31bdbab --- /dev/null +++ b/.tasks/routing-refactoring/README.md @@ -0,0 +1,351 @@ +# Routing Refactoring - Task Roadmap + +This directory contains task breakdowns for implementing semantic routing for CHUCC-server, making datasets, branches, and commits directly shareable via URL. + +--- + +## Overview + +**Goal:** Refactor endpoint routing from query-parameter style to path-based semantic URLs that follow Apache Jena Fuseki best practices with version control extensions. + +**Documentation:** See [docs/architecture/semantic-routing.md](../../docs/architecture/semantic-routing.md) for complete specification. + +**Status:** ⏳ Not Started + +--- + +## Background + +### Current Pattern (Query Parameters) +``` +GET /sparql?dataset=mydata&branch=main&query=... +GET /data?dataset=mydata&commit=01936d8f...&graph=... +``` + +**Problems:** +- ❌ Not shareable (long query strings) +- ❌ Inconsistent (TagController uses path params) +- ❌ Not RESTful (resources not in URL path) +- ❌ Not discoverable + +### Target Pattern (Path Segments) +``` +GET /mydata/version/branches/main/sparql?query=... +GET /mydata/version/commits/01936d8f.../data?graph=... +GET /mydata/version/tags/v1.0/sparql?query=... +``` + +**Benefits:** +- ✅ Shareable and bookmarkable +- ✅ Immutable URLs for commits/tags (perfect for citations) +- ✅ RESTful resource hierarchy +- ✅ Fuseki-compatible pattern + +--- + +## Task Breakdown + +### Session 1: Foundation - URL Utilities and OpenAPI Updates +**File:** [session-1-url-utilities-and-openapi.md](./session-1-url-utilities-and-openapi.md) +**Status:** ⏳ Not Started +**Estimated Time:** 3-4 hours + +**Deliverables:** +- `VersionControlUrls` utility class for URL construction +- OpenAPI spec updates with new path structures +- Response header helpers (`Content-Location`, `Link`) + +**Why First:** Provides foundation for all subsequent tasks + +--- + +### Session 2: Dataset Parameter Standardization +**File:** [session-2-dataset-parameter-standardization.md](./session-2-dataset-parameter-standardization.md) +**Status:** ⏳ Not Started +**Estimated Time:** 4-5 hours + +**Deliverables:** +- Move dataset from query param to path variable across all controllers +- Align TagController with rest of codebase +- Update all integration tests +- Maintain backward compatibility with query param (deprecated) + +**Why Second:** Establishes consistent dataset addressing before adding version routing + +--- + +### Session 3: Versioned SPARQL Endpoints +**File:** [session-3-versioned-sparql-endpoints.md](./session-3-versioned-sparql-endpoints.md) +**Status:** ⏳ Not Started +**Estimated Time:** 5-6 hours + +**Deliverables:** +- `GET/POST /{dataset}/version/branches/{name}/sparql` +- `GET/POST /{dataset}/version/commits/{id}/sparql` +- `GET/POST /{dataset}/version/tags/{name}/sparql` +- `POST /{dataset}/version/branches/{name}/update` +- Integration tests for all versioned SPARQL endpoints + +**Why Third:** Core functionality for querying at specific versions + +--- + +### Session 4: Versioned Graph Store Protocol Endpoints +**File:** [session-4-versioned-gsp-endpoints.md](./session-4-versioned-gsp-endpoints.md) +**Status:** ⏳ Not Started +**Estimated Time:** 5-6 hours + +**Deliverables:** +- `GET /{dataset}/version/{ref}/data?graph={iri}` +- `PUT/POST/DELETE/PATCH /{dataset}/version/branches/{name}/data?graph={iri}` +- Integration tests for all versioned GSP endpoints +- Immutability enforcement (no writes to commits/tags) + +**Why Fourth:** Extends GSP with version awareness + +--- + +### Session 5: Version Control Management Endpoints +**File:** [session-5-version-control-management.md](./session-5-version-control-management.md) +**Status:** ⏳ Not Started +**Estimated Time:** 4-5 hours + +**Deliverables:** +- `GET /{dataset}/version/branches/{name}` - Branch metadata +- `GET /{dataset}/version/commits/{id}` - Commit metadata +- `GET /{dataset}/version/tags/{name}` - Tag metadata +- Response includes navigational `Link` headers +- Integration tests + +**Why Fifth:** Adds resource metadata endpoints for discoverability + +--- + +### Session 6: Advanced Operations Routing +**File:** [session-6-advanced-operations-routing.md](./session-6-advanced-operations-routing.md) +**Status:** ⏳ Not Started +**Estimated Time:** 5-6 hours + +**Deliverables:** +- `POST /{dataset}/version/branches/{target}/merge` +- `POST /{dataset}/version/branches/{name}/reset` +- `POST /{dataset}/version/branches/{name}/revert` +- `POST /{dataset}/version/branches/{name}/cherry-pick` +- `POST /{dataset}/version/branches/{name}/rebase` +- `POST /{dataset}/version/branches/{name}/squash` +- `GET /{dataset}/version/commits/{id1}/diff/{id2}` +- `GET /{dataset}/version/branches/{name}/history` +- Integration tests + +**Why Sixth:** Refactors advanced operations to use new routing + +--- + +### Session 7: Batch Operations Routing +**File:** [session-7-batch-operations-routing.md](./session-7-batch-operations-routing.md) +**Status:** ⏳ Not Started +**Estimated Time:** 3-4 hours + +**Deliverables:** +- `POST /{dataset}/version/branches/{name}/batch-graphs` +- `POST /{dataset}/version/branches/{name}/batch` +- Integration tests +- Performance verification + +**Why Seventh:** Batch operations benefit from explicit branch targeting + +--- + +### Session 8: Dataset Management Endpoints +**File:** [session-8-dataset-management-endpoints.md](./session-8-dataset-management-endpoints.md) +**Status:** ⏳ Not Started +**Estimated Time:** 3-4 hours + +**Deliverables:** +- `POST /datasets/{name}` - Create dataset +- `DELETE /datasets/{name}` - Delete dataset +- `GET /datasets` - List datasets +- `GET /datasets/{name}` - Dataset metadata +- Integration tests + +**Why Eighth:** Dataset management uses new patterns from previous sessions + +--- + +### Session 9: Current-State Shortcuts +**File:** [session-9-current-state-shortcuts.md](./session-9-current-state-shortcuts.md) +**Status:** ⏳ Not Started +**Estimated Time:** 4-5 hours + +**Deliverables:** +- `GET/POST /{dataset}/sparql` - Query main HEAD (shortcut) +- `POST /{dataset}/update` - Update main branch (shortcut) +- `GET /{dataset}/data?graph={iri}` - GSP at main HEAD (shortcut) +- `PUT/POST/DELETE/PATCH /{dataset}/data?graph={iri}` - GSP operations +- Integration tests + +**Why Ninth:** Convenience shortcuts after core versioned endpoints established + +--- + +### Session 10: Backward Compatibility and Migration +**File:** [session-10-backward-compatibility.md](./session-10-backward-compatibility.md) +**Status:** ⏳ Not Started +**Estimated Time:** 4-5 hours + +**Deliverables:** +- Maintain old query-param endpoints (marked as deprecated) +- Add `Deprecation` header to old endpoints +- Add `Content-Location` header pointing to new URLs +- Migration guide documentation +- Integration tests for both old and new patterns + +**Why Tenth:** Ensures smooth migration path for existing clients + +--- + +### Session 11: Documentation and Examples +**File:** [session-11-documentation-and-examples.md](./session-11-documentation-and-examples.md) +**Status:** ⏳ Not Started +**Estimated Time:** 3-4 hours + +**Deliverables:** +- Update [docs/api/routing-guide.md](../../docs/api/routing-guide.md) +- Update all API documentation with new URL patterns +- Add example use cases (citations, reproducibility) +- Update OpenAPI Swagger UI with examples +- Migration guide for users + +**Why Last:** Documentation after implementation complete + +--- + +## Dependencies + +```mermaid +graph TD + S1[Session 1: URL Utilities] --> S2[Session 2: Dataset Param] + S2 --> S3[Session 3: Versioned SPARQL] + S2 --> S4[Session 4: Versioned GSP] + S2 --> S5[Session 5: VC Management] + S3 --> S6[Session 6: Advanced Ops] + S4 --> S6 + S5 --> S6 + S6 --> S7[Session 7: Batch Ops] + S2 --> S8[Session 8: Dataset Management] + S3 --> S9[Session 9: Current-State Shortcuts] + S4 --> S9 + S6 --> S10[Session 10: Backward Compat] + S7 --> S10 + S8 --> S10 + S9 --> S10 + S10 --> S11[Session 11: Documentation] +``` + +**Critical Path:** S1 → S2 → S3/S4 → S6 → S10 → S11 + +--- + +## Testing Strategy + +### Integration Test Pattern + +For each session, create integration tests that verify: + +1. **New pattern works:** + ```java + @Test + void newPattern_shouldWork() { + String url = "/mydata/version/branches/main/sparql?query=..."; + ResponseEntity response = restTemplate.getForEntity(url, String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getHeaders().get("Content-Location")) + .contains("/mydata/version/branches/main"); + } + ``` + +2. **Old pattern still works (backward compat):** + ```java + @Test + void oldPattern_shouldStillWork() { + String url = "/sparql?dataset=mydata&branch=main&query=..."; + ResponseEntity response = restTemplate.getForEntity(url, String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getHeaders().get("Content-Location")) + .contains("/mydata/version/branches/main"); // Points to new URL + } + ``` + +3. **Response headers correct:** + ```java + assertThat(response.getHeaders().get("Content-Location")).isNotNull(); + assertThat(response.getHeaders().get("Link")).isNotNull(); + ``` + +--- + +## Quality Gates + +Each session must pass: + +- ✅ All unit tests pass +- ✅ All integration tests pass +- ✅ Zero Checkstyle violations +- ✅ Zero SpotBugs warnings +- ✅ Zero PMD violations +- ✅ Zero compiler warnings +- ✅ OpenAPI spec validates +- ✅ Backward compatibility maintained (if applicable) + +**Command:** +```bash +mvn -q clean install +``` + +--- + +## Progress Tracking + +| Session | Status | Estimated | Actual | Completion Date | +|---------|--------|-----------|--------|-----------------| +| 1. URL Utilities | ⏳ Not Started | 3-4h | - | - | +| 2. Dataset Param | ⏳ Not Started | 4-5h | - | - | +| 3. Versioned SPARQL | ⏳ Not Started | 5-6h | - | - | +| 4. Versioned GSP | ⏳ Not Started | 5-6h | - | - | +| 5. VC Management | ⏳ Not Started | 4-5h | - | - | +| 6. Advanced Ops | ⏳ Not Started | 5-6h | - | - | +| 7. Batch Ops | ⏳ Not Started | 3-4h | - | - | +| 8. Dataset Management | ⏳ Not Started | 3-4h | - | - | +| 9. Current-State Shortcuts | ⏳ Not Started | 4-5h | - | - | +| 10. Backward Compat | ⏳ Not Started | 4-5h | - | - | +| 11. Documentation | ⏳ Not Started | 3-4h | - | - | +| **TOTAL** | | **43-54h** | - | - | + +**Estimated Total:** 43-54 hours (~6-7 full working days) + +--- + +## Related Documentation + +- **[Semantic Routing Specification](../../docs/architecture/semantic-routing.md)** - Executive summary +- **[Full Routing Proposal](/tmp/routing-concept-proposal.md)** - Detailed analysis (800+ lines) +- **[API Extensions](../../docs/api/api-extensions.md)** - Current API documentation +- **[CQRS + Event Sourcing](../../docs/architecture/cqrs-event-sourcing.md)** - Architecture fundamentals + +--- + +## Notes + +- **Backward compatibility is critical** - All old query-param patterns must continue working +- **Follow TDD** - Write integration tests before implementation +- **Use quality gates** - Run `mvn -q clean install` after each session +- **Update OpenAPI specs** - Keep API documentation in sync +- **Add `Content-Location` headers** - Help clients discover new URLs + +--- + +**Created:** 2025-11-06 +**Author:** Claude (Anthropic) +**Status:** Ready for implementation diff --git a/.tasks/routing-refactoring/session-1-url-utilities-and-openapi.md b/.tasks/routing-refactoring/session-1-url-utilities-and-openapi.md new file mode 100644 index 0000000..aa020d0 --- /dev/null +++ b/.tasks/routing-refactoring/session-1-url-utilities-and-openapi.md @@ -0,0 +1,724 @@ +# Session 1: URL Utilities and OpenAPI Updates + +**Status:** ⏳ Not Started +**Priority:** High (Foundation for all other sessions) +**Estimated Time:** 3-4 hours +**Complexity:** Medium + +--- + +## Overview + +Create utility classes for URL construction and update OpenAPI specifications to reflect the new semantic routing patterns. This provides the foundation for all subsequent routing refactoring sessions. + +**Goal:** Establish URL building infrastructure and API documentation framework before implementing actual endpoint changes. + +--- + +## Deliverables + +1. ✅ `VersionControlUrls` utility class +2. ✅ Response header helper methods +3. ✅ OpenAPI spec updates with new path structures +4. ✅ Unit tests for URL utilities +5. ✅ OpenAPI validation passes + +--- + +## Task 1: Create VersionControlUrls Utility Class + +### Location + +**New File:** `src/main/java/org/chucc/vcserver/controller/util/VersionControlUrls.java` + +### Implementation + +```java +package org.chucc.vcserver.controller.util; + +import org.chucc.vcserver.domain.CommitId; +import org.springframework.web.util.UriComponentsBuilder; + +/** + * Utility class for constructing version control URLs following semantic routing patterns. + *

+ * This class provides helper methods to build RESTful URLs that make datasets, branches, + * commits, and tags directly shareable and bookmarkable. + *

+ *

+ * URL Pattern: {@code /{dataset}/version/{ref-type}/{ref-name}/{service}} + *

+ * + * @see Semantic Routing Specification + */ +public final class VersionControlUrls { + + private VersionControlUrls() { + // Utility class - prevent instantiation + } + + // ==================== Dataset URLs ==================== + + /** + * Constructs URL for dataset root. + * + * @param dataset Dataset name + * @return URL path (e.g., "/mydata") + */ + public static String dataset(String dataset) { + return "/" + dataset; + } + + /** + * Constructs URL for dataset metadata endpoint. + * + * @param dataset Dataset name + * @return URL path (e.g., "/datasets/mydata") + */ + public static String datasetMetadata(String dataset) { + return "/datasets/" + dataset; + } + + // ==================== Branch URLs ==================== + + /** + * Constructs URL for branch resource. + * + * @param dataset Dataset name + * @param branch Branch name + * @return URL path (e.g., "/mydata/version/branches/main") + */ + public static String branch(String dataset, String branch) { + return String.format("/%s/version/branches/%s", dataset, branch); + } + + /** + * Constructs URL for branch list endpoint. + * + * @param dataset Dataset name + * @return URL path (e.g., "/mydata/version/branches") + */ + public static String branches(String dataset) { + return String.format("/%s/version/branches", dataset); + } + + /** + * Constructs URL for branch history. + * + * @param dataset Dataset name + * @param branch Branch name + * @return URL path (e.g., "/mydata/version/branches/main/history") + */ + public static String branchHistory(String dataset, String branch) { + return String.format("/%s/version/branches/%s/history", dataset, branch); + } + + // ==================== Commit URLs ==================== + + /** + * Constructs URL for commit resource. + * + * @param dataset Dataset name + * @param commitId Commit ID + * @return URL path (e.g., "/mydata/version/commits/01936d8f-1234-7890-abcd-ef1234567890") + */ + public static String commit(String dataset, String commitId) { + return String.format("/%s/version/commits/%s", dataset, commitId); + } + + /** + * Constructs URL for commit resource. + * + * @param dataset Dataset name + * @param commitId Commit ID object + * @return URL path + */ + public static String commit(String dataset, CommitId commitId) { + return commit(dataset, commitId.value()); + } + + /** + * Constructs URL for commit list endpoint. + * + * @param dataset Dataset name + * @return URL path (e.g., "/mydata/version/commits") + */ + public static String commits(String dataset) { + return String.format("/%s/version/commits", dataset); + } + + /** + * Constructs URL for commit history (from specific commit). + * + * @param dataset Dataset name + * @param commitId Commit ID + * @return URL path (e.g., "/mydata/version/commits/01936d8f.../history") + */ + public static String commitHistory(String dataset, String commitId) { + return String.format("/%s/version/commits/%s/history", dataset, commitId); + } + + /** + * Constructs URL for diff between two commits. + * + * @param dataset Dataset name + * @param fromCommit Source commit ID + * @param toCommit Target commit ID + * @return URL path (e.g., "/mydata/version/commits/abc/diff/def") + */ + public static String commitDiff(String dataset, String fromCommit, String toCommit) { + return String.format("/%s/version/commits/%s/diff/%s", dataset, fromCommit, toCommit); + } + + // ==================== Tag URLs ==================== + + /** + * Constructs URL for tag resource. + * + * @param dataset Dataset name + * @param tag Tag name + * @return URL path (e.g., "/mydata/version/tags/v1.0") + */ + public static String tag(String dataset, String tag) { + return String.format("/%s/version/tags/%s", dataset, tag); + } + + /** + * Constructs URL for tag list endpoint. + * + * @param dataset Dataset name + * @return URL path (e.g., "/mydata/version/tags") + */ + public static String tags(String dataset) { + return String.format("/%s/version/tags", dataset); + } + + // ==================== SPARQL Endpoints ==================== + + /** + * Constructs URL for SPARQL query endpoint at branch. + * + * @param dataset Dataset name + * @param branch Branch name + * @return URL path (e.g., "/mydata/version/branches/main/sparql") + */ + public static String sparqlAtBranch(String dataset, String branch) { + return String.format("/%s/version/branches/%s/sparql", dataset, branch); + } + + /** + * Constructs URL for SPARQL query endpoint at commit. + * + * @param dataset Dataset name + * @param commitId Commit ID + * @return URL path (e.g., "/mydata/version/commits/01936d8f.../sparql") + */ + public static String sparqlAtCommit(String dataset, String commitId) { + return String.format("/%s/version/commits/%s/sparql", dataset, commitId); + } + + /** + * Constructs URL for SPARQL query endpoint at tag. + * + * @param dataset Dataset name + * @param tag Tag name + * @return URL path (e.g., "/mydata/version/tags/v1.0/sparql") + */ + public static String sparqlAtTag(String dataset, String tag) { + return String.format("/%s/version/tags/%s/sparql", dataset, tag); + } + + /** + * Constructs URL for SPARQL update endpoint at branch. + * + * @param dataset Dataset name + * @param branch Branch name + * @return URL path (e.g., "/mydata/version/branches/main/update") + */ + public static String updateAtBranch(String dataset, String branch) { + return String.format("/%s/version/branches/%s/update", dataset, branch); + } + + /** + * Constructs URL for current-state SPARQL query (shortcut to main HEAD). + * + * @param dataset Dataset name + * @return URL path (e.g., "/mydata/sparql") + */ + public static String sparql(String dataset) { + return String.format("/%s/sparql", dataset); + } + + /** + * Constructs URL for current-state SPARQL update (shortcut to main branch). + * + * @param dataset Dataset name + * @return URL path (e.g., "/mydata/update") + */ + public static String update(String dataset) { + return String.format("/%s/update", dataset); + } + + // ==================== Graph Store Protocol Endpoints ==================== + + /** + * Constructs URL for GSP endpoint at branch. + * + * @param dataset Dataset name + * @param branch Branch name + * @return URL path (e.g., "/mydata/version/branches/main/data") + */ + public static String dataAtBranch(String dataset, String branch) { + return String.format("/%s/version/branches/%s/data", dataset, branch); + } + + /** + * Constructs URL for GSP endpoint at commit. + * + * @param dataset Dataset name + * @param commitId Commit ID + * @return URL path (e.g., "/mydata/version/commits/01936d8f.../data") + */ + public static String dataAtCommit(String dataset, String commitId) { + return String.format("/%s/version/commits/%s/data", dataset, commitId); + } + + /** + * Constructs URL for GSP endpoint at tag. + * + * @param dataset Dataset name + * @param tag Tag name + * @return URL path (e.g., "/mydata/version/tags/v1.0/data") + */ + public static String dataAtTag(String dataset, String tag) { + return String.format("/%s/version/tags/%s/data", dataset, tag); + } + + /** + * Constructs URL for current-state GSP (shortcut to main HEAD). + * + * @param dataset Dataset name + * @return URL path (e.g., "/mydata/data") + */ + public static String data(String dataset) { + return String.format("/%s/data", dataset); + } + + // ==================== Advanced Operations ==================== + + /** + * Constructs URL for merge endpoint. + * + * @param dataset Dataset name + * @param targetBranch Target branch name + * @return URL path (e.g., "/mydata/version/branches/main/merge") + */ + public static String merge(String dataset, String targetBranch) { + return String.format("/%s/version/branches/%s/merge", dataset, targetBranch); + } + + /** + * Constructs URL for reset endpoint. + * + * @param dataset Dataset name + * @param branch Branch name + * @return URL path (e.g., "/mydata/version/branches/main/reset") + */ + public static String reset(String dataset, String branch) { + return String.format("/%s/version/branches/%s/reset", dataset, branch); + } + + /** + * Constructs URL for revert endpoint. + * + * @param dataset Dataset name + * @param branch Branch name + * @return URL path (e.g., "/mydata/version/branches/main/revert") + */ + public static String revert(String dataset, String branch) { + return String.format("/%s/version/branches/%s/revert", dataset, branch); + } + + /** + * Constructs URL for cherry-pick endpoint. + * + * @param dataset Dataset name + * @param branch Branch name + * @return URL path (e.g., "/mydata/version/branches/main/cherry-pick") + */ + public static String cherryPick(String dataset, String branch) { + return String.format("/%s/version/branches/%s/cherry-pick", dataset, branch); + } + + /** + * Constructs URL for rebase endpoint. + * + * @param dataset Dataset name + * @param branch Branch name + * @return URL path (e.g., "/mydata/version/branches/main/rebase") + */ + public static String rebase(String dataset, String branch) { + return String.format("/%s/version/branches/%s/rebase", dataset, branch); + } + + /** + * Constructs URL for squash endpoint. + * + * @param dataset Dataset name + * @param branch Branch name + * @return URL path (e.g., "/mydata/version/branches/main/squash") + */ + public static String squash(String dataset, String branch) { + return String.format("/%s/version/branches/%s/squash", dataset, branch); + } + + // ==================== Batch Operations ==================== + + /** + * Constructs URL for batch graphs endpoint. + * + * @param dataset Dataset name + * @param branch Branch name + * @return URL path (e.g., "/mydata/version/branches/main/batch-graphs") + */ + public static String batchGraphs(String dataset, String branch) { + return String.format("/%s/version/branches/%s/batch-graphs", dataset, branch); + } + + /** + * Constructs URL for batch SPARQL endpoint. + * + * @param dataset Dataset name + * @param branch Branch name + * @return URL path (e.g., "/mydata/version/branches/main/batch") + */ + public static String batch(String dataset, String branch) { + return String.format("/%s/version/branches/%s/batch", dataset, branch); + } + + // ==================== Refs ==================== + + /** + * Constructs URL for refs list (all branches and tags). + * + * @param dataset Dataset name + * @return URL path (e.g., "/mydata/version/refs") + */ + public static String refs(String dataset) { + return String.format("/%s/version/refs", dataset); + } +} +``` + +--- + +## Task 2: Create Response Header Helpers + +### Location + +**New File:** `src/main/java/org/chucc/vcserver/controller/util/ResponseHeaderBuilder.java` + +### Implementation + +```java +package org.chucc.vcserver.controller.util; + +import org.springframework.http.HttpHeaders; + +/** + * Utility class for building response headers for semantic routing. + *

+ * Provides helper methods to add Content-Location and Link headers that help clients + * discover canonical URLs and related resources (HATEOAS pattern). + *

+ */ +public final class ResponseHeaderBuilder { + + private ResponseHeaderBuilder() { + // Utility class - prevent instantiation + } + + /** + * Adds Content-Location header with canonical URL. + * + * @param headers HTTP headers + * @param canonicalUrl Canonical URL path + */ + public static void addContentLocation(HttpHeaders headers, String canonicalUrl) { + headers.set("Content-Location", canonicalUrl); + } + + /** + * Adds Link header for related resource. + * + * @param headers HTTP headers + * @param url Related resource URL + * @param rel Relationship type (e.g., "version", "branch", "commit") + */ + public static void addLink(HttpHeaders headers, String url, String rel) { + String linkValue = String.format("<%s>; rel=\"%s\"", url, rel); + headers.add("Link", linkValue); + } + + /** + * Adds Link header for commit version. + * + * @param headers HTTP headers + * @param dataset Dataset name + * @param commitId Commit ID + */ + public static void addCommitLink(HttpHeaders headers, String dataset, String commitId) { + addLink(headers, VersionControlUrls.commit(dataset, commitId), "version"); + } + + /** + * Adds Link header for branch. + * + * @param headers HTTP headers + * @param dataset Dataset name + * @param branch Branch name + */ + public static void addBranchLink(HttpHeaders headers, String dataset, String branch) { + addLink(headers, VersionControlUrls.branch(dataset, branch), "branch"); + } + + /** + * Adds Link header for tag. + * + * @param headers HTTP headers + * @param dataset Dataset name + * @param tag Tag name + */ + public static void addTagLink(HttpHeaders headers, String dataset, String tag) { + addLink(headers, VersionControlUrls.tag(dataset, tag), "tag"); + } +} +``` + +--- + +## Task 3: Create Unit Tests + +### Location + +**New File:** `src/test/java/org/chucc/vcserver/controller/util/VersionControlUrlsTest.java` + +### Implementation + +```java +package org.chucc.vcserver.controller.util; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.chucc.vcserver.domain.CommitId; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link VersionControlUrls}. + */ +class VersionControlUrlsTest { + + @Test + void dataset_shouldBuildCorrectUrl() { + assertThat(VersionControlUrls.dataset("mydata")) + .isEqualTo("/mydata"); + } + + @Test + void branch_shouldBuildCorrectUrl() { + assertThat(VersionControlUrls.branch("mydata", "main")) + .isEqualTo("/mydata/version/branches/main"); + } + + @Test + void commit_shouldBuildCorrectUrl() { + String commitId = "01936d8f-1234-7890-abcd-ef1234567890"; + assertThat(VersionControlUrls.commit("mydata", commitId)) + .isEqualTo("/mydata/version/commits/01936d8f-1234-7890-abcd-ef1234567890"); + } + + @Test + void commitWithObject_shouldBuildCorrectUrl() { + CommitId commitId = CommitId.fromString("01936d8f-1234-7890-abcd-ef1234567890"); + assertThat(VersionControlUrls.commit("mydata", commitId)) + .isEqualTo("/mydata/version/commits/01936d8f-1234-7890-abcd-ef1234567890"); + } + + @Test + void tag_shouldBuildCorrectUrl() { + assertThat(VersionControlUrls.tag("mydata", "v1.0")) + .isEqualTo("/mydata/version/tags/v1.0"); + } + + @Test + void sparqlAtBranch_shouldBuildCorrectUrl() { + assertThat(VersionControlUrls.sparqlAtBranch("mydata", "main")) + .isEqualTo("/mydata/version/branches/main/sparql"); + } + + @Test + void sparqlAtCommit_shouldBuildCorrectUrl() { + String commitId = "01936d8f-1234-7890-abcd-ef1234567890"; + assertThat(VersionControlUrls.sparqlAtCommit("mydata", commitId)) + .isEqualTo("/mydata/version/commits/01936d8f-1234-7890-abcd-ef1234567890/sparql"); + } + + @Test + void dataAtBranch_shouldBuildCorrectUrl() { + assertThat(VersionControlUrls.dataAtBranch("mydata", "main")) + .isEqualTo("/mydata/version/branches/main/data"); + } + + @Test + void commitDiff_shouldBuildCorrectUrl() { + assertThat(VersionControlUrls.commitDiff("mydata", "abc", "def")) + .isEqualTo("/mydata/version/commits/abc/diff/def"); + } + + @Test + void merge_shouldBuildCorrectUrl() { + assertThat(VersionControlUrls.merge("mydata", "main")) + .isEqualTo("/mydata/version/branches/main/merge"); + } + + @Test + void batchGraphs_shouldBuildCorrectUrl() { + assertThat(VersionControlUrls.batchGraphs("mydata", "develop")) + .isEqualTo("/mydata/version/branches/develop/batch-graphs"); + } +} +``` + +--- + +## Task 4: Update OpenAPI Annotations (Example) + +Update controller OpenAPI annotations to reflect new URL patterns: + +### Example: BranchController + +**File:** `src/main/java/org/chucc/vcserver/controller/BranchController.java` + +**Before:** +```java +@Tag(name = "Version Control - Branches", description = "Branch management operations") +@RestController +@RequestMapping("/version/branches") +public class BranchController { + + @Operation(summary = "Get branch details") + @GetMapping("/{name}") + public ResponseEntity getBranch( + @RequestParam(defaultValue = "default") String dataset, + @PathVariable String name) { + // ... + } +} +``` + +**After:** +```java +@Tag(name = "Version Control - Branches", description = "Branch management operations") +@RestController +@RequestMapping("/{dataset}/version/branches") +public class BranchController { + + @Operation( + summary = "Get branch details", + description = "Returns metadata for a specific branch including HEAD commit ID, protection status, and timestamps." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Branch details returned successfully"), + @ApiResponse(responseCode = "404", description = "Branch or dataset not found") + }) + @GetMapping("/{name}") + public ResponseEntity getBranch( + @Parameter(description = "Dataset name", example = "default") + @PathVariable String dataset, + + @Parameter(description = "Branch name", example = "main") + @PathVariable String name) { + // ... + } +} +``` + +**Note:** This task only updates OpenAPI annotations. Actual routing changes happen in subsequent sessions. + +--- + +## Task 5: Validate OpenAPI Spec + +After updates, validate that OpenAPI spec is correct: + +```bash +# Run application +mvn -q spring-boot:run + +# Access Swagger UI (in separate terminal) +curl http://localhost:8080/v3/api-docs + +# Should return valid OpenAPI 3.0 JSON without errors +``` + +**Validation points:** +- ✅ All path parameters documented +- ✅ Example values provided +- ✅ Response codes documented +- ✅ No duplicate operation IDs + +--- + +## Testing Strategy + +### Unit Tests + +```bash +mvn -q test -Dtest=VersionControlUrlsTest +``` + +**Expected:** All tests pass + +### Integration Tests + +No integration tests needed for this session (utility classes only). + +--- + +## Quality Gates + +```bash +# Full build must pass +mvn -q clean install + +# Specific checks: +mvn -q compile checkstyle:check # Zero violations +mvn -q spotbugs:check # Zero warnings +mvn -q pmd:check # Zero violations +``` + +--- + +## Completion Checklist + +- [ ] `VersionControlUrls` class created with all URL builder methods +- [ ] `ResponseHeaderBuilder` class created with header helpers +- [ ] Unit tests for `VersionControlUrls` pass +- [ ] OpenAPI annotations updated with path parameters +- [ ] OpenAPI spec validates (no errors) +- [ ] Javadoc complete for all public methods +- [ ] All quality gates pass (Checkstyle, SpotBugs, PMD) +- [ ] Build succeeds (`mvn -q clean install`) + +--- + +## Next Session + +**Session 2:** [Dataset Parameter Standardization](./session-2-dataset-parameter-standardization.md) +- Use `VersionControlUrls` to build response URLs +- Add `Content-Location` headers using `ResponseHeaderBuilder` + +--- + +**Estimated Time:** 3-4 hours +**Priority:** High (foundation for all routing changes) diff --git a/.tasks/routing-refactoring/session-2-dataset-parameter-standardization.md b/.tasks/routing-refactoring/session-2-dataset-parameter-standardization.md new file mode 100644 index 0000000..c67b064 --- /dev/null +++ b/.tasks/routing-refactoring/session-2-dataset-parameter-standardization.md @@ -0,0 +1,482 @@ +# Session 2: Dataset Parameter Standardization + +**Status:** ⏳ Not Started +**Priority:** High (Prerequisite for versioned routing) +**Estimated Time:** 4-5 hours +**Complexity:** Medium-High + +--- + +## Overview + +Standardize dataset addressing across all controllers by moving dataset from query parameter to path variable. This aligns the codebase with Fuseki patterns (`/{dataset}/{service}`) and establishes consistent dataset routing before adding version-aware endpoints. + +**Current Problem:** +- Most controllers: `@RequestParam String dataset` (default: "default") +- TagController: `@PathVariable String dataset` +- **Inconsistent!** + +**Target State:** +- All controllers: `@PathVariable String dataset` (no default) +- Request mapping: `/{dataset}/version/...` + +--- + +## Deliverables + +1. ✅ Refactor all controllers to use path-based dataset parameter +2. ✅ Update integration tests for new URL patterns +3. ✅ Add backward compatibility support (query param still works, deprecated) +4. ✅ Add `Content-Location` headers pointing to new URLs +5. ✅ All tests pass + +--- + +## Affected Controllers + +| Controller | Current Mapping | Target Mapping | Files to Update | +|------------|-----------------|----------------|-----------------| +| BranchController | `/version/branches` + `?dataset=` | `/{dataset}/version/branches` | Controller + 4 tests | +| CommitController | `/version/commits` + `?dataset=` | `/{dataset}/version/commits` | Controller + 3 tests | +| TagController | `/{dataset}/version/tags` | ✅ Already correct | Review only | +| MergeController | `/version/merge` + `?dataset=` | `/{dataset}/version/merge` | Controller + 5 tests | +| AdvancedOpsController | `/version/*` + `?dataset=` | `/{dataset}/version/*` | Controller + 8 tests | +| HistoryController | `/version/history` + `?dataset=` | `/{dataset}/version/history` | Controller + 3 tests | +| BatchOpsController | `/version/batch*` + `?dataset=` | `/{dataset}/version/batch*` | Controller + 2 tests | +| DatasetController | `/version/datasets` | `/datasets` + `/{name}` | Controller + 2 tests | + +**Total:** 8 controllers, ~30 integration tests + +--- + +## Task 1: Refactor BranchController + +### Current Implementation + +**File:** `src/main/java/org/chucc/vcserver/controller/BranchController.java` + +```java +@RestController +@RequestMapping("/version/branches") +public class BranchController { + + @GetMapping + public ResponseEntity> listBranches( + @RequestParam(defaultValue = "default") String dataset) { + // ... + } + + @PostMapping + public ResponseEntity createBranch( + @RequestParam(defaultValue = "default") String dataset, + @RequestBody CreateBranchRequest request) { + // ... + } + + @GetMapping("/{name}") + public ResponseEntity getBranch( + @RequestParam(defaultValue = "default") String dataset, + @PathVariable String name) { + // ... + } + + @DeleteMapping("/{name}") + public ResponseEntity deleteBranch( + @RequestParam(defaultValue = "default") String dataset, + @PathVariable String name) { + // ... + } +} +``` + +### Target Implementation + +```java +@RestController +@RequestMapping("/{dataset}/version/branches") +public class BranchController { + + @GetMapping + public ResponseEntity> listBranches( + @PathVariable String dataset) { + + List branches = branchService.listBranches(dataset); + + // Add Content-Location header with canonical URL + HttpHeaders headers = new HttpHeaders(); + ResponseHeaderBuilder.addContentLocation( + headers, + VersionControlUrls.branches(dataset) + ); + + return ResponseEntity.ok().headers(headers).body(branches); + } + + @PostMapping + public ResponseEntity createBranch( + @PathVariable String dataset, + @RequestBody CreateBranchRequest request) { + + BranchInfo branch = branchService.createBranch(dataset, request); + + // Add Content-Location with new branch URL + HttpHeaders headers = new HttpHeaders(); + ResponseHeaderBuilder.addContentLocation( + headers, + VersionControlUrls.branch(dataset, branch.name()) + ); + + return ResponseEntity.status(HttpStatus.CREATED) + .headers(headers) + .body(branch); + } + + @GetMapping("/{name}") + public ResponseEntity getBranch( + @PathVariable String dataset, + @PathVariable String name) { + + BranchInfo branch = branchService.getBranch(dataset, name); + + // Add Content-Location and Link headers + HttpHeaders headers = new HttpHeaders(); + ResponseHeaderBuilder.addContentLocation( + headers, + VersionControlUrls.branch(dataset, name) + ); + ResponseHeaderBuilder.addCommitLink( + headers, + dataset, + branch.commitId() + ); + + return ResponseEntity.ok().headers(headers).body(branch); + } + + @DeleteMapping("/{name}") + public ResponseEntity deleteBranch( + @PathVariable String dataset, + @PathVariable String name) { + + branchService.deleteBranch(dataset, name); + return ResponseEntity.noContent().build(); + } +} +``` + +### Update Integration Tests + +**File:** `src/test/java/org/chucc/vcserver/controller/BranchControllerIT.java` + +**Before:** +```java +@Test +void listBranches_shouldReturnAllBranches() { + String url = "/version/branches?dataset=default"; + ResponseEntity response = restTemplate.getForEntity(url, String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); +} +``` + +**After:** +```java +@Test +void listBranches_newPattern_shouldReturnAllBranches() { + // New pattern: dataset in path + String url = "/default/version/branches"; + ResponseEntity response = restTemplate.getForEntity(url, String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getHeaders().get("Content-Location")) + .containsExactly("/default/version/branches"); +} + +@Test +void listBranches_oldPattern_shouldStillWork() { + // Old pattern: dataset as query param (deprecated) + String url = "/version/branches?dataset=default"; + ResponseEntity response = restTemplate.getForEntity(url, String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + // Should redirect or include new URL in Content-Location + assertThat(response.getHeaders().get("Content-Location")) + .containsExactly("/default/version/branches"); +} +``` + +--- + +## Task 2: Add Backward Compatibility Layer + +Create a separate controller for old query-param pattern: + +### New File: `BranchControllerLegacy.java` + +**Location:** `src/main/java/org/chucc/vcserver/controller/legacy/BranchControllerLegacy.java` + +```java +package org.chucc.vcserver.controller.legacy; + +import org.chucc.vcserver.controller.BranchController; +import org.chucc.vcserver.controller.util.ResponseHeaderBuilder; +import org.chucc.vcserver.controller.util.VersionControlUrls; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +/** + * Legacy backward compatibility controller for branch operations. + *

+ * Supports old query-parameter style routing for dataset parameter. + * This controller is deprecated and will be removed in v2.0. + *

+ *

+ * Clients should migrate to new path-based routing: + *

    + *
  • Old: {@code GET /version/branches?dataset=mydata}
  • + *
  • New: {@code GET /mydata/version/branches}
  • + *
+ *

+ * + * @deprecated Use {@link BranchController} with path-based dataset parameter + */ +@Deprecated(since = "1.0", forRemoval = true) +@RestController +@RequestMapping("/version/branches") +public class BranchControllerLegacy { + + private final BranchController delegate; + + public BranchControllerLegacy(BranchController delegate) { + this.delegate = delegate; + } + + /** + * List branches (legacy query-param style). + * + * @param dataset Dataset name (query parameter, deprecated) + * @return Branches list with Content-Location pointing to new URL + * @deprecated Use {@code GET /{dataset}/version/branches} + */ + @Deprecated + @GetMapping + public ResponseEntity listBranchesLegacy( + @RequestParam(defaultValue = "default") String dataset) { + + // Delegate to new controller + ResponseEntity response = delegate.listBranches(dataset); + + // Add headers pointing to new URL + HttpHeaders headers = new HttpHeaders(); + headers.putAll(response.getHeaders()); + headers.set("Deprecation", "true"); + headers.set("Link", String.format("<%s>; rel=\"canonical\"", + VersionControlUrls.branches(dataset))); + + return ResponseEntity.status(response.getStatusCode()) + .headers(headers) + .body(response.getBody()); + } + + // Similar methods for other operations... +} +``` + +**Benefits:** +- ✅ Old clients continue working +- ✅ Clear deprecation signals (`Deprecation` header) +- ✅ Points clients to new URL (`Link` header, `Content-Location`) +- ✅ Easy to remove in v2.0 (delete package) + +--- + +## Task 3: Refactor Remaining Controllers + +Apply the same pattern to: + +1. **CommitController** - `/{dataset}/version/commits` +2. **MergeController** - `/{dataset}/version/merge` +3. **AdvancedOpsController** - `/{dataset}/version/reset`, `/revert`, etc. +4. **HistoryController** - `/{dataset}/version/history`, `/diff` +5. **BatchOpsController** - `/{dataset}/version/batch*` +6. **DatasetController** - Move from `/version/datasets` to `/datasets` + +**Note:** TagController already uses path parameter - only needs review. + +--- + +## Task 4: Update All Integration Tests + +For each controller, update tests: + +```java +@Test +void operation_newPattern_shouldWork() { + String url = "/default/version/branches/main"; + ResponseEntity response = restTemplate.getForEntity(url, String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getHeaders().get("Content-Location")).isNotNull(); +} + +@Test +void operation_oldPattern_shouldStillWork() { + String url = "/version/branches/main?dataset=default"; + ResponseEntity response = restTemplate.getForEntity(url, String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getHeaders().get("Deprecation")).containsExactly("true"); + assertThat(response.getHeaders().get("Content-Location")) + .containsExactly("/default/version/branches/main"); +} +``` + +**Affected Test Files (~30 files):** +- BranchControllerIT +- CommitControllerIT +- MergeControllerIT +- ResetIntegrationTest +- RevertIntegrationTest +- CherryPickIntegrationTest +- RebaseIntegrationTest +- SquashIntegrationTest +- HistoryControllerIT +- DiffControllerIT +- BatchOperationsIT +- DatasetCreationIT +- ...and more + +--- + +## Task 5: Update OpenAPI Documentation + +Update `@RequestMapping` and parameter annotations: + +```java +@Operation( + summary = "List all branches", + description = "Returns list of all branches in the dataset." +) +@ApiResponses({ + @ApiResponse(responseCode = "200", description = "Branch list returned"), + @ApiResponse(responseCode = "404", description = "Dataset not found") +}) +@GetMapping +public ResponseEntity> listBranches( + @Parameter( + description = "Dataset name", + example = "default", + required = true + ) + @PathVariable String dataset) { + // ... +} +``` + +--- + +## Testing Strategy + +### Step 1: Unit Tests +```bash +mvn -q test -Dtest=BranchControllerTest +``` + +### Step 2: Integration Tests (New Pattern) +```bash +mvn -q test -Dtest=BranchControllerIT +``` + +### Step 3: Integration Tests (Legacy Pattern) +```bash +mvn -q test -Dtest=BranchControllerLegacyIT +``` + +### Step 4: Full Build +```bash +mvn -q clean install +``` + +--- + +## Quality Gates + +```bash +# Static analysis +mvn -q compile checkstyle:check spotbugs:check pmd:check + +# All tests +mvn -q clean install + +# Expected: BUILD SUCCESS +``` + +--- + +## Completion Checklist + +- [ ] BranchController refactored to path-based dataset +- [ ] CommitController refactored +- [ ] MergeController refactored +- [ ] AdvancedOpsController refactored +- [ ] HistoryController refactored +- [ ] BatchOpsController refactored +- [ ] DatasetController refactored +- [ ] TagController reviewed (already correct) +- [ ] Legacy controllers created for backward compatibility +- [ ] All integration tests updated +- [ ] New pattern tests pass +- [ ] Legacy pattern tests pass +- [ ] OpenAPI documentation updated +- [ ] `Content-Location` headers added +- [ ] `Link` headers added for deprecated endpoints +- [ ] All quality gates pass + +--- + +## Migration Impact + +### For Clients + +**Breaking Change?** No (v1.0 maintains backward compatibility) + +**Old URLs (still work):** +``` +GET /version/branches?dataset=mydata +GET /version/commits?dataset=mydata +``` + +**New URLs (recommended):** +``` +GET /mydata/version/branches +GET /mydata/version/commits +``` + +**Response headers guide clients:** +```http +Deprecation: true +Link: ; rel="canonical" +Content-Location: /mydata/version/branches +``` + +### For Developers + +**Code changes required:** +- Update integration tests to use new URLs +- Update any hardcoded URLs in code +- Use `VersionControlUrls` utility for URL construction + +--- + +## Next Session + +**Session 3:** [Versioned SPARQL Endpoints](./session-3-versioned-sparql-endpoints.md) +- Add `/{dataset}/version/branches/{name}/sparql` +- Add `/{dataset}/version/commits/{id}/sparql` +- Add `/{dataset}/version/tags/{name}/sparql` + +--- + +**Estimated Time:** 4-5 hours +**Priority:** High diff --git a/.tasks/routing-refactoring/session-3-versioned-sparql-endpoints.md b/.tasks/routing-refactoring/session-3-versioned-sparql-endpoints.md new file mode 100644 index 0000000..a6d779b --- /dev/null +++ b/.tasks/routing-refactoring/session-3-versioned-sparql-endpoints.md @@ -0,0 +1,637 @@ +# Session 3: Versioned SPARQL Endpoints + +**Status:** ⏳ Not Started +**Priority:** High (Core functionality) +**Estimated Time:** 5-6 hours +**Complexity:** Medium-High +**Dependencies:** Session 1 (URL utilities), Session 2 (dataset parameter) + +--- + +## Overview + +Implement versioned SPARQL query and update endpoints that allow querying at specific branches, commits, or tags. This is the core feature that makes dataset states shareable and citable via immutable URLs. + +**Goal:** Users can query/update at any version reference using RESTful URLs. + +--- + +## Deliverables + +1. ✅ `GET/POST /{dataset}/version/branches/{name}/sparql` - Query at branch +2. ✅ `GET/POST /{dataset}/version/commits/{id}/sparql` - Query at commit (immutable) +3. ✅ `GET/POST /{dataset}/version/tags/{name}/sparql` - Query at tag +4. ✅ `POST /{dataset}/version/branches/{name}/update` - Update branch +5. ✅ Integration tests for all endpoints +6. ✅ Immutability enforcement (no updates to commits/tags) + +--- + +## Background: Current SPARQL Routing + +### Current Implementation + +**File:** `src/main/java/org/chucc/vcserver/controller/SparqlController.java` + +**Current pattern:** +``` +GET/POST /sparql?query=...&dataset=default&branch=main +GET/POST /sparql?query=...&dataset=default&commit=01936d8f... +POST /update?update=...&dataset=default&branch=main +``` + +**Selectors:** Query parameters `branch`, `commit`, `asOf` + +--- + +## Task 1: Create Versioned SPARQL Controller + +### New File: `VersionedSparqlController.java` + +**Location:** `src/main/java/org/chucc/vcserver/controller/VersionedSparqlController.java` + +```java +package org.chucc.vcserver.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.chucc.vcserver.controller.util.ResponseHeaderBuilder; +import org.chucc.vcserver.controller.util.VersionControlUrls; +import org.chucc.vcserver.service.SparqlQueryService; +import org.chucc.vcserver.service.SparqlUpdateService; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.time.OffsetDateTime; +import java.util.Optional; + +/** + * Versioned SPARQL query and update endpoints. + *

+ * Supports querying and updating at specific version references: + *

    + *
  • Branch: {@code /{dataset}/version/branches/{name}/sparql}
  • + *
  • Commit: {@code /{dataset}/version/commits/{id}/sparql} (read-only)
  • + *
  • Tag: {@code /{dataset}/version/tags/{name}/sparql} (read-only)
  • + *
+ *

+ * + * @see Semantic Routing + */ +@Tag(name = "SPARQL Protocol - Versioned", description = "SPARQL operations at specific versions") +@RestController +public class VersionedSparqlController { + + private final SparqlQueryService queryService; + private final SparqlUpdateService updateService; + + public VersionedSparqlController( + SparqlQueryService queryService, + SparqlUpdateService updateService) { + this.queryService = queryService; + this.updateService = updateService; + } + + // ==================== Query at Branch ==================== + + /** + * Execute SPARQL query at branch HEAD. + * + * @param dataset Dataset name + * @param branch Branch name + * @param query SPARQL query string + * @param asOf Time-travel timestamp (optional) + * @return Query results + */ + @Operation( + summary = "Query dataset at branch", + description = "Execute SPARQL query against branch HEAD. Supports time-travel with asOf parameter." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Query executed successfully"), + @ApiResponse(responseCode = "400", description = "Invalid SPARQL query"), + @ApiResponse(responseCode = "404", description = "Branch or dataset not found") + }) + @GetMapping("/{dataset}/version/branches/{branch}/sparql") + public ResponseEntity queryAtBranch( + @Parameter(description = "Dataset name", example = "default", required = true) + @PathVariable String dataset, + + @Parameter(description = "Branch name", example = "main", required = true) + @PathVariable String branch, + + @Parameter(description = "SPARQL query", required = true) + @RequestParam String query, + + @Parameter(description = "Time-travel timestamp (ISO 8601)", example = "2025-11-03T10:00:00Z") + @RequestParam(required = false) OffsetDateTime asOf) { + + // Execute query + String results = queryService.executeQuery(dataset, branch, Optional.ofNullable(asOf), query); + + // Build response headers + HttpHeaders headers = new HttpHeaders(); + ResponseHeaderBuilder.addContentLocation( + headers, + VersionControlUrls.sparqlAtBranch(dataset, branch) + ); + ResponseHeaderBuilder.addBranchLink(headers, dataset, branch); + + // Get current commit ID and add version link + String commitId = queryService.getCurrentCommit(dataset, branch); + ResponseHeaderBuilder.addCommitLink(headers, dataset, commitId); + + return ResponseEntity.ok() + .headers(headers) + .contentType(MediaType.parseMediaType("application/sparql-results+json")) + .body(results); + } + + /** + * Execute SPARQL query at branch HEAD (POST form). + * + * @param dataset Dataset name + * @param branch Branch name + * @param query SPARQL query string (from POST body) + * @param asOf Time-travel timestamp (optional) + * @return Query results + */ + @PostMapping( + value = "/{dataset}/version/branches/{branch}/sparql", + consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE + ) + public ResponseEntity queryAtBranchPost( + @PathVariable String dataset, + @PathVariable String branch, + @RequestParam String query, + @RequestParam(required = false) OffsetDateTime asOf) { + + // Delegate to GET handler + return queryAtBranch(dataset, branch, query, asOf); + } + + // ==================== Query at Commit ==================== + + /** + * Execute SPARQL query at specific commit (immutable). + * + * @param dataset Dataset name + * @param commitId Commit ID (UUIDv7) + * @param query SPARQL query string + * @return Query results + */ + @Operation( + summary = "Query dataset at commit", + description = "Execute SPARQL query against specific commit. **Immutable** - perfect for citations!" + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Query executed successfully"), + @ApiResponse(responseCode = "400", description = "Invalid SPARQL query or commit ID"), + @ApiResponse(responseCode = "404", description = "Commit or dataset not found") + }) + @GetMapping("/{dataset}/version/commits/{commitId}/sparql") + public ResponseEntity queryAtCommit( + @Parameter(description = "Dataset name", example = "default", required = true) + @PathVariable String dataset, + + @Parameter( + description = "Commit ID (UUIDv7)", + example = "01936d8f-1234-7890-abcd-ef1234567890", + required = true + ) + @PathVariable String commitId, + + @Parameter(description = "SPARQL query", required = true) + @RequestParam String query) { + + // Execute query at commit + String results = queryService.executeQueryAtCommit(dataset, commitId, query); + + // Build response headers + HttpHeaders headers = new HttpHeaders(); + ResponseHeaderBuilder.addContentLocation( + headers, + VersionControlUrls.sparqlAtCommit(dataset, commitId) + ); + ResponseHeaderBuilder.addCommitLink(headers, dataset, commitId); + + // Add Cache-Control header (commits are immutable!) + headers.setCacheControl("public, max-age=31536000, immutable"); + + return ResponseEntity.ok() + .headers(headers) + .contentType(MediaType.parseMediaType("application/sparql-results+json")) + .body(results); + } + + /** + * Execute SPARQL query at commit (POST form). + */ + @PostMapping( + value = "/{dataset}/version/commits/{commitId}/sparql", + consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE + ) + public ResponseEntity queryAtCommitPost( + @PathVariable String dataset, + @PathVariable String commitId, + @RequestParam String query) { + + return queryAtCommit(dataset, commitId, query); + } + + // ==================== Query at Tag ==================== + + /** + * Execute SPARQL query at tagged version. + * + * @param dataset Dataset name + * @param tag Tag name + * @param query SPARQL query string + * @return Query results + */ + @Operation( + summary = "Query dataset at tag", + description = "Execute SPARQL query against tagged version." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Query executed successfully"), + @ApiResponse(responseCode = "400", description = "Invalid SPARQL query"), + @ApiResponse(responseCode = "404", description = "Tag or dataset not found") + }) + @GetMapping("/{dataset}/version/tags/{tag}/sparql") + public ResponseEntity queryAtTag( + @Parameter(description = "Dataset name", example = "default", required = true) + @PathVariable String dataset, + + @Parameter(description = "Tag name", example = "v1.0", required = true) + @PathVariable String tag, + + @Parameter(description = "SPARQL query", required = true) + @RequestParam String query) { + + // Execute query at tag + String results = queryService.executeQueryAtTag(dataset, tag, query); + + // Get commit ID for this tag + String commitId = queryService.getTagCommit(dataset, tag); + + // Build response headers + HttpHeaders headers = new HttpHeaders(); + ResponseHeaderBuilder.addContentLocation( + headers, + VersionControlUrls.sparqlAtTag(dataset, tag) + ); + ResponseHeaderBuilder.addTagLink(headers, dataset, tag); + ResponseHeaderBuilder.addCommitLink(headers, dataset, commitId); + + return ResponseEntity.ok() + .headers(headers) + .contentType(MediaType.parseMediaType("application/sparql-results+json")) + .body(results); + } + + /** + * Execute SPARQL query at tag (POST form). + */ + @PostMapping( + value = "/{dataset}/version/tags/{tag}/sparql", + consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE + ) + public ResponseEntity queryAtTagPost( + @PathVariable String dataset, + @PathVariable String tag, + @RequestParam String query) { + + return queryAtTag(dataset, tag, query); + } + + // ==================== Update at Branch ==================== + + /** + * Execute SPARQL update at branch (creates commit). + * + * @param dataset Dataset name + * @param branch Branch name + * @param update SPARQL update string + * @param author Commit author (header) + * @param message Commit message (header, optional) + * @return Update response with commit ID + */ + @Operation( + summary = "Update dataset at branch", + description = "Execute SPARQL update against branch HEAD. Creates new commit automatically." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Update executed, commit created"), + @ApiResponse(responseCode = "204", description = "No-op (no changes detected)"), + @ApiResponse(responseCode = "400", description = "Invalid SPARQL update"), + @ApiResponse(responseCode = "404", description = "Branch or dataset not found"), + @ApiResponse(responseCode = "409", description = "Conflict (use If-Match header)") + }) + @PostMapping( + value = "/{dataset}/version/branches/{branch}/update", + consumes = "application/sparql-update" + ) + public ResponseEntity updateAtBranch( + @PathVariable String dataset, + @PathVariable String branch, + + @RequestBody String update, + + @RequestHeader(value = "SPARQL-VC-Author", required = false) String author, + @RequestHeader(value = "SPARQL-VC-Message", required = false) String message, + @RequestHeader(value = "If-Match", required = false) String ifMatch) { + + // Execute update (creates commit) + String commitId = updateService.executeUpdate( + dataset, + branch, + update, + Optional.ofNullable(author).orElse("anonymous"), + Optional.ofNullable(message).orElse("SPARQL update"), + Optional.ofNullable(ifMatch) + ); + + // Build response headers + HttpHeaders headers = new HttpHeaders(); + ResponseHeaderBuilder.addContentLocation( + headers, + VersionControlUrls.updateAtBranch(dataset, branch) + ); + ResponseHeaderBuilder.addBranchLink(headers, dataset, branch); + ResponseHeaderBuilder.addCommitLink(headers, dataset, commitId); + + // Add Location header pointing to new commit + headers.setLocation( + java.net.URI.create(VersionControlUrls.commit(dataset, commitId)) + ); + + return ResponseEntity.ok() + .headers(headers) + .body(String.format("{\"commitId\": \"%s\"}", commitId)); + } +} +``` + +--- + +## Task 2: Update SparqlQueryService + +Add methods to support version-aware queries: + +**File:** `src/main/java/org/chucc/vcserver/service/SparqlQueryService.java` + +```java +/** + * Execute query at specific commit (immutable). + * + * @param dataset Dataset name + * @param commitId Commit ID + * @param query SPARQL query + * @return Query results + */ +public String executeQueryAtCommit(String dataset, String commitId, String query) { + // Get materialized view at commit + DatasetGraph datasetGraph = materializedViewService.getViewAtCommit(dataset, commitId); + + // Execute query + return executeQueryOnDataset(datasetGraph, query); +} + +/** + * Execute query at tag. + * + * @param dataset Dataset name + * @param tag Tag name + * @param query SPARQL query + * @return Query results + */ +public String executeQueryAtTag(String dataset, String tag, String query) { + // Resolve tag to commit + String commitId = tagRepository.findByDatasetAndName(dataset, tag) + .orElseThrow(() -> new TagNotFoundException(dataset, tag)) + .commitId(); + + // Delegate to commit query + return executeQueryAtCommit(dataset, commitId, query); +} + +/** + * Get commit ID for tag. + */ +public String getTagCommit(String dataset, String tag) { + return tagRepository.findByDatasetAndName(dataset, tag) + .orElseThrow(() -> new TagNotFoundException(dataset, tag)) + .commitId(); +} +``` + +--- + +## Task 3: Create Integration Tests + +### New File: `VersionedSparqlControllerIT.java` + +**Location:** `src/test/java/org/chucc/vcserver/controller/VersionedSparqlControllerIT.java` + +```java +@SpringBootTest(webEnvironment = RANDOM_PORT) +@ActiveProfiles("it") +class VersionedSparqlControllerIT extends IntegrationTestFixture { + + @Test + void queryAtBranch_shouldReturnResults() { + // Arrange: Insert test data + String insertQuery = "INSERT DATA { \"test\" }"; + sparqlUpdateService.executeUpdate(DATASET, "main", insertQuery, "test", "Add test data", Optional.empty()); + + // Act: Query at branch + String url = "/default/version/branches/main/sparql?query=" + + URLEncoder.encode("SELECT * WHERE { ?s ?p ?o }", StandardCharsets.UTF_8); + + ResponseEntity response = restTemplate.getForEntity(url, String.class); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getHeaders().get("Content-Location")) + .containsExactly("/default/version/branches/main/sparql"); + assertThat(response.getBody()).contains("http://example.org/s"); + } + + @Test + void queryAtCommit_shouldReturnImmutableResults() { + // Arrange: Create commit + String insertQuery = "INSERT DATA { \"v1\" }"; + String commitId = sparqlUpdateService.executeUpdate( + DATASET, "main", insertQuery, "test", "Version 1", Optional.empty()); + + // Act: Query at commit + String url = String.format("/default/version/commits/%s/sparql?query=%s", + commitId, + URLEncoder.encode("SELECT * WHERE { ?s ?p ?o }", StandardCharsets.UTF_8)); + + ResponseEntity response = restTemplate.getForEntity(url, String.class); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getHeaders().get("Content-Location")) + .contains("/default/version/commits/" + commitId + "/sparql"); + assertThat(response.getHeaders().getCacheControl()) + .contains("immutable"); // Immutable! + assertThat(response.getBody()).contains("http://example.org/s"); + } + + @Test + void queryAtCommit_afterSubsequentChanges_shouldReturnOriginalState() { + // Arrange: Create commit v1 + String insertV1 = "INSERT DATA { \"v1\" }"; + String commitIdV1 = sparqlUpdateService.executeUpdate( + DATASET, "main", insertV1, "test", "Version 1", Optional.empty()); + + // Arrange: Create commit v2 (different data) + String deleteV1 = "DELETE DATA { \"v1\" }"; + String insertV2 = "INSERT DATA { \"v2\" }"; + sparqlUpdateService.executeUpdate( + DATASET, "main", deleteV1 + "; " + insertV2, "test", "Version 2", Optional.empty()); + + // Act: Query at commit v1 (should still show v1, not v2) + String url = String.format("/default/version/commits/%s/sparql?query=%s", + commitIdV1, + URLEncoder.encode("SELECT * WHERE { ?s ?p ?o }", StandardCharsets.UTF_8)); + + ResponseEntity response = restTemplate.getForEntity(url, String.class); + + // Assert: Should return v1 data, not v2! + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).contains("\"v1\""); + assertThat(response.getBody()).doesNotContain("\"v2\""); + } + + @Test + void queryAtTag_shouldReturnResults() { + // Arrange: Create commit and tag + String insertQuery = "INSERT DATA { \"v1.0\" }"; + String commitId = sparqlUpdateService.executeUpdate( + DATASET, "main", insertQuery, "test", "Release 1.0", Optional.empty()); + + tagService.createTag(DATASET, "v1.0", commitId, "test", "First release", true); + + // Act: Query at tag + String url = "/default/version/tags/v1.0/sparql?query=" + + URLEncoder.encode("SELECT * WHERE { ?s ?p ?o }", StandardCharsets.UTF_8); + + ResponseEntity response = restTemplate.getForEntity(url, String.class); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getHeaders().get("Content-Location")) + .containsExactly("/default/version/tags/v1.0/sparql"); + assertThat(response.getBody()).contains("\"v1.0\""); + } + + @Test + void updateAtBranch_shouldCreateCommit() { + // Act: Execute update + String url = "/default/version/branches/main/update"; + String updateQuery = "INSERT DATA { \"new\" }"; + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.parseMediaType("application/sparql-update")); + headers.set("SPARQL-VC-Author", "TestUser "); + headers.set("SPARQL-VC-Message", "Add new data"); + + HttpEntity request = new HttpEntity<>(updateQuery, headers); + ResponseEntity response = restTemplate.postForEntity(url, request, String.class); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getHeaders().get("Content-Location")) + .contains("/default/version/branches/main/update"); + assertThat(response.getHeaders().getLocation()).isNotNull(); // Points to new commit + assertThat(response.getBody()).contains("commitId"); + } + + @Test + void updateAtCommit_shouldReturn405MethodNotAllowed() { + // Act: Try to update at commit (should fail - immutable!) + String commitId = "01936d8f-1234-7890-abcd-ef1234567890"; + String url = "/default/version/commits/" + commitId + "/update"; + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.parseMediaType("application/sparql-update")); + + HttpEntity request = new HttpEntity<>("INSERT DATA {

}", headers); + + // Assert: Should return 405 Method Not Allowed + assertThatThrownBy(() -> restTemplate.postForEntity(url, request, String.class)) + .hasMessageContaining("405"); + } +} +``` + +--- + +## Task 4: Add Immutability Enforcement + +Commits and tags are immutable - no update endpoints should exist for them: + +**Implementation:** Simply don't create update endpoints for commits/tags. Spring will return `405 Method Not Allowed` automatically. + +**Test:** +```java +@Test +void updateAtCommit_shouldReturn405() { + // Commits are immutable - no update endpoint exists + String url = "/default/version/commits/01936d8f.../update"; + assertThatThrownBy(() -> restTemplate.postForEntity(url, "...", String.class)) + .hasMessageContaining("405 Method Not Allowed"); +} +``` + +--- + +## Quality Gates + +```bash +# Unit tests +mvn -q test -Dtest=VersionedSparqlControllerIT + +# Integration tests +mvn -q test -Dtest=*SparqlControllerIT + +# Full build +mvn -q clean install +``` + +--- + +## Completion Checklist + +- [ ] `VersionedSparqlController` created +- [ ] Query at branch endpoint implemented +- [ ] Query at commit endpoint implemented (immutable) +- [ ] Query at tag endpoint implemented +- [ ] Update at branch endpoint implemented +- [ ] No update endpoints for commits/tags (405 enforcement) +- [ ] `SparqlQueryService` methods added +- [ ] Integration tests pass +- [ ] Response headers correct (`Content-Location`, `Link`, `Cache-Control`) +- [ ] OpenAPI documentation updated +- [ ] All quality gates pass + +--- + +## Next Session + +**Session 4:** [Versioned Graph Store Protocol Endpoints](./session-4-versioned-gsp-endpoints.md) + +--- + +**Estimated Time:** 5-6 hours +**Priority:** High (core feature) diff --git a/docs/architecture/semantic-routing.md b/docs/architecture/semantic-routing.md new file mode 100644 index 0000000..baae573 --- /dev/null +++ b/docs/architecture/semantic-routing.md @@ -0,0 +1,723 @@ +# Semantic Routing for CHUCC-Server + +## Executive Summary + +This document defines the semantic routing concept for CHUCC-server that makes datasets, branches, and commits **directly shareable via URL**. The design extends Apache Jena Fuseki's proven routing patterns with version control semantics. + +**Status:** Approved for implementation (2025-11-06) + +--- + +## Core Design Principles + +1. **Fuseki-compatible base** - `/{dataset}/{service}` for current-state operations +2. **Version-aware extension** - `/{dataset}/version/{ref}/{service}` for versioned operations +3. **RESTful hierarchy** - Resources addressable by path, not query strings +4. **Shareable URLs** - Every resource has a canonical, bookmarkable URL +5. **Immutable commit URLs** - Perfect for academic citations and reproducibility +6. **Backward compatible** - Support old query-param style during migration + +--- + +## Quick Comparison + +### Apache Jena Fuseki Pattern +``` +/myDataset/sparql # Dataset as path segment +/myDataset/data # Service type explicit +/myDataset/data?graph=... # Named graphs as query params +``` + +### CHUCC Current Pattern (To Be Deprecated) +``` +/sparql?dataset=mydata&branch=main # Query param, not shareable +/data?dataset=mydata&graph=...&branch=main # Verbose, not RESTful +``` + +### CHUCC Proposed Pattern (Target) +``` +/mydata/version/branches/main/sparql # Shareable, RESTful +/mydata/version/commits/01936d8f.../data # Immutable, citable +/mydata/version/tags/v1.0/sparql # Human-readable versions +``` + +--- + +## URL Structure Overview + +### Hierarchy Pattern + +``` +/{dataset} Dataset root + /sparql Current state query (main HEAD) + /data Current state GSP (main HEAD) + /update Update main branch + /version Version control root + /refs List all refs + /branches Branch management + /{name} Specific branch + /sparql Query branch + /data GSP operations on branch + /update Update branch + /merge Merge into branch + /history Branch history + /commits Commit management + /{id} Specific commit (immutable) + /sparql Query commit state + /data GSP operations (read-only) + /history History from commit + /diff/{otherId} Diff between commits + /tags Tag management + /{name} Specific tag + /sparql Query tagged version + /data GSP operations (read-only) +``` + +--- + +## Core Routing Patterns + +### 1. Dataset Management + +``` +POST /datasets/{name} Create dataset +DELETE /datasets/{name}?confirmed=true Delete dataset +GET /datasets List datasets +GET /datasets/{name} Dataset metadata +``` + +**Example:** +```bash +curl -X POST http://localhost:8080/datasets/biodiversity \ + -H "Content-Type: application/json" \ + -H "SPARQL-VC-Author: Alice " \ + -d '{"description": "Species data"}' +``` + +--- + +### 2. Current State Operations (Main Branch HEAD) + +These endpoints operate on the **HEAD of the main branch** (default behavior): + +``` +GET/POST /{dataset}/sparql Query current state +POST /{dataset}/update Update (creates commit) +GET /{dataset}/data?graph={iri} Get graph +PUT /{dataset}/data?graph={iri} Replace graph +POST /{dataset}/data?graph={iri} Merge into graph +DELETE /{dataset}/data?graph={iri} Delete graph +PATCH /{dataset}/data?graph={iri} Apply RDF Patch +``` + +**Example:** +```bash +# Query current state of dataset +curl "http://localhost:8080/mydata/sparql?query=SELECT * WHERE { ?s ?p ?o }" + +# Get named graph at current state +curl "http://localhost:8080/mydata/data?graph=http://example.org/metadata" +``` + +--- + +### 3. Branch Management + +``` +GET /{dataset}/version/branches List branches +POST /{dataset}/version/branches Create branch +GET /{dataset}/version/branches/{name} Branch metadata +DELETE /{dataset}/version/branches/{name} Delete branch +``` + +**Example:** +```bash +# Get main branch details +curl http://localhost:8080/mydata/version/branches/main + +# Response: +{ + "name": "main", + "commitId": "01936d8f-1234-7890-abcd-ef1234567890", + "protected": true, + "timestamp": "2025-11-03T10:30:00Z" +} +``` + +**Shareable URL:** +``` +https://chucc.example.org/mydata/version/branches/main +``` + +--- + +### 4. Commit Management + +``` +GET /{dataset}/version/commits List commits +POST /{dataset}/version/commits Create commit +GET /{dataset}/version/commits/{id} Commit metadata +``` + +**Example:** +```bash +# Get commit details +curl http://localhost:8080/mydata/version/commits/01936d8f-1234-7890-abcd-ef1234567890 + +# Response: +{ + "id": "01936d8f-1234-7890-abcd-ef1234567890", + "message": "Add species metadata", + "author": "Alice ", + "timestamp": "2025-11-03T10:30:00Z", + "parents": ["01936d8e-..."], + "patchSize": 42 +} +``` + +**Shareable URL (immutable):** +``` +https://chucc.example.org/mydata/version/commits/01936d8f-1234-7890-abcd-ef1234567890 +``` + +--- + +### 5. Tag Management + +``` +GET /{dataset}/version/tags List tags +POST /{dataset}/version/tags Create tag +GET /{dataset}/version/tags/{name} Tag metadata +DELETE /{dataset}/version/tags/{name} Delete tag +``` + +**Example:** +```bash +# Get tag details +curl http://localhost:8080/mydata/version/tags/v1.0 + +# Response: +{ + "name": "v1.0", + "commitId": "01936d8f-...", + "message": "First stable release", + "annotated": true +} +``` + +**Shareable URL:** +``` +https://chucc.example.org/mydata/version/tags/v1.0 +``` + +--- + +### 6. Versioned SPARQL Operations + +Query or update at a **specific version reference** (branch, commit, or tag): + +``` +GET/POST /{dataset}/version/branches/{name}/sparql Query branch +GET/POST /{dataset}/version/commits/{id}/sparql Query commit (immutable) +GET/POST /{dataset}/version/tags/{name}/sparql Query tag +POST /{dataset}/version/branches/{name}/update Update branch (only) +``` + +**Examples:** +```bash +# Query main branch HEAD +curl "http://localhost:8080/mydata/version/branches/main/sparql?query=SELECT * WHERE { ?s ?p ?o }" + +# Query at specific commit (IMMUTABLE - perfect for citations!) +curl "http://localhost:8080/mydata/version/commits/01936d8f-1234-7890-abcd-ef1234567890/sparql?query=SELECT * WHERE { ?s ?p ?o }" + +# Query at tagged version +curl "http://localhost:8080/mydata/version/tags/v1.0/sparql?query=SELECT * WHERE { ?s ?p ?o }" + +# Update develop branch +curl -X POST http://localhost:8080/mydata/version/branches/develop/update \ + -H "Content-Type: application/sparql-update" \ + -H "SPARQL-VC-Author: Alice" \ + -d "INSERT DATA {

}" +``` + +**Key Restrictions:** +- ✅ Queries work on branches, commits, tags +- ✅ Updates only work on branches +- ❌ Cannot update commits (immutable) +- ❌ Cannot update tags (immutable) + +--- + +### 7. Versioned Graph Store Protocol + +GSP operations at a **specific version reference**: + +``` +GET /{dataset}/version/{ref}/data?graph={iri} Get graph at version +GET /{dataset}/version/{ref}/data?default Get default graph +PUT /{dataset}/version/branches/{name}/data?graph={iri} Replace (branch only) +POST /{dataset}/version/branches/{name}/data?graph={iri} Merge (branch only) +DELETE /{dataset}/version/branches/{name}/data?graph={iri} Delete (branch only) +PATCH /{dataset}/version/branches/{name}/data?graph={iri} Patch (branch only) +``` + +Where `{ref}` = `branches/{name}` | `commits/{id}` | `tags/{name}` + +**Examples:** +```bash +# Get graph from main branch HEAD +curl "http://localhost:8080/mydata/version/branches/main/data?graph=http://example.org/metadata" + +# Get graph from specific commit (IMMUTABLE!) +curl "http://localhost:8080/mydata/version/commits/01936d8f-1234/data?default" + +# Update graph on develop branch +curl -X PUT "http://localhost:8080/mydata/version/branches/develop/data?graph=http://example.org/metadata" \ + -H "Content-Type: text/turtle" \ + -H "SPARQL-VC-Author: Alice" \ + -d "

." +``` + +**Shareable Graph URLs:** +``` +# Current state on main +https://chucc.example.org/mydata/version/branches/main/data?graph=http://example.org/metadata + +# Immutable graph at commit (perfect for data citations!) +https://chucc.example.org/mydata/version/commits/01936d8f-1234/data?default + +# Graph at tagged version +https://chucc.example.org/mydata/version/tags/v1.0/data?graph=http://example.org/metadata +``` + +--- + +### 8. Advanced Version Control Operations + +``` +POST /{dataset}/version/branches/{target}/merge Merge branches +POST /{dataset}/version/branches/{name}/reset Reset branch +POST /{dataset}/version/branches/{name}/revert Revert commits +POST /{dataset}/version/branches/{name}/cherry-pick Cherry-pick +POST /{dataset}/version/branches/{name}/rebase Rebase +POST /{dataset}/version/branches/{name}/squash Squash commits +GET /{dataset}/version/branches/{name}/history Branch history +GET /{dataset}/version/commits/{id}/history History from commit +GET /{dataset}/version/commits/{id1}/diff/{id2} Diff between commits +``` + +**Examples:** +```bash +# Merge feature branch into main +curl -X POST http://localhost:8080/mydata/version/branches/main/merge \ + -H "Content-Type: application/json" \ + -H "SPARQL-VC-Author: Alice" \ + -d '{"source": "feature-xyz", "strategy": "three-way"}' + +# Get branch history +curl "http://localhost:8080/mydata/version/branches/main/history?limit=10" + +# Diff between two commits +curl "http://localhost:8080/mydata/version/commits/01936d8f-1234/diff/01936d90-5678" +``` + +**Shareable Diff URL:** +``` +https://chucc.example.org/mydata/version/commits/01936d8f-1234/diff/01936d90-5678 +``` + +--- + +### 9. Batch Operations + +``` +POST /{dataset}/version/branches/{name}/batch-graphs Batch GSP operations +POST /{dataset}/version/branches/{name}/batch Batch SPARQL operations +``` + +**Example:** +```bash +curl -X POST http://localhost:8080/mydata/version/branches/develop/batch-graphs \ + -H "Content-Type: application/json" \ + -H "SPARQL-VC-Author: Alice" \ + -d '{ + "operations": [ + {"type": "PUT", "graph": "http://example.org/g1", "content": "...", "contentType": "text/turtle"}, + {"type": "DELETE", "graph": "http://example.org/g2"} + ], + "mode": "single-commit" + }' +``` + +--- + +## Key Benefits + +### For End Users + +✅ **Shareable** - Copy URL, share with colleagues via email/Slack +✅ **Bookmarkable** - Save important dataset versions in browser +✅ **Discoverable** - Browse API hierarchy via URLs +✅ **Immutable** - Commit/tag URLs never change (perfect for citations) +✅ **Human-readable** - Understand URL structure at a glance + +### For Researchers + +✅ **Reproducible** - Share exact query + data version in papers +✅ **Citable** - Permanent URLs for dataset versions in publications +✅ **Auditable** - Track data lineage via URL history +✅ **Federated** - Reference external versioned datasets in SPARQL queries + +### For Developers + +✅ **Fuseki-compatible** - Familiar pattern for Jena users +✅ **Consistent** - Same pattern across all endpoints +✅ **Testable** - Easy to construct test URLs programmatically +✅ **RESTful** - Standard HTTP semantics (GET, PUT, POST, DELETE) + +--- + +## URL Shareability Use Cases + +### 1. Share a Dataset +``` +https://chucc.example.org/biodiversity-data +``` +→ **Use case:** Documentation, API discovery + +--- + +### 2. Share a Branch +``` +https://chucc.example.org/biodiversity-data/version/branches/main +``` +→ **Use case:** Collaboration ("I'm working on branch X"), monitoring + +--- + +### 3. Share a Commit (Immutable) +``` +https://chucc.example.org/biodiversity-data/version/commits/01936d8f-1234-7890-abcd-ef1234567890 +``` +→ **Use case:** Academic citations, reproducibility, audit trails + +**Example in research paper:** +``` +Jones et al. (2025). "Biodiversity Analysis Using RDF." +Dataset version: https://chucc.example.org/biodiversity-data/version/commits/01936d8f-1234 +``` + +--- + +### 4. Share a Query at Specific Version +``` +https://chucc.example.org/biodiversity-data/version/commits/01936d8f-1234/sparql?query=SELECT * WHERE { ?s ?p ?o LIMIT 10 } +``` +→ **Use case:** Reproducible research, bug reports, live documentation examples + +--- + +### 5. Share a Graph at Specific Version +``` +https://chucc.example.org/biodiversity-data/version/tags/v1.0/data?graph=http://example.org/species +``` +→ **Use case:** Data exports, federated queries, data portability + +--- + +### 6. Share a Diff Between Commits +``` +https://chucc.example.org/biodiversity-data/version/commits/01936d8f-1234/diff/01936d90-5678 +``` +→ **Use case:** Code reviews, change notifications, audit reports + +--- + +## Migration Strategy + +### Phase 1: Dual-Mode Support (v1.0 - Current Target) + +Support **both** old and new routing patterns: + +**Old Pattern (Deprecated, still works):** +``` +GET /sparql?dataset=mydata&branch=main&query=... +GET /data?dataset=mydata&commit=01936d8f...&graph=... +``` + +**New Pattern (Recommended):** +``` +GET /mydata/version/branches/main/sparql?query=... +GET /mydata/version/commits/01936d8f.../data?graph=... +``` + +**Implementation approach:** +- Both patterns route to same handlers +- New pattern responses include `Content-Location` header with canonical URL +- Documentation emphasizes new pattern +- OpenAPI specs show both patterns (new marked as preferred) + +--- + +### Phase 2: Deprecation Warnings (v1.5 - Future) + +- Old pattern returns `Deprecation` header +- Response includes `Link` header pointing to new URL +- Documentation marks old pattern as deprecated + +--- + +### Phase 3: Removal (v2.0 - Breaking Change) + +- Old pattern removed completely +- Migration guide provided +- Major version bump (breaking change) + +--- + +## Response Headers for New Pattern + +All responses include navigational headers: + +```http +Content-Location: /mydata/version/branches/main/data?graph=http://example.org/g1 +Link: ; rel="version" +Link: ; rel="branch" +ETag: "01936d8f-1234-7890-abcd-ef1234567890" +``` + +**Purpose:** +- `Content-Location` - Canonical URL of resource +- `Link` headers - Related resources (HATEOAS) +- `ETag` - Version identifier for optimistic locking + +--- + +## Implementation Guidelines + +### Controller Refactoring + +**Current structure:** +- `@RequestParam String dataset` - Query parameter (inconsistent) +- `/{dataset}/version/tags` - Path parameter (TagController only) + +**Target structure:** +- `@PathVariable String dataset` - Path parameter (all controllers) +- `/{dataset}/version/branches/{name}` - RESTful hierarchy + +**Example transformation:** + +**Before:** +```java +@GetMapping("/sparql") +public ResponseEntity query( + @RequestParam(defaultValue = "default") String dataset, + @RequestParam(required = false) String branch, + @RequestParam String query) { + // ... +} +``` + +**After:** +```java +@GetMapping("/{dataset}/version/branches/{branch}/sparql") +public ResponseEntity queryBranch( + @PathVariable String dataset, + @PathVariable String branch, + @RequestParam String query) { + // ... +} + +// Backward compatibility endpoint (deprecated) +@GetMapping("/sparql") +@Deprecated +public ResponseEntity queryLegacy( + @RequestParam(defaultValue = "default") String dataset, + @RequestParam(required = false) String branch, + @RequestParam String query) { + // Delegate to new endpoint +} +``` + +--- + +### URL Construction Helpers + +Provide utility classes for building URLs: + +```java +public class VersionControlUrls { + + public static String branch(String dataset, String branch) { + return String.format("/%s/version/branches/%s", dataset, branch); + } + + public static String commit(String dataset, String commitId) { + return String.format("/%s/version/commits/%s", dataset, commitId); + } + + public static String tag(String dataset, String tag) { + return String.format("/%s/version/tags/%s", dataset, tag); + } + + public static String sparqlAtBranch(String dataset, String branch) { + return String.format("/%s/version/branches/%s/sparql", dataset, branch); + } + + public static String commitDiff(String dataset, String from, String to) { + return String.format("/%s/version/commits/%s/diff/%s", dataset, from, to); + } +} +``` + +**Usage:** +```java +String location = VersionControlUrls.commit(dataset, commitId); +response.addHeader("Content-Location", location); +``` + +--- + +### OpenAPI Documentation Updates + +Update all `@Operation` annotations with new path structure: + +```java +@Operation( + summary = "Query dataset at specific branch", + description = "Execute SPARQL query against branch HEAD. Supports all SPARQL 1.1 query forms." +) +@GetMapping("/{dataset}/version/branches/{branch}/sparql") +public ResponseEntity queryBranch(...) { } +``` + +--- + +## Testing Strategy + +### Integration Tests + +Test both old and new patterns during migration: + +```java +@Test +void queryBranch_newPattern_shouldWork() { + // New pattern + String url = "/mydata/version/branches/main/sparql?query=..."; + ResponseEntity response = restTemplate.getForEntity(url, String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getHeaders().get("Content-Location")) + .contains("/mydata/version/branches/main"); +} + +@Test +void queryBranch_oldPattern_shouldStillWork() { + // Old pattern (deprecated but still works) + String url = "/sparql?dataset=mydata&branch=main&query=..."; + ResponseEntity response = restTemplate.getForEntity(url, String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + // Should include Content-Location with new pattern + assertThat(response.getHeaders().get("Content-Location")) + .contains("/mydata/version/branches/main"); +} +``` + +--- + +## Quick Reference: Complete URL Patterns + +### Dataset Management +``` +POST /datasets/{name} # Create dataset +DELETE /datasets/{name}?confirmed=true # Delete dataset +GET /datasets # List datasets +GET /datasets/{name} # Dataset metadata +``` + +### Current State (Main Branch HEAD) +``` +GET/POST /{dataset}/sparql # Query +POST /{dataset}/update # Update +GET /{dataset}/data?graph={iri} # Get graph +PUT /{dataset}/data?graph={iri} # Replace graph +POST /{dataset}/data?graph={iri} # Merge graph +DELETE /{dataset}/data?graph={iri} # Delete graph +PATCH /{dataset}/data?graph={iri} # Patch graph +``` + +### Version Control Management +``` +GET /{dataset}/version/refs # All refs +GET /{dataset}/version/branches # List branches +POST /{dataset}/version/branches # Create branch +GET /{dataset}/version/branches/{name} # Branch details +DELETE /{dataset}/version/branches/{name} # Delete branch +GET /{dataset}/version/commits # List commits +POST /{dataset}/version/commits # Create commit +GET /{dataset}/version/commits/{id} # Commit details +GET /{dataset}/version/tags # List tags +POST /{dataset}/version/tags # Create tag +GET /{dataset}/version/tags/{name} # Tag details +DELETE /{dataset}/version/tags/{name} # Delete tag +``` + +### Versioned SPARQL +``` +GET/POST /{dataset}/version/branches/{name}/sparql # Query branch +GET/POST /{dataset}/version/commits/{id}/sparql # Query commit +GET/POST /{dataset}/version/tags/{name}/sparql # Query tag +POST /{dataset}/version/branches/{name}/update # Update branch +``` + +### Versioned GSP +``` +GET /{dataset}/version/{ref}/data?graph={iri} # Get graph at version +PUT /{dataset}/version/branches/{name}/data?graph={iri} # Replace (branch only) +POST /{dataset}/version/branches/{name}/data?graph={iri} # Merge (branch only) +DELETE /{dataset}/version/branches/{name}/data?graph={iri} # Delete (branch only) +PATCH /{dataset}/version/branches/{name}/data?graph={iri} # Patch (branch only) +``` + +### Advanced Operations +``` +POST /{dataset}/version/branches/{target}/merge # Merge +POST /{dataset}/version/branches/{name}/reset # Reset +POST /{dataset}/version/branches/{name}/revert # Revert +POST /{dataset}/version/branches/{name}/cherry-pick # Cherry-pick +POST /{dataset}/version/branches/{name}/rebase # Rebase +POST /{dataset}/version/branches/{name}/squash # Squash +GET /{dataset}/version/branches/{name}/history # History +GET /{dataset}/version/commits/{id}/history # History from commit +GET /{dataset}/version/commits/{id1}/diff/{id2} # Diff +POST /{dataset}/version/branches/{name}/batch-graphs # Batch GSP +POST /{dataset}/version/branches/{name}/batch # Batch SPARQL +``` + +--- + +## Related Documentation + +- **[Full Routing Proposal](/tmp/routing-concept-proposal.md)** - Detailed 800+ line analysis +- **[API Extensions](../api/api-extensions.md)** - CHUCC-specific extensions +- **[CQRS + Event Sourcing](./cqrs-event-sourcing.md)** - Architecture fundamentals +- **[C4 Component Diagram](./c4-level3-component.md)** - System structure + +--- + +## Status and Next Steps + +**Current Status:** ✅ Approved for implementation (2025-11-06) + +**Implementation Tasks:** See `./.tasks/routing-refactoring/` for detailed task breakdown + +**Target Release:** v1.0 (with dual-mode support for backward compatibility) + +--- + +**Document Version:** 1.0 +**Date:** 2025-11-06 +**Author:** Claude (Anthropic) +**Approver:** arne-bdt