feat: canonical POST /load, deprecate /ingest (RFC-009 Phase 5) (#222)

* feat(server): canonical POST /load, deprecate /ingest (RFC-009 Phase 5)

The CLI's non-deprecated `load` verb rode the deprecated `/ingest` route, so
`/ingest`'s eventual removal would silently break it. Add a canonical `/load`,
mirroring the shipped `/mutate`↔`/change` and `/query`↔`/read` pattern.

- Extract `server_ingest`'s body into a shared `run_ingest` (branch-exists /
  fork-if-`from`, Cedar auth, admission, `load_as`, `IngestOutput` mapping).
- `server_load` (canonical) → `run_ingest`, `Json<IngestOutput>`.
- `server_ingest` (deprecated) → `run_ingest` + `#[deprecated]` + RFC 9745/8288
  `Deprecation: true` / `Link: </load>; rel="successor-version"` headers.
- Router mounts `/load` (same 32 MB body limit) beside `/ingest`; OpenAPI
  `paths(...)` gains `server_load` and flags `server_ingest` deprecated.

`/load` reuses `IngestRequest`/`IngestOutput`, exactly as canonical `/mutate`
reuses `Change*` — a DTO rename is a separate, larger change (out of scope).

openapi.json regenerated. Tests: openapi `/load` present + not deprecated,
`/ingest` deprecated, `/load` bearer-secured; data_routes `/load` happy path +
`/ingest` deprecation headers. Existing `/ingest` route tests stay green (the
shim is unchanged). Docs: server.md endpoint table; RFC-009 Phase 5 marked
landed (incl. the hand-mount-vs-utoipa-axum registration finding).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(cli): point remote load at /load (RFC-009 Phase 5)

`GraphClient::load`'s remote arm now POSTs to the canonical `/load` route
instead of the deprecated `/ingest`; the deprecated `ingest` verb keeps
riding `/ingest`. `parity_load` exercises `/load` on the remote arm (its
documented flip); the matrix exclusions comment is updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Andrew Altshuler 2026-06-14 03:32:16 +03:00 committed by GitHub
parent 6144bb18d6
commit 8726ca92ec
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 325 additions and 57 deletions

View file

@ -620,6 +620,83 @@ async fn change_endpoint_emits_deprecation_headers() {
);
}
#[tokio::test(flavor = "multi_thread")]
async fn load_endpoint_loads_into_existing_branch() {
// Canonical bulk-load endpoint (RFC-009 Phase 5). Same wire shape as
// /ingest, no deprecation signal.
let (_temp, app) = app_for_loaded_graph().await;
let request = IngestRequest {
branch: Some("main".to_string()),
from: None,
mode: Some(LoadMode::Merge),
data: r#"{"type":"Person","data":{"name":"Loaded","age":7}}"#.to_string(),
};
let response = app
.clone()
.oneshot(
Request::builder()
.uri("/load")
.method(Method::POST)
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&request).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert!(
response.headers().get("deprecation").is_none(),
"POST /load must not advertise itself as deprecated"
);
let body_bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
let body: Value = serde_json::from_slice(&body_bytes).unwrap();
assert_eq!(body["branch"], "main");
assert_eq!(body["tables"][0]["table_key"], "node:Person");
}
#[tokio::test(flavor = "multi_thread")]
async fn ingest_endpoint_emits_deprecation_headers() {
// `/ingest` is the deprecated alias of `/load` (RFC-009 Phase 5): flagged
// at runtime per RFC 9745 (`Deprecation: true`) + RFC 8288 (`Link: </load>;
// rel="successor-version"`). The OpenAPI side is covered by
// `openapi_ingest_is_deprecated` in tests/openapi.rs.
let (_temp, app) = app_for_loaded_graph().await;
let request = IngestRequest {
branch: Some("main".to_string()),
from: None,
mode: Some(LoadMode::Merge),
data: r#"{"type":"Person","data":{"name":"Legacyer","age":33}}"#.to_string(),
};
let response = app
.clone()
.oneshot(
Request::builder()
.uri("/ingest")
.method(Method::POST)
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&request).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get("deprecation")
.and_then(|v| v.to_str().ok()),
Some("true"),
"POST /ingest must advertise `Deprecation: true` (RFC 9745)"
);
assert_eq!(
response.headers().get("link").and_then(|v| v.to_str().ok()),
Some("</load>; rel=\"successor-version\""),
"POST /ingest must point at /load via `Link` rel=successor-version (RFC 8288)"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn read_endpoint_emits_deprecation_headers() {
// `/read` is kept indefinitely for byte-stable back-compat but flagged

View file

@ -172,6 +172,7 @@ const EXPECTED_PATHS: &[&str] = &[
"/queries/{name}",
"/schema",
"/schema/apply",
"/load",
"/ingest",
"/branches",
"/branches/{branch}",
@ -300,6 +301,32 @@ fn openapi_ingest_is_post() {
assert!(doc["paths"]["/ingest"]["post"].is_object());
}
#[test]
fn openapi_load_is_not_deprecated() {
// RFC-009 Phase 5: /load is the canonical bulk-load endpoint.
let doc = openapi_json();
assert!(doc["paths"]["/load"]["post"].is_object());
let deprecated = doc["paths"]["/load"]["post"]
.get("deprecated")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
assert!(
!deprecated,
"/load is the canonical load endpoint and must not be deprecated"
);
}
#[test]
fn openapi_ingest_is_deprecated() {
// RFC-009 Phase 5: /ingest is now the deprecated alias of /load.
let doc = openapi_json();
assert_eq!(
doc["paths"]["/ingest"]["post"]["deprecated"],
serde_json::Value::Bool(true),
"/ingest must be flagged deprecated now that /load is canonical"
);
}
#[test]
fn openapi_branches_supports_get_and_post() {
let doc = openapi_json();
@ -705,6 +732,7 @@ fn protected_endpoints_reference_bearer_token_security() {
("/schema/apply", "post"),
("/queries", "get"),
("/queries/{name}", "post"),
("/load", "post"),
("/ingest", "post"),
("/export", "post"),
("/snapshot", "get"),