feat(sdk): type the pipe-config write methods from the GraphQL schema - #657
Open
mocha06 wants to merge 1 commit into
Open
feat(sdk): type the pipe-config write methods from the GraphQL schema#657mocha06 wants to merge 1 commit into
mocha06 wants to merge 1 commit into
Conversation
mocha06
changed the base branch from
rc-dev/refactor/649-sdk-destructive-confirmation
to
dev
September 2, 2026 16:16
Pipefy's GraphQL input objects reject an unknown field themselves (InputObject 'UpdatePipeInput' doesn't accept argument 'nmae'), on nested inputs too. The SDK was the one layer without those shapes: 21 write operations took **attrs: Any and forwarded whatever they were handed, so a consumer got no autocomplete, no client-side check, and no introspectable signature for exactly the methods where a typo costs a round trip. Add scripts/generate_graphql_inputs.py, which writes pipefy_sdk.graphql_inputs from a committed snapshot of the schema. Its three commands split on whether they reach the network: snapshot introspects the live API, generate rewrites the models from the snapshot, and check regenerates in memory and fails on a difference. Only check runs in CI, which has no Pipefy credentials; catching the API itself move is test_input_types_snapshot_matches_live, marked integration. Adding a type to ROOT_INPUT_TYPES is the whole of a later batch's codegen step, since the closure is resolved from there. Migrate the pipe-configuration batch: update_pipe, update_phase, create_phase_field, update_phase_field, update_label, create_field_condition and update_field_condition, on PipeConfigService and the PipefyClient facade. The model carries the id, because UpdatePipeInput.id is ID! in the schema, so update_pipe(pipe_id, name=...) becomes update_pipe(UpdatePipeInput(id=..., name=...)). The remaining 14 operations are tracked as sub-issues of #652 and land one batch at a time. Three mappings needed a decision rather than a translation, and they live in the hand-written _base.py so a regeneration cannot overwrite them: ID maps to str | int and is not coerced. updatePipe takes an integer id, but createFieldCondition answers a string expressions_structure entry with an opaque 500, so coercing either way would break one of the two. A GraphQL enum is typed str with its documented values exported as a tuple, following the CONDITION_OPERATIONS convention, so a value added server-side works without an SDK release. extra="forbid" mirrors the API rather than adding a rule on top of it. The model only moves the rejection to before the request, where it can name the field; the offending value is never echoed back, so a wrong field carrying a secret stays out of a transcript or a shell log. Two shapes still need repairing before the input is parsed, because the models mirror the schema exactly. GraphQL coerces a bare value into a single-item list, so expressions_structure: [0] is a legal way to write [[0]] on the wire and a model typed from [[ID]] would refuse it; normalize_field_condition_fields runs on the raw mapping at both boundaries and is idempotent, so the service's own pass still covers a direct SDK caller. The reserved-key guards move rather than vanish: they lived in the SDK because **attrs sat beside a positional argument the same key could override, and they now sit at the CLI boundary, which still has two sources. phase_id and pipe_id stay keyword arguments on update_phase_field. The mutation has no such fields; they narrow the SDK's slug-to-uuid lookup. The MCP tool and the CLI lift them out of extra_input and --extra rather than rejecting them. The wire payload does not move: for all seven methods the typed path builds the same GraphQL input the **attrs merge built, normalizers included. No MCP tool name, argument shape, envelope, or CLI option changes. Closes #652. Signed-off-by: mocha06 <52426811+mocha06@users.noreply.github.com>
mocha06
force-pushed
the
rc-dev/feat/652-sdk-typed-graphql-inputs
branch
from
September 2, 2026 16:22
17bc882 to
6a10baf
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #652. Based on
dev.Motivation
Pipefy's GraphQL input objects are strictly typed and reject an unknown field themselves:
The SDK was the one layer without those shapes. Twenty-one write operations took
**attrs: Anyand forwarded whatever they were handed, so a consumer got no autocomplete, no client-side check, and no introspectable signature for exactly the methods where a typo costs a round trip. The MCP tool layer hand-maintains the same shapes a second time to serve its JSON schemas.What ships
The generator, the schema snapshot, the drift check, and the first batch of seven methods. The remaining fourteen operations are filed as sub-issues of #652 (#653 tables, #654 webhooks and inbox email, #655 automations, #656 relations) so each lands as its own reviewable batch.
The generator
scripts/generate_graphql_inputs.pyhas three commands, and the split between them is what lets CI check the models without credentials:snapshotgenerate(default)snapshotchecktests/test_generate_graphql_inputs.pycheckcatches a hand-edit of a generated file, or a snapshot committed without regenerating. It cannot see the API itself move, because CI has no Pipefy credentials. That half istest_input_types_snapshot_matches_live, markedintegration.Adding a type to
ROOT_INPUT_TYPESis the whole of a future batch's codegen step; the transitive closure is resolved from there. Today's seven roots pull in 12 input objects, 108 fields and 2 enums.packages/sdk/src/pipefy_sdk/graphql_inputs/holds_generated.pyand__init__.py(both written by the generator, both carrying a# @generatedheader) plus a hand-written_base.py. Everything that needed a decision rather than a mapping lives in_base.py, so a regeneration never overwrites it.The migrated methods
update_pipe,update_phase,create_phase_field,update_phase_field,update_label,create_field_condition,update_field_condition— onPipeConfigServiceand on thePipefyClientfacade.The proven contract
Everything below was read off the live schema by the same introspection the generator runs, and the wire behaviour was checked against the API directly.
Identifier form.
IDmaps tostr | intand is not coerced. That mapping is forced, not chosen:updatePipeaccepts{id: 303088927}as an integer (verified), whilecreateFieldConditionanswers a stringexpressions_structureentry with an opaque 500 (documented onnormalize_field_condition_payload). Coercing in either direction would break one of the two.Required despite reading as optional. The schema marks four fields
NON_NULLon inputs that look like partial updates, and the models now make that visible rather than leaving it to a docstring:UpdatePhaseInputid,nameUpdatePhaseFieldInputid,labelUpdateLabelInputid,name,colorCreatePhaseFieldInputphase_id,label,typeUnknown fields.
extra="forbid"mirrors the API rather than adding a rule on top of it. Nested inputs behave the same way —updatePipe(input: {preferences: {findabl: true}})answersInputObject 'RepoPreferenceInput' doesn't accept argument 'findabl'— so a nested rejection is not new either. The model only moves both rejections to before the request, where the message can name the field.Enums are soft. A GraphQL enum is typed
strand the documented values are exported as a tuple (COLORS_VALUES), following theCONDITION_OPERATIONSconvention already in the SDK. A value added server-side keeps working without an SDK release.Authorization. Unchanged. Every one of these mutations is governed by the API permission on the caller's credential, exactly as before; nothing here is a permission check.
Behaviour boundaries
**attrsmerge built, normalizers included. Proved by constructing both and comparing (7/7 identical), which is also what rules the change out as the cause of the two API behaviours in the matrix below.expressions_structure: [0]is a legal way to write[[0]]on the wire — verified: the API stores it as[["0"]]. A model mirroring[[ID]]refuses it, sonormalize_field_condition_fields(new, inpipefy_sdk.utils) runs on the raw mapping first, at both boundaries. It is idempotent, and the service still runs the same repair on the serialized payload for a direct SDK caller who skips it.**attrsguards would have let--extra '{"phaseId": "999"}'overwrite thePHASE_IDargument and file the rule on another phase, and--extra '{"id": ...}'patch a different condition.reject_reserved_extra_keysrestores the refusal the SDK used to raise, at the layer that now owns it. The MCP tools were already covered by their own reserved sets.update_phase_fieldtakesphase_id/pipe_idas keyword arguments. The mutation has no such fields; they narrow the SDK's slug-to-uuid lookup. The MCP tool and the CLI lift them out ofextra_input/--extrarather than rejecting them, so both paths that worked before still work.**attrssat beside a positional argument that the same key could override. One typed input leaves the SDK nothing to reconcile, so the check now sits at the boundary that still has two sources: the CLI's--extraand the MCP tools'extra_input.null. That was true of**attrstoo.extra_inputkey or nestedpreferenceskey now comes back asINVALID_ARGUMENTSnaming the field, instead of as the API's rejection one round trip later. The rejected value is never echoed back, so a wrong field carrying a secret stays out of the transcript and the shell log.Docs and tests
docs/sdk/README.md— a "Typed mutation inputs" section with the worked example, theIDrule, the soft-enum rule, and the resolution-hint rule.packages/sdk/README.md— how to regenerate, and whatcheckcan and cannot catch..gitignore—scripts/*is ignored behind an allowlist; the generator is added to it.tests/test_generate_graphql_inputs.py(33),packages/sdk/tests/test_graphql_inputs.py(19),packages/sdk/tests/services/pipefy/test_input_types_snapshot_integration.py(1,integration), four MCP cases (the new rejection, the lifted hint, the nested path, the repaired shape) and three CLI cases (both reserved-key guards and the repaired shape).Testing
Offline: full suite green,
ruff check,ruff format --check,lint-imports(2 contracts kept),bump_version.py verify, the skill-reference and Cursor-plugin linters,uv build --all-packages. The builtpipefywheel carriesgraphql_inputs/; the snapshot JSON stays out of it, being a dev artifact.The live drift check ran too: a fresh
snapshotagainst the API reproduces the committed file byte for byte, andcheckstill passes against it.The runs below drove the
pipefy-mcp-serverbinary built from this branch, over the real MCP protocol on stdio, against a prod organization. Every phase, field, label and condition was created and deleted by the runs.update_pipenamesuccess=Trueupdate_pipe preferences.findablINVALID_ARGUMENTSnamingpreferences.findabl, no requestInvalid arguments for update_pipe: 'preferences.findabl' is not an accepted field.create_phase(setup)344145761update_phasedescription, name resolved by the toolsuccess=Truecreate_phase_fieldselect with optionsprioridade_652_19482create_phase_field extra_input.requredINVALID_ARGUMENTSnamingrequred, no field created'requred' is not an accepted fieldupdate_phase_fieldslug +phase_idhintsuccess=Trueupdate_phase_fieldhint insideextra_inputsuccess=Trueupdate_phase_field extra_input.requredINVALID_ARGUMENTSnamingrequred'requred' is not an accepted fieldcreate_label(setup)318030991update_labelwith a 3-digit hex colour#RRGGBBsuccess=Trueupdate_label extra_input.colourINVALID_ARGUMENTSnamingcolour'colour' is not an accepted fieldcreate_phase_fieldtarget (setup)433626970create_field_conditionwith integerexpressions_structureid=307275623 code=FIELD_CONDITION_WRONG_PHASE actual_phase=319036221update_field_conditionname and actionssuccess=Trueupdate_field_condition extra_input.nmaeINVALID_ARGUMENTSnamingnmae'nmae' is not an accepted fielddelete_field_condition, preview then confirmsuccess=Truedelete_label, preview then confirmsuccess=Truedelete_phase, cascading both fields (teardown)success=TrueTwo rows deserve their reasoning written down, because both look like regressions and neither is.
Row 14.
createFieldConditionignoresphaseIdfor a freshly created phase and files the rule under the pipe's Start form. Sending the identical payload through raw GraphQL, with no SDK or MCP layer involved, reproduces it: requested344145760, actual319036221("Start form"). This is the behaviour the tool's_verify_created_field_conditionstep and itsFIELD_CONDITION_WRONG_PHASEcode already exist to catch, so the row records the toolkit reporting an API behaviour correctly.Row 15. An update carrying only
nameis rejected withValidation failed: Condicionais devem ter pelo menos uma ação. The API requiresactionson every field-condition update, whatever the schema's nullability says. The scenario sends them.One known mismatch is left alone deliberately:
update_phase_field'soptionsargument is annotatedlist[Any] | dict[str, Any] | None, whileUpdatePhaseFieldInput.optionsis[String]. Neither a dict nor a non-string list was ever usable — no code path handled them and the API refused both — so the effect of this PR is that they now fail before the request instead of after. Narrowing the annotation would change the tool's JSON schema, which this PR otherwise does not touch, so it belongs in its own change.Not verified:
mcp.pipefy.com. The hosted deployment pins an exact published release, so it cannot exercise this branch. The SDK is the only layer that changes shape, and it ships in the same wheel.