|
| 1 | +# SDK Spec: GraphQL Fragment Inlining |
| 2 | + |
| 3 | +**Jira**: INFP-496 |
| 4 | +**Created**: 2026-03-13 |
| 5 | +**Status**: Implemented |
| 6 | +**Parent spec**: [infrahub/dev/specs/infp-496-graphql-fragment-inlining/spec.md](../../../../dev/specs/infp-496-graphql-fragment-inlining/spec.md) |
| 7 | + |
| 8 | +## Scope |
| 9 | + |
| 10 | +This spec covers the SDK-side responsibilities for the GraphQL Fragment Inlining feature (FR-015). The Infrahub server and backend integration are documented in the parent spec. |
| 11 | + |
| 12 | +Per the architecture decision in the parent spec: |
| 13 | + |
| 14 | +> Fragment parsing, resolution, and rendering is **SDK responsibility**, not server responsibility. |
| 15 | +
|
| 16 | +The SDK must provide: |
| 17 | + |
| 18 | +1. **Config model extension** — `graphql_fragments` in `.infrahub.yml` |
| 19 | +2. **Fragment renderer** — parse, resolve (transitively), and render queries |
| 20 | +3. **CLI integration** — `infrahubctl` local workflows apply fragment rendering automatically |
| 21 | + |
| 22 | +--- |
| 23 | + |
| 24 | +## Component Responsibilities |
| 25 | + |
| 26 | +| Responsibility | SDK Module | |
| 27 | +| --- | --- | |
| 28 | +| `InfrahubRepositoryFragmentConfig` model | `infrahub_sdk/schema/repository.py` | |
| 29 | +| `graphql_fragments` field on `InfrahubRepositoryConfig` | `infrahub_sdk/schema/repository.py` | |
| 30 | +| Read fragment file content from disk | `InfrahubRepositoryFragmentConfig.load_fragments()` | |
| 31 | +| Parse `.gql` fragment files into AST | `infrahub_sdk/graphql/fragment_renderer.py` | |
| 32 | +| Build fragment name index (across all declared files) | `infrahub_sdk/graphql/fragment_renderer.py` | |
| 33 | +| Detect duplicate fragment names across files | `infrahub_sdk/graphql/fragment_renderer.py` | |
| 34 | +| Resolve transitive fragment dependencies | `infrahub_sdk/graphql/fragment_renderer.py` | |
| 35 | +| Detect circular fragment dependencies | `infrahub_sdk/graphql/fragment_renderer.py` | |
| 36 | +| Render self-contained query document | `infrahub_sdk/graphql/fragment_renderer.py` | |
| 37 | +| High-level `render_query()` entry point (load + render) | `infrahub_sdk/graphql/query_renderer.py` | |
| 38 | +| Typed error exceptions | `infrahub_sdk/exceptions.py` | |
| 39 | +| Apply rendering in `infrahubctl` execution | `infrahub_sdk/ctl/utils.py` | |
| 40 | +| Apply rendering in `infrahubctl` transform | `infrahub_sdk/ctl/cli_commands.py` | |
| 41 | + |
| 42 | +--- |
| 43 | + |
| 44 | +## API Contract: Fragment Renderer |
| 45 | + |
| 46 | +### CLI-facing entry point |
| 47 | + |
| 48 | +```python |
| 49 | +# infrahub_sdk/graphql/query_renderer.py |
| 50 | + |
| 51 | +def render_query(name: str, config: InfrahubRepositoryConfig, relative_path: str = ".") -> str: |
| 52 | + """Return a self-contained GraphQL document for the named query, with fragment definitions inlined. |
| 53 | +
|
| 54 | + Loads the query file and all declared fragment files from config, then delegates to |
| 55 | + render_query_with_fragments. |
| 56 | +
|
| 57 | + Raises: |
| 58 | + ResourceNotDefinedError: Query name not found in config. |
| 59 | + FragmentFileNotFoundError: A declared fragment file path does not exist. |
| 60 | + DuplicateFragmentError: Same fragment name declared in multiple files. |
| 61 | + FragmentNotFoundError: Query references a fragment not found in any declared file. |
| 62 | + CircularFragmentError: Circular dependency detected among fragments. |
| 63 | + """ |
| 64 | +``` |
| 65 | + |
| 66 | +### Low-level entry point |
| 67 | + |
| 68 | +```python |
| 69 | +# infrahub_sdk/graphql/fragment_renderer.py |
| 70 | + |
| 71 | +def render_query_with_fragments(query_str: str, fragment_files: list[str]) -> str: |
| 72 | + """Return a self-contained GraphQL document with required fragment definitions inlined. |
| 73 | +
|
| 74 | + If the query contains no fragment spreads, query_str is returned unchanged. |
| 75 | +
|
| 76 | + Raises: |
| 77 | + QuerySyntaxError: Query string or a fragment file contains invalid GraphQL syntax. |
| 78 | + DuplicateFragmentError: Same fragment name declared in multiple files. |
| 79 | + FragmentNotFoundError: Query references a fragment not found in any declared file. |
| 80 | + CircularFragmentError: Circular dependency detected among fragments. |
| 81 | + """ |
| 82 | +``` |
| 83 | + |
| 84 | +### Public helpers in `fragment_renderer.py` |
| 85 | + |
| 86 | +```python |
| 87 | +def build_fragment_index(fragment_files: list[str]) -> dict[str, FragmentDefinitionNode]: |
| 88 | + """Parse all fragment file contents and return a mapping from fragment name to its AST node.""" |
| 89 | + |
| 90 | +def collect_required_fragments( |
| 91 | + query_doc: DocumentNode, |
| 92 | + fragment_index: dict[str, FragmentDefinitionNode], |
| 93 | +) -> list[str]: |
| 94 | + """Walk query_doc and collect all fragment names required (transitively). |
| 95 | +
|
| 96 | + Returns a topologically ordered list of unique fragment names. |
| 97 | + """ |
| 98 | +``` |
| 99 | + |
| 100 | +### Error types (additions to `infrahub_sdk/exceptions.py`) |
| 101 | + |
| 102 | +```python |
| 103 | +class GraphQLQueryError(Error): |
| 104 | + """Base class for all errors raised during GraphQL query rendering.""" |
| 105 | + |
| 106 | + |
| 107 | +class QuerySyntaxError(GraphQLQueryError): |
| 108 | + def __init__(self, syntax_error: str) -> None: ... |
| 109 | + # message: f"GraphQL syntax error: {syntax_error}" |
| 110 | + |
| 111 | + |
| 112 | +class FragmentNotFoundError(GraphQLQueryError): |
| 113 | + def __init__(self, fragment_name: str, query_file: str | None = None, message: str | None = None) -> None: ... |
| 114 | + # message: f"Fragment '{fragment_name}' not found." (or mentions query_file if provided) |
| 115 | + |
| 116 | + |
| 117 | +class DuplicateFragmentError(GraphQLQueryError): |
| 118 | + def __init__(self, fragment_name: str, message: str | None = None) -> None: ... |
| 119 | + # message: f"Fragment '{fragment_name}' is defined more than once across declared fragment files." |
| 120 | + |
| 121 | + |
| 122 | +class CircularFragmentError(GraphQLQueryError): |
| 123 | + def __init__(self, cycle: list[str], message: str | None = None) -> None: ... |
| 124 | + # message: f"Circular fragment dependency detected: {' -> '.join(cycle)}." |
| 125 | + |
| 126 | + |
| 127 | +class FragmentFileNotFoundError(GraphQLQueryError): |
| 128 | + def __init__(self, file_path: str, message: str | None = None) -> None: ... |
| 129 | + # message: f"Fragment file '{file_path}' declared in graphql_fragments does not exist." |
| 130 | +``` |
| 131 | + |
| 132 | +`GraphQLQueryError` is also handled in `handle_exception()` in `ctl/utils.py` so CLI commands print |
| 133 | +a clean error message and exit instead of raising an unhandled exception. |
| 134 | + |
| 135 | +--- |
| 136 | + |
| 137 | +## Config Model Extension |
| 138 | + |
| 139 | +```python |
| 140 | +# infrahub_sdk/schema/repository.py |
| 141 | + |
| 142 | +class InfrahubRepositoryFragmentConfig(InfrahubRepositoryConfigElement): |
| 143 | + model_config = ConfigDict(extra="forbid") |
| 144 | + name: str = Field(..., description="Logical name for this fragment file or directory") |
| 145 | + file_path: Path = Field(..., description="Path to a .gql file or directory of .gql files, relative to repo root") |
| 146 | + |
| 147 | + def load_fragments(self, relative_path: str = ".") -> list[str]: |
| 148 | + """Read and return raw content of all fragment files at file_path. |
| 149 | +
|
| 150 | + If file_path is a .gql file, returns a single-element list. |
| 151 | + If file_path is a directory, returns one entry per .gql file found (sorted). |
| 152 | + Raises FragmentFileNotFoundError if file_path does not exist. |
| 153 | + """ |
| 154 | + resolved = Path(f"{relative_path}/{self.file_path}") |
| 155 | + if not resolved.exists(): |
| 156 | + raise FragmentFileNotFoundError(file_path=str(self.file_path)) |
| 157 | + if resolved.is_dir(): |
| 158 | + return [f.read_text(encoding="UTF-8") for f in sorted(resolved.glob("*.gql"))] |
| 159 | + return [resolved.read_text(encoding="UTF-8")] |
| 160 | + |
| 161 | + |
| 162 | +class InfrahubRepositoryConfig(BaseModel): |
| 163 | + # ... existing fields ... |
| 164 | + graphql_fragments: list[InfrahubRepositoryFragmentConfig] = Field( |
| 165 | + default_factory=list, description="GraphQL fragment files" |
| 166 | + ) |
| 167 | +``` |
| 168 | + |
| 169 | +--- |
| 170 | + |
| 171 | +## infrahubctl Integration |
| 172 | + |
| 173 | +Both CLI call sites use `render_query()` from `query_renderer.py`, which handles loading fragment |
| 174 | +files from config and delegating to `render_query_with_fragments`. |
| 175 | + |
| 176 | +### `execute_graphql_query()` in `ctl/utils.py` |
| 177 | + |
| 178 | +```python |
| 179 | +# Before |
| 180 | +query_str = query_object.load_query() |
| 181 | + |
| 182 | +# After |
| 183 | +query_str = render_query(name=query, config=repository_config) |
| 184 | +``` |
| 185 | + |
| 186 | +### `transform()` in `ctl/cli_commands.py` |
| 187 | + |
| 188 | +```python |
| 189 | +# Before |
| 190 | +query_str = repository_config.get_query(name=transform.query).load_query() |
| 191 | + |
| 192 | +# After |
| 193 | +query_str = render_query(name=transform.query, config=repository_config) |
| 194 | +``` |
| 195 | + |
| 196 | +--- |
| 197 | + |
| 198 | +## Testing Requirements |
| 199 | + |
| 200 | +### Unit tests — `tests/unit/sdk/graphql/test_fragment_renderer.py` |
| 201 | + |
| 202 | +- Render query with single direct fragment spread → correct output |
| 203 | +- Render query with fragment spreads across two files → correct output |
| 204 | +- Render query with transitive dependency (A → B across files) → correct output |
| 205 | +- Render query with no fragment spreads → returned unchanged |
| 206 | +- Same fragment spread used twice → fragment definition appears once in output |
| 207 | +- Only required fragments included, not all from the file |
| 208 | +- `FragmentNotFoundError` raised for unresolved spread |
| 209 | +- `DuplicateFragmentError` raised for duplicate name across multiple content strings |
| 210 | +- `DuplicateFragmentError` raised for duplicate name within the same content string |
| 211 | +- `CircularFragmentError` raised for A→B→A cycle |
| 212 | +- `QuerySyntaxError` raised for invalid GraphQL syntax in query or fragment file |
| 213 | + |
| 214 | +### Unit tests — `tests/unit/sdk/graphql/test_query_renderer.py` |
| 215 | + |
| 216 | +- `render_query()` loads query + fragments from config and returns rendered document |
| 217 | +- `render_query()` with no `graphql_fragments` in config returns query unchanged |
| 218 | + |
| 219 | +### Unit tests — `tests/unit/sdk/test_repository.py` |
| 220 | + |
| 221 | +- `InfrahubRepositoryConfig` parses `graphql_fragments` YAML correctly |
| 222 | +- `InfrahubRepositoryFragmentConfig.load_fragments()` with a file path returns a single-element list with the file content |
| 223 | +- `InfrahubRepositoryFragmentConfig.load_fragments()` with a directory path returns one entry per `.gql` file found |
| 224 | +- `load_fragments()` raises `FragmentFileNotFoundError` for a path that does not exist |
| 225 | + |
| 226 | +### Integration / functional tests — caller-side |
| 227 | + |
| 228 | +- See main repo plan for backend integration tests and E2E fixtures |
| 229 | + |
| 230 | +--- |
| 231 | + |
| 232 | +## Constraints |
| 233 | + |
| 234 | +- Fragment rendering uses only `graphql-core` (already a dependency). No new dependencies. |
| 235 | +- All new public functions carry full type hints. |
| 236 | +- Both async and sync `InfrahubClient` paths are unaffected — rendering is a pure string transformation with no I/O. |
| 237 | +- Generated files (`protocols.py`) are not touched. |
0 commit comments