◐ Off-By-One · answer catalog

go-api-link-existence-validation

1 answer(s)godocker

go-api-link-existence-validation

📦 Source in repository (JSON)

Answer

Root cause. POST /v1/links wrote the edge straight to the store, so targets referencing nonexistent (or cross-tenant) memories/groups returned 201 Created and produced dangling graph edges.

Fix. Two pieces in /workspace (module github.com/example/graphapi):

1. objectExists helper — the single normalization point (handler.go). Real repos return not-found as errors, so the helper translates repo errors into a boolean, keeping the handler free of repo-specific error types:

// objectExists reports whether object kind/id exists for tenantID.
//   nil error        -> exists (true, nil)
//   not-found        -> (false, nil): domainerr.ErrNotFound OR string error
//                       ("not found", "does not exist", "no such")
//   tenant mismatch  -> (false, nil): domainerr.ErrTenantMismatch OR string
//                       ("belongs to another tenant"); treated as nonexistent
//                       so cross-tenant existence is never leaked
//   anything else    -> (false, err): caller surfaces it as a 500
func objectExists(ctx context.Context, repo ObjectRepo, tenantID, id string) (bool, error) {
    if repo == nil {
        return true, nil // external/unverified: nothing to resolve locally
    }
    if err := repo.Get(ctx, tenantID, id); err != nil {
        switch {
        case isNotFound(err) || isTenantMismatch(err):
            return false, nil
        default:
            return false, err
        }
    }
    return true, nil
}

isNotFound matches domainerr.ErrNotFound via errors.Is plus the string forms real repos emit; isTenantMismatch does the same for domainerr.ErrTenantMismatch / "belongs to another tenant".

2. Create handler validates both endpoints before persisting (handler.go):

// Source: verified only when a source repo is configured; sources are
// documented external/unverified, so an unconfigured kind is accepted.
if exists, err := objectExists(r.Context(), h.repoFor(req.Source.Kind), tenantID, req.Source.ID); err != nil {
    writeError(w, internalError("failed to verify link source: %v", err))
    return
} else if !exists {
    writeError(w, validationError("link source %s %q does not exist", req.Source.Kind, req.Source.ID))
    return
}

// Target: must be a memory or group that exists in this tenant.
tgtRepo := h.repoFor(req.Target.Kind)
if tgtRepo == nil {
    writeError(w, validationError("unsupported link target kind %q (expected memory or group)", req.Target.Kind))
    return
}
if exists, err := objectExists(r.Context(), tgtRepo, tenantID, req.Target.ID); err != nil {
    writeError(w, internalError("failed to verify link target: %v", err))
    return
} else if !exists {
    writeError(w, validationError("link target %s %q does not exist", req.Target.Kind, req.Target.ID))
    return
}
// ...store.Create(...) -> 201

Missing endpoints → 400 VALIDATION_ERROR naming the object (e.g. link target memory "mem_1" does not exist); generic repo failures (dial tcp: connection refused) → 500 INTERNAL_ERROR; nothing is persisted on any rejection.

3. Regression suite (handler_test.go) — TestLinkOperations_Create_OrphanValidation with 9 subtests; every rejection subtest also asserts the link store was never called (no dangling edges). Supporting pieces: domainerr sentinels (ErrNotFound, ErrTenantMismatch), ObjectRepo/LinkStore interfaces, and Repos wiring (nil source repo = external/unverified).

Evidence & signatures

Verified on Go 1.26 with `gofmt`/`go vet` clean and `go test -race` passing:

```
--- PASS: TestLinkOperations_Create_OrphanValidation (9 subtests)
    ✓ valid link created when source external and target exists        -> 201, store called once
    ✓ missing memory target rejected … naming it                      -> 400 VALIDATION_ERROR, "memory \"mem_1\" does not exist"
    ✓ missing group target rejected … naming it                       -> 400 VALIDATION_ERROR (string-error repo), "group \"grp_9\" does not exist"
    ✓ missing source rejected when a source repo is configured        -> 400 VALIDATION_ERROR, "source \"src_1\" does not exist"
    ✓ not-found surfaced as plain string error mapped to 400          -> 400 ("not found: memory mem_1")
    ✓ tenant mismatch sentinel treated as nonexistent                 -> 400, message does not reveal the object exists
    ✓ tenant mismatch string error treated as nonexistent             -> 400 ("belongs to another tenant")
    ✓ generic repository error stays 500                              -> 500 INTERNAL_ERROR
    ✓ external unverified source accepted without a source repo       -> 201
--- PASS: TestObjectExistsClassification (7 cases, normalization table pinned)
ok  	github.com/example/graphapi	1.012s
```

Edge cases covered beyond the 9 subtests: the classification unit test pins nil-repo/exists/not-found-sentinel/string-not-found/tenant-mismatch-sentinel/string-mismatch/generic-error behavior; rejected requests assert zero `LinkStore.Create` calls (no edge persisted); the test setup deliberately uses a nil *interface* for external sources to avoid the typed-nil trap.
{"model": "deepseek-v4-flash", "problem_class": "go-api-link-existence-validation", "result": "passed", "tests": 9}
Generated from the verified corpus · MIT licensedBack to the catalog