Migrate GraphQL layer from graphene to strawberry (query shapes pinned by golden SDL)#2139
Migrate GraphQL layer from graphene to strawberry (query shapes pinned by golden SDL)#2139JSv4 wants to merge 34 commits into
Conversation
…y: 0 diffs vs graphene golden)
…+ og_metadata port
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
|
Review: Graphene → Strawberry GraphQL migration Reviewed via the local merge commit ( Overview Strengths
Confirmed test breakage (should block merge as-is)
All five are local (function-scope) imports, so pytest collects the files fine and only fails at the specific test methods — consistent with the PR's own "Full backend suite — pending completion of the porting fan-out" caveat. Recommend a final repo-wide sweep for references to Other observations
Suggested next steps before removing WIP status
|
…emantics Node fields using relay.Node.Field resolved via DjangoObjectType.get_node (type get_queryset + .get(pk)), NOT the permission-filtered OpenContractsNode path. Types without a get_queryset (e.g. MessageType) resolved unfiltered by pk with per-field visibility enforcement. The core default over-filtered, breaking test_mentions.test_permission_enforcement_corpus. CorpusType keeps its permission-aware custom get_node (ported OpenContractsNode).
…port, DRF deletion guard, mention resolver names
…fer_action rewire
…ed_with to core.permissions
…irect-resolver unit tests
…n generated schema modules
…Node; fix bulk-upload list fields; install get_node/get_queryset compat aliases
…graduated test files The strawberry migration replaced the untyped graphene test base classes (graphene_django GraphQLTestCase / graphene.test.Client) with typed first-party equivalents in config/graphql/testing.py. Subclassing an Any-typed base had made mypy skip those test bodies entirely, so 22 test modules were only vacuously 'graduated' — their latent setUpTestData class-attribute pattern, dynamic resolver-alias calls, and self.client shadowing were hidden, not absent. Swapping in a concretely-typed base surfaces them. - Add per-module disable-error-code headers to the generated strawberry schema modules (name-defined/valid-type/arg-type) with documented rationale; fix the hand-written config/graphql/core/* modules so the entire config/graphql surface type-checks clean. - Re-baseline the 22 pre-existing test modules in mypy.ini with a documented block (non-bug patterns, no test-logic changes). - Fix the newly-authored test_schema_parity.py in place (cast the parallel served-type variable after the kind check) rather than baselining it, keeping it fully type-checked.
…linter job
Two CI failures on the migration branch, both regressions it introduced:
frontend-e2e ("Login + Navigate All Views"): createCorpus returned HTTP
500 in the live runserver, so the corpus-workflow / routing-round-trip /
threads-discussions specs failed at 'create corpus via UI'. Root cause:
opencontractserver/pipeline/embedders/test_embedder.py was deleted as
collateral in an earlier migration commit, but config/settings/test.py
still names it as DEFAULT_EMBEDDER. Corpus creation seeds a structural
Readme.CAML document whose post-save hook runs calculate_embedding_for_doc_text
eagerly (CELERY_TASK_ALWAYS_EAGER in test settings), which imports the
missing module and 500s. The pytest suite masks this because a
session-autouse conftest fixture disconnects the document post-save
signals; the e2e runserver has no such fixture. Restored the file from
main verbatim.
linter (pre-commit --all-files):
- mypy hook: its isolated env could not import strawberry.ext.mypy_plugin
(added to mypy.ini) because additional_dependencies still listed the
removed graphene-django. Swapped graphene-django==3.2.3 for
strawberry-graphql==0.320.3.
- flake8/pyupgrade/black/isort: applied the repo's standard formatting to
the generated strawberry schema modules and migration-touched test files
(they had been committed without pre-commit). Schema-shape parity is
unchanged (test_schema_parity still passes); the golden SDL only lost
trailing whitespace on blank docstring lines, which parity ignores.
ReviewThis is a huge, carefully-engineered migration (60k+ lines) with a genuinely strong verification story — the golden-SDL parity test, the deliberate preservation of graphene-django's 🔴 Critical — IDOR regression: 7 singular "by ID" query fields lost permission filtering
Seven model-backed types are registered with no
I verified three of these directly against the pre-migration graphene resolvers (
The corresponding plural resolvers a few lines below each ( Suggested fix: give each of these types a 🟠 Medium — unhandled
|
The migration ported the singular "fetch one object by global Relay ID"
query fields to config/graphql/core/relay.py::get_node_from_global_id.
That helper falls back to an UNFILTERED model._default_manager.get(pk=)
when the target type has no get_node/get_queryset hook, so any caller
(anonymous, for chatMessage) could fetch private rows by forging
base64("<Type>:<id>"). Thirteen model-backed types had lost the
permission filtering their graphene resolvers performed.
Registered a permission-aware get_node hook on each, mirroring the
graphene resolver (BaseService.get_or_none / filter_visible / the owning
service): RelationshipType, AnnotationLabelType, LabelSetType, MessageType,
FieldsetType, ColumnType, DatacellType, AnalyzerType, GremlinEngineType_READ,
AgentConfigurationType, BadgeType, UserExportType, UserImportType, and
AssignmentType (keeps its deprecated superuser-or-participant gate).
moderationAction was already safe (custom gated resolver, not the fallback).
Also: GraphQLView.dispatch now catches DRF AuthenticationFailed (raised by
ApiKeyBackend for a bad API key when USE_API_KEY_AUTH=True) and returns a
200 GraphQL error, matching the graphene middleware instead of an unhandled
500 (auth now runs in get_context, outside query execution's try/except).
Added opencontractserver/tests/test_singular_node_idor.py: a structural
guard that asserts every type resolved via get_node_from_global_id carries
a permission hook (prevents recurrence), plus a behavioral non-owner-denied
check. All 12 config/graphql surface checks stay green (schema parity, mypy,
flake8, E001 service-layer).
|
Thanks — the 🔴 IDOR finding was spot-on and is now fixed in Method: I grepped every
Each now registers a 🟠 medium: also fixed — Regression insurance: added The two 🟡 pre-existing items ( Generated by Claude Code |
| connection_type=lambda edges, pageInfo: ConnectionValue( # type: ignore[arg-type] | ||
| edges, pageInfo | ||
| ), |
…ing test-pollution bug
- Restore three per-field relay max_limit overrides the port silently
dropped (documentRelationships, annotations, extracts), each falling
back to the global 100-record cap instead of their higher, deliberate
ceilings.
- Re-expose DocumentType._assert_user_can_read as a class staticmethod
so the existing ported logic is reachable via the graphene-era
bound-method call convention tests rely on.
- Fix test_file_url_prewarm.py to reference the correctly-renamed
FileUrlPrewarmExtension instead of the deleted middleware class.
- Fix a pre-existing, migration-unrelated bug in test_mentions.py: the
conversation fixture used the wrong-case "THREAD" string instead of
the ConversationTypeChoices.THREAD enum value ("thread"), which
silently persisted (Django doesn't validate choices on save) and
broke non-creator message visibility.
- Fix test_pipeline_component_queries.py permanently deleting the
shared opencontractserver/pipeline/embedders/test_embedder.py
(the suite's default embedder) in tearDownClass instead of
restoring it, which cascaded into ~90 unrelated failures in any
full-suite run that reached this test class.
Full backend suite now green (0 failed, 0 errors) under both
docker compose -f test.yml and -f local.yml.
…oms corpora; fix bulk-ingest ext gate and relationship-embedding bottleneck - opencontractserver/enrichment/services/customs_ruling_citation_service.py: detects HTS tariff codes (plain annotations) and CBP ruling-number citations (CorpusReference rows resolved against sibling document titles) from each document's own post-parse text, reusing EnrichmentWriter for persistence. Runnable via manage.py enrich_customs_rulings. Validated at 100% precision/recall against a 96-doc pilot vs. the source dataset's own golden-tested extraction. - ingest_corpus management command: ext_ok now unions get_convertible_extensions() so files eligible for the configured file converter (e.g. Gotenberg -> .doc) are actually accepted, matching the command's own docstring. - embeddings_task.py: calculate_embeddings_for_relationship_batch's explicit-embedder path now batches through embed_texts_batch() (shared _batch_embed_items helper, also used by the annotation path) instead of one HTTP call per relationship -- a real bottleneck for parsers that emit many relationships per document (e.g. Warp-Ingest's heading-hierarchy OC_SUBTREE_GROUP rows). - celery worker start script: ignore .pilot_data so watchfiles doesn't choke on large locally-materialized ingest batches.
The graphene->strawberry migration (#2139) dropped per-row visibility filtering on singular to-one FK object fields. graphene-django auto- converted such FKs (when the target type overrode get_queryset) into a permission-filtered resolver, so an invisible target resolved to null; the strawberry port declared them as plain getattr fields, leaking the target row's fields across a permission boundary (e.g. a structural AnnotationType.corpus or a cross-corpus CorpusReferenceType.targetDocument pointing at a private corpus/document). - Add config/graphql/core/relay.py::resolve_visible_fk, applying the target type's registered get_node/get_queryset visibility hook; route the affected nullable FK fields through it across annotation/agent/ conversation/corpus/document/extract/research/social/user types. - Close the non-null DocumentPathType.document leak at the list level: _get_queryset_DocumentPathType now enforces MIN(document, corpus), the same semantic CorpusType.documents uses (issue #1682). - Wrap the get_node hook path in get_node_from_global_id with the same malformed-pk guard the default path already had. - drf_mutation/drf_deletion pass group="mutate" so DRF-routed mutations share the write rate-limit bucket instead of a separate "<lambda>" one. - Pin the served-schema security rules: a new test drives a too-deep query and an introspection query through schema.execute_sync (the real AddValidationRules extension path), not the decorative validation_rules list. - Declare graphql-relay explicitly in requirements; remove the dead ALLOW_GRAPHQL_DEBUG setting; correct the stale MessageType relay docstring and migration doc. Verified: test_fk_visibility_traversal (new, 10 tests), schema parity (zero shape change), singular-node IDOR, served-validation, and the versioning/DocumentPath/structural/mentions regression suites all green.
…erry-refactor-review-6pq6lh Close review gaps in graphene→strawberry migration (FK-traversal IDOR, rate bucket, served-validation test)
main added a CorpusGroup feature (issue #2056) in graphene while this branch replaced graphene with strawberry, so config/graphql/graphene_types.py and config/graphql/mutations.py conflicted as modify/delete (both files are deleted here; their sole non-conflicting change was CorpusGroup registration, now ported below). - config/graphql/corpus_types.py: port CorpusGroupType to strawberry (relay Node, permission-annotation fields, corpora resolved via CorpusGroupService.get_group_corpora_visible_to_user, default_agent gated through the existing resolve_visible_fk/AgentConfigurationType get_node hook instead of a bespoke resolver). - config/graphql/corpus_queries.py: add corpus_groups (connection) and corpus_group (singular, via get_node_from_global_id) query fields; drop the now-deleted graphene_types import (already superseded by the existing corpus_types import list in this file). - config/graphql/corpus_group_mutations.py: rewrite the graphene Create/Update/DeleteCorpusGroupMutation classes as strawberry MUTATION_FIELDS, matching corpus_folder_mutations.py's template. - config/graphql/schema.py: wire corpus_group_mutations into the Query/Mutation namespaces and extra_types. - opencontractserver/tasks/embeddings_task.py + its test module: both branches independently extracted the same _batch_embed_items shared helper for relationship-batch embedding; main's version is a strict superset (adds a source/target-annotation prefetch optimization and an extra sub-batching test), so took main's version wholesale. - .gitignore: merged the two pilot_data ignore-rule additions into one. Golden SDL (config/graphql/schema.graphql) still needs a deliberate regeneration to capture the new CorpusGroup schema surface - tracked as follow-up before merging this PR.
test_corpus_groups.py (already committed, pre-dating this merge) queries
corpora { edges { node { title } } } - main's graphene DjangoObjectType
auto-converted the "corpora" M2M field into a relay connection, which my
initial strawberry port missed (built it as a plain list instead). Fixed
CorpusGroupType.corpora in config/graphql/corpus_types.py to paginate via
resolve_django_connection/CorpusTypeConnection, matching the CorpusType.
documents field's existing pattern. Regenerated config/graphql/schema.graphql
to capture the corrected shape.
mypy 2.2.0 (bumped by main's dependabot PR) newly flags the standard
setUpTestData class-attribute pattern in test_fk_visibility_traversal.py
(added earlier in this PR, never baselined) - the same non-bug pattern
already documented and re-baselined for 22 other modules in mypy.ini's
"strawberry migration re-baseline" section. Added it to that list rather
than rewriting the test.
pre-commit (black/isort/flake8/mypy) is green; targeted tests (schema
parity, corpus groups, FK visibility traversal, singular-node IDOR,
security hardening, relationship-batch embedding) all pass: 145 passed.
ReviewThis PR already has two thorough automated review passes on it (the IDOR finding on singular-node resolution and the AuthenticationFailed-500 finding), both of which the author fixed in Verified fixed since last review
New observation — tokenAuth/updateMe have no rate limiting I couldn't diff this file against the pre-migration graphene source directly (no history in this shallow checkout), so I can't confirm whether this is a regression introduced by the port or a pre-existing gap in the graphene code that's simply being carried over faithfully (which would make it out of scope for this PR, per the same reasoning the author gave for the Minor — config/graphql/schema.py extra-types wiring is repetitive Note — unrelated changelog fragments/commits present Everything else I spot-checked
Recommendation |
Graphene → Strawberry migration
Status: migration in progress — resolver porting fan-out underway. Will be updated on this PR as it completes; do not merge yet.
What this does
Replaces the graphene / graphene-django GraphQL layer (schema types, resolvers, mutations) and the graphene-level auth middlewares with strawberry-graphql, with a machine-verified guarantee of zero query-shape changes:
config/graphql/schema.graphql(10.6k lines).opencontractserver/tests/test_schema_parity.pystructurally compares the served strawberry schema against it — every type, field, argument name/type, nullability wrapper, interface, enum member, and printed default must match exactly. The parity test is green.config/graphql/core/): relay global IDs +Nodeinterface in graphene wire format (base64("TypeName:pk")), countable/PDF-page-aware connection factories, a faithful port of graphene-django's connection resolution (arrayconnection cursors,RELAY_CONNECTION_MAX_LIMIT=100, 1-basedoffset→afterconversion), django-filter FilterSet argument mapping incl.GlobalIDFilterdecoding,GenericScalar/JSONString/BigIntscalars, permission-annotation resolvers, DRF-serializer mutation bases, auth decorators with graphql_jwt-compatible error messages.graphql_jwt.middleware.JSONWebTokenMiddleware+ the API-key graphene middleware are gone. Per-request authentication happens once inconfig/graphql/views.py::GraphQLView.get_contextvia the standardAUTHENTICATION_BACKENDSchain (JWT / Auth0 / API-key / session).tokenAuth/verifyToken/refreshTokenare strawberry-native ports preserving long-running refresh-token laziness.DepthLimitValidationRule+DisableIntrospectionattach via strawberry'sAddValidationRules(which appends to the full spec rule set — the graphene replace-the-rules trap is structurally impossible). The GCS file-URL pre-warm middleware became a strawberrySchemaExtension.graphene.test.Client→ drop-inconfig/graphql/testing.py::Client(same result dict shape);GraphQLTestCaseported for endpoint-level tests;schema.execute(...)→schema.execute_sync(...).graphene-djangoremoved from requirements/INSTALLED_APPS;strawberry-graphql==0.320.3added;django-graphql-jwtretained only as a JWT signing/backend utility (its graphene middleware/mutations are no longer used).How the port was done
The strawberry schema skeleton (394 types, all field/argument shapes) was generated by introspecting the live graphene schema, guaranteeing shape parity by construction. Custom resolver bodies (465 stubs) are being ported verbatim module-by-module — roots are Django model instances in both frameworks, so bodies transfer nearly unchanged, keeping the service-layer permission patterns intact (the
opencontracts.E001architecture check stays green).Verification
test_schema_parity— green (schema shape vs golden SDL)test_og_metadata_queries— 18/18 green (first fully ported module)test_token_expiration— 10/10 green (JWT auth path)🤖 Generated with Claude Code
https://claude.ai/code/session_01SZxDJP7pKPSryHv7d5tiWm
Generated by Claude Code