redhat-datalayer-graphql 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- redhat_datalayer_graphql-0.1.0/.env.example +7 -0
- redhat_datalayer_graphql-0.1.0/.gitignore +54 -0
- redhat_datalayer_graphql-0.1.0/PKG-INFO +382 -0
- redhat_datalayer_graphql-0.1.0/README.md +368 -0
- redhat_datalayer_graphql-0.1.0/pyproject.toml +27 -0
- redhat_datalayer_graphql-0.1.0/src/datalayer_graphql/__init__.py +29 -0
- redhat_datalayer_graphql-0.1.0/src/datalayer_graphql/auth/__init__.py +8 -0
- redhat_datalayer_graphql-0.1.0/src/datalayer_graphql/auth/bearer.py +41 -0
- redhat_datalayer_graphql-0.1.0/src/datalayer_graphql/client/__init__.py +7 -0
- redhat_datalayer_graphql-0.1.0/src/datalayer_graphql/client/supergraph.py +185 -0
- redhat_datalayer_graphql-0.1.0/src/datalayer_graphql/config/__init__.py +7 -0
- redhat_datalayer_graphql-0.1.0/src/datalayer_graphql/config/supergraph.py +30 -0
- redhat_datalayer_graphql-0.1.0/src/datalayer_graphql/exceptions.py +95 -0
- redhat_datalayer_graphql-0.1.0/src/datalayer_graphql/models.py +22 -0
- redhat_datalayer_graphql-0.1.0/src/datalayer_graphql/operations.py +69 -0
- redhat_datalayer_graphql-0.1.0/src/datalayer_graphql/py.typed +0 -0
- redhat_datalayer_graphql-0.1.0/tests/conftest.py +81 -0
- redhat_datalayer_graphql-0.1.0/tests/test_client.py +335 -0
- redhat_datalayer_graphql-0.1.0/tests/test_operations.py +122 -0
- redhat_datalayer_graphql-0.1.0/tests/test_smoke_supergraph.py +72 -0
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Supergraph integration test (copy to .env and fill in)
|
|
2
|
+
SUPERGRAPH_URL=https://vpn.graphql.stage.redhat.com
|
|
3
|
+
SUPERGRAPH_TOKEN=your-bearer-token-here
|
|
4
|
+
SUPERGRAPH_CLIENT_NAME=datalayer-sdk-test
|
|
5
|
+
SUPERGRAPH_VERIFY_SSL=false
|
|
6
|
+
SUPERGRAPH_SMOKE_OPERATION=Cves
|
|
7
|
+
SUPERGRAPH_SMOKE_VARIABLES={"first": 10}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# --- Secrets & local env ---
|
|
2
|
+
.env
|
|
3
|
+
.env.*
|
|
4
|
+
!.env.example
|
|
5
|
+
# --- Python ---
|
|
6
|
+
__pycache__/
|
|
7
|
+
*.py[cod]
|
|
8
|
+
*$py.class
|
|
9
|
+
*.so
|
|
10
|
+
.Python
|
|
11
|
+
# --- Packaging / build ---
|
|
12
|
+
build/
|
|
13
|
+
dist/
|
|
14
|
+
wheels/
|
|
15
|
+
*.egg-info/
|
|
16
|
+
*.egg
|
|
17
|
+
pip-wheel-metadata/
|
|
18
|
+
# --- Virtual environments ---
|
|
19
|
+
.venv/
|
|
20
|
+
venv/
|
|
21
|
+
env/
|
|
22
|
+
# --- uv (keep uv.lock — commit it) ---
|
|
23
|
+
# --- Testing ---
|
|
24
|
+
.pytest_cache/
|
|
25
|
+
.coverage
|
|
26
|
+
.coverage.*
|
|
27
|
+
htmlcov/
|
|
28
|
+
.tox/
|
|
29
|
+
.nox/
|
|
30
|
+
coverage.xml
|
|
31
|
+
*.cover
|
|
32
|
+
.hypothesis/
|
|
33
|
+
# --- Type checkers / linters ---
|
|
34
|
+
.mypy_cache/
|
|
35
|
+
.ruff_cache/
|
|
36
|
+
.pytype/
|
|
37
|
+
# --- IDE / editors ---
|
|
38
|
+
.idea/
|
|
39
|
+
.vscode/
|
|
40
|
+
*.swp
|
|
41
|
+
*.swo
|
|
42
|
+
*~
|
|
43
|
+
.project
|
|
44
|
+
.pydevproject
|
|
45
|
+
.settings/
|
|
46
|
+
# --- OS ---
|
|
47
|
+
.DS_Store
|
|
48
|
+
Thumbs.db
|
|
49
|
+
# --- Logs & temp ---
|
|
50
|
+
*.log
|
|
51
|
+
*.tmp
|
|
52
|
+
.cache/
|
|
53
|
+
|
|
54
|
+
.cursor
|
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: redhat-datalayer-graphql
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Async Python client for Red Hat's GraphQL supergraph (Apollo Router)
|
|
5
|
+
Author: Red Hat DataLayer Team
|
|
6
|
+
Maintainer-email: Mayur Deshmukh <mdeshmuk@redhat.com>, Pranav Advani <padvani@redhat.com>
|
|
7
|
+
License-Expression: Apache-2.0
|
|
8
|
+
Requires-Python: >=3.13
|
|
9
|
+
Requires-Dist: gql[httpx]>=3.5.0
|
|
10
|
+
Requires-Dist: graphql-core>=3.2.0
|
|
11
|
+
Requires-Dist: httpx>=0.28.1
|
|
12
|
+
Requires-Dist: pydantic>=2.13.4
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# datalayer-graphql
|
|
16
|
+
|
|
17
|
+
Async Python client for Red Hat's GraphQL **supergraph** (Apollo Router). Provides a single connection surface — auth, Apollo client headers, and operation execution — so every consumer (MCP servers, scripts, tests) connects the same way.
|
|
18
|
+
|
|
19
|
+
Built on [gql](https://gql.readthedocs.io/) with an [httpx](https://www.python-httpx.org/) transport, adding org-specific auth patterns, Apollo headers, and federation-aware error handling.
|
|
20
|
+
|
|
21
|
+
## Features
|
|
22
|
+
|
|
23
|
+
- Async `SupergraphClient` built on `gql` + `httpx`
|
|
24
|
+
- Typed `SupergraphConfig` via [Pydantic](https://docs.pydantic.dev/)
|
|
25
|
+
- Bearer token auth from environment variables
|
|
26
|
+
- Standard Apollo client headers (`apollographql-client-name`, `apollographql-client-version`)
|
|
27
|
+
- Operation registry — call `execute(operation_name=...)` without passing the query each time
|
|
28
|
+
- `.graphql` file loading via `load_operations()`
|
|
29
|
+
- Structured exception hierarchy with federation error support
|
|
30
|
+
- Response headers captured for router inspection
|
|
31
|
+
|
|
32
|
+
## Requirements
|
|
33
|
+
|
|
34
|
+
- Python 3.13+
|
|
35
|
+
- [uv](https://docs.astral.sh/uv/) (recommended) or pip
|
|
36
|
+
- VPN access for internal endpoints (e.g. `vpn.graphql.stage.redhat.com`)
|
|
37
|
+
- Valid GraphQL bearer token with `api.graphql` scope
|
|
38
|
+
|
|
39
|
+
## Installation
|
|
40
|
+
|
|
41
|
+
From the monorepo root:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
uv sync --package datalayer-graphql
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
As a workspace dependency (already wired in `datalayer-mcp`):
|
|
48
|
+
|
|
49
|
+
```toml
|
|
50
|
+
dependencies = ["datalayer-graphql"]
|
|
51
|
+
|
|
52
|
+
[tool.uv.sources]
|
|
53
|
+
datalayer-graphql = { workspace = true }
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Quick start
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
import asyncio
|
|
60
|
+
|
|
61
|
+
from datalayer_graphql import BearerTokenAuth, SupergraphClient, SupergraphConfig
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
async def main() -> None:
|
|
65
|
+
client = SupergraphClient(
|
|
66
|
+
SupergraphConfig(
|
|
67
|
+
url="https://vpn.graphql.stage.redhat.com",
|
|
68
|
+
auth=BearerTokenAuth(token_env="SUPERGRAPH_TOKEN"),
|
|
69
|
+
client_name="rh-graphql-studio-explorer",
|
|
70
|
+
operations={
|
|
71
|
+
"Cves": """
|
|
72
|
+
query Cves($first: Int!) {
|
|
73
|
+
cves(first: $first) {
|
|
74
|
+
totalCount
|
|
75
|
+
edges { node { title url } }
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
""",
|
|
79
|
+
},
|
|
80
|
+
)
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
async with client:
|
|
84
|
+
result = await client.execute(
|
|
85
|
+
operation_name="Cves",
|
|
86
|
+
variables={"first": 10},
|
|
87
|
+
)
|
|
88
|
+
print(result.data)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
asyncio.run(main())
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Set the token before running:
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
export SUPERGRAPH_TOKEN="eyJ..." # client-credentials JWT with api.graphql scope
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
See [`examples/graphql/`](../../examples/graphql/) for more complete examples including
|
|
101
|
+
operations registries, variable passing, and `.graphql` file loading.
|
|
102
|
+
|
|
103
|
+
### Loading operations from `.graphql` files
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
from pathlib import Path
|
|
107
|
+
from datalayer_graphql import load_operations, SupergraphConfig, BearerTokenAuth
|
|
108
|
+
|
|
109
|
+
operations = load_operations(Path("operations/"))
|
|
110
|
+
|
|
111
|
+
config = SupergraphConfig(
|
|
112
|
+
url="https://vpn.graphql.stage.redhat.com",
|
|
113
|
+
auth=BearerTokenAuth(token_env="SUPERGRAPH_TOKEN"),
|
|
114
|
+
operations=operations,
|
|
115
|
+
)
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Each `.graphql` file must contain exactly one named operation. The operation name becomes the key in the returned dict.
|
|
119
|
+
|
|
120
|
+
## Configuration
|
|
121
|
+
|
|
122
|
+
### `SupergraphConfig`
|
|
123
|
+
|
|
124
|
+
| Field | Type | Default | Description |
|
|
125
|
+
|-------|------|---------|-------------|
|
|
126
|
+
| `url` | `HttpUrl` | — | Supergraph endpoint URL |
|
|
127
|
+
| `auth` | `BearerTokenAuth` | — | Authentication configuration |
|
|
128
|
+
| `client_name` | `str` | `"rh-graphql-studio-explorer"` | Value for `apollographql-client-name` header |
|
|
129
|
+
| `client_version` | `str` | `"latest"` | Value for `apollographql-client-version` header |
|
|
130
|
+
| `timeout` | `float` | `30.0` | HTTP request timeout in seconds |
|
|
131
|
+
| `verify_ssl` | `bool` | `True` | TLS certificate verification. Set `False` on VPN/corporate networks with internal CAs |
|
|
132
|
+
| `operations` | `dict[str, str]` | `{}` | Maps operation name -> GraphQL query string |
|
|
133
|
+
| `extra_headers` | `dict[str, str]` | `{}` | Additional HTTP headers merged into every request |
|
|
134
|
+
|
|
135
|
+
### `BearerTokenAuth`
|
|
136
|
+
|
|
137
|
+
Reads a bearer token from an environment variable at request time (not stored in config):
|
|
138
|
+
|
|
139
|
+
```python
|
|
140
|
+
BearerTokenAuth(token_env="SUPERGRAPH_TOKEN")
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
- Looks up `os.environ["SUPERGRAPH_TOKEN"]`
|
|
144
|
+
- Strips whitespace and a leading `Bearer ` prefix if present
|
|
145
|
+
- Sets `Authorization: Bearer <token>` on every HTTP request via httpx auth hooks
|
|
146
|
+
|
|
147
|
+
### Operation registry
|
|
148
|
+
|
|
149
|
+
GraphQL POST bodies require a `query` string. To support an API that only passes `operation_name`, register queries on config:
|
|
150
|
+
|
|
151
|
+
```python
|
|
152
|
+
SupergraphConfig(
|
|
153
|
+
url="...",
|
|
154
|
+
auth=BearerTokenAuth(token_env="SUPERGRAPH_TOKEN"),
|
|
155
|
+
operations={
|
|
156
|
+
"GetCustomerById": "query GetCustomerById($id: ID!) { customer(id: $id) { id name } }",
|
|
157
|
+
"Cves": "query Cves($first: Int!) { cves(first: $first) { totalCount } }",
|
|
158
|
+
},
|
|
159
|
+
)
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
You can also pass `query=` directly to `execute()` to bypass the registry:
|
|
163
|
+
|
|
164
|
+
```python
|
|
165
|
+
await client.execute(
|
|
166
|
+
operation_name="AdHoc",
|
|
167
|
+
query="query AdHoc { __typename }",
|
|
168
|
+
)
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
## API reference
|
|
172
|
+
|
|
173
|
+
### `SupergraphClient`
|
|
174
|
+
|
|
175
|
+
```python
|
|
176
|
+
client = SupergraphClient(config: SupergraphConfig)
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
Creates a `gql.Client` with an `HTTPXAsyncTransport`, configured with default headers, auth, and connection pooling.
|
|
180
|
+
|
|
181
|
+
#### `execute`
|
|
182
|
+
|
|
183
|
+
```python
|
|
184
|
+
result = await client.execute(
|
|
185
|
+
operation_name: str,
|
|
186
|
+
variables: dict | None = None,
|
|
187
|
+
query: str | None = None,
|
|
188
|
+
) -> ExecuteResult
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Sends a GraphQL POST request via the `gql` transport.
|
|
192
|
+
|
|
193
|
+
**Returns:** `ExecuteResult(data=..., extensions=..., response_headers=...)`
|
|
194
|
+
|
|
195
|
+
#### Context manager
|
|
196
|
+
|
|
197
|
+
Always close the client when done (or use `async with`):
|
|
198
|
+
|
|
199
|
+
```python
|
|
200
|
+
async with SupergraphClient(config) as client:
|
|
201
|
+
result = await client.execute(operation_name="Cves", variables={"first": 10})
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
### `ExecuteResult`
|
|
205
|
+
|
|
206
|
+
```python
|
|
207
|
+
@dataclass(frozen=True)
|
|
208
|
+
class ExecuteResult:
|
|
209
|
+
data: dict[str, Any] | None
|
|
210
|
+
extensions: dict[str, Any] | None = None
|
|
211
|
+
response_headers: Mapping[str, str] | None = None
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
### `load_operations`
|
|
215
|
+
|
|
216
|
+
```python
|
|
217
|
+
from pathlib import Path
|
|
218
|
+
|
|
219
|
+
operations: dict[str, str] = load_operations(Path("operations/"))
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
Scans a directory for `.graphql` files. Each file must contain exactly one named operation. Returns `{operation_name: query_string}`.
|
|
223
|
+
|
|
224
|
+
## Error handling
|
|
225
|
+
|
|
226
|
+
### Exception hierarchy
|
|
227
|
+
|
|
228
|
+
```
|
|
229
|
+
SupergraphError # Base for all supergraph errors
|
|
230
|
+
├── SupergraphConnectionError # Network / connection failures
|
|
231
|
+
├── SupergraphHTTPError # HTTP 4xx/5xx (status_code attribute)
|
|
232
|
+
└── GraphQLError # GraphQL-level errors in response body
|
|
233
|
+
├── errors: list[GraphQLErrorDetail] # Parsed error details
|
|
234
|
+
├── data: dict | None # Partial data (federation)
|
|
235
|
+
└── extensions: dict | None
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
### `GraphQLErrorDetail`
|
|
239
|
+
|
|
240
|
+
```python
|
|
241
|
+
@dataclass(frozen=True)
|
|
242
|
+
class GraphQLErrorDetail:
|
|
243
|
+
message: str
|
|
244
|
+
path: list[str | int] | None
|
|
245
|
+
locations: list[dict[str, int]] | None
|
|
246
|
+
extensions: dict[str, Any] | None
|
|
247
|
+
|
|
248
|
+
@property
|
|
249
|
+
def code(self) -> str | None: ... # e.g. "SUBREQUEST_HTTP_ERROR"
|
|
250
|
+
@property
|
|
251
|
+
def service(self) -> str | None: ... # e.g. "rhg-docs-aem"
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
### Error mapping
|
|
255
|
+
|
|
256
|
+
| Situation | Exception |
|
|
257
|
+
|-----------|-----------|
|
|
258
|
+
| Missing env var for token | `ValueError` from `BearerTokenAuth.resolve_token()` |
|
|
259
|
+
| Missing query for operation | `ValueError` from `execute()` |
|
|
260
|
+
| HTTP 401/403/503 | `SupergraphHTTPError` (`.status_code` attribute) |
|
|
261
|
+
| GraphQL errors in body | `GraphQLError` with parsed `GraphQLErrorDetail` list |
|
|
262
|
+
| Network / connection failure | `SupergraphConnectionError` |
|
|
263
|
+
| Malformed response | `SupergraphError` |
|
|
264
|
+
| Timeout | `TimeoutError` (standard Python) |
|
|
265
|
+
|
|
266
|
+
Federation error example:
|
|
267
|
+
|
|
268
|
+
```python
|
|
269
|
+
try:
|
|
270
|
+
result = await client.execute(operation_name="GetDocs")
|
|
271
|
+
except GraphQLError as e:
|
|
272
|
+
for detail in e.errors:
|
|
273
|
+
print(detail.code) # "SUBREQUEST_HTTP_ERROR"
|
|
274
|
+
print(detail.service) # "rhg-docs-aem"
|
|
275
|
+
if e.data:
|
|
276
|
+
print("Partial data:", e.data)
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
## Package layout
|
|
280
|
+
|
|
281
|
+
```
|
|
282
|
+
src/datalayer_graphql/
|
|
283
|
+
├── __init__.py # Public exports
|
|
284
|
+
├── auth/
|
|
285
|
+
│ └── bearer.py # BearerTokenAuth + httpx auth flow
|
|
286
|
+
├── client/
|
|
287
|
+
│ └── supergraph.py # SupergraphClient (wraps gql.Client)
|
|
288
|
+
├── config/
|
|
289
|
+
│ └── supergraph.py # SupergraphConfig + header builder
|
|
290
|
+
├── exceptions.py # Exception hierarchy
|
|
291
|
+
├── models.py # ExecuteResult
|
|
292
|
+
└── operations.py # load_operations()
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
## Tests
|
|
296
|
+
|
|
297
|
+
### Unit tests
|
|
298
|
+
|
|
299
|
+
```bash
|
|
300
|
+
uv run pytest packages/datalayer-graphql/tests/test_client.py tests/test_operations.py -v
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
### Integration smoke tests
|
|
304
|
+
|
|
305
|
+
Live smoke tests hit the stage VPN supergraph. They are **skipped** automatically when `.env` is missing or incomplete.
|
|
306
|
+
|
|
307
|
+
#### Setup
|
|
308
|
+
|
|
309
|
+
```bash
|
|
310
|
+
cd packages/datalayer-graphql
|
|
311
|
+
cp .env.example .env
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
Edit `.env`:
|
|
315
|
+
|
|
316
|
+
```env
|
|
317
|
+
SUPERGRAPH_URL=https://vpn.graphql.stage.redhat.com
|
|
318
|
+
SUPERGRAPH_TOKEN=your-bearer-token-here
|
|
319
|
+
SUPERGRAPH_CLIENT_NAME=rh-graphql-studio-explorer
|
|
320
|
+
SUPERGRAPH_VERIFY_SSL=false
|
|
321
|
+
SUPERGRAPH_SMOKE_OPERATION=Cves
|
|
322
|
+
SUPERGRAPH_SMOKE_VARIABLES={"first": 10}
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
#### Run
|
|
326
|
+
|
|
327
|
+
```bash
|
|
328
|
+
uv run pytest packages/datalayer-graphql/tests/test_smoke_supergraph.py -v -s
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
### Obtaining a token
|
|
332
|
+
|
|
333
|
+
Tokens expire after ~15 minutes. Use the included token script to fetch a fresh
|
|
334
|
+
client-credentials token from Red Hat SSO and write it to your `.env` file:
|
|
335
|
+
|
|
336
|
+
```bash
|
|
337
|
+
uv run scripts/fetch_token.py \
|
|
338
|
+
--client-id my-client-id \
|
|
339
|
+
--client-secret YOUR_SECRET \
|
|
340
|
+
--env-file packages/datalayer-graphql/.env
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
Or fetch manually with curl:
|
|
344
|
+
|
|
345
|
+
```bash
|
|
346
|
+
curl -X POST \
|
|
347
|
+
"https://sso.stage.redhat.com/auth/realms/redhat-external/protocol/openid-connect/token" \
|
|
348
|
+
-H "Content-Type: application/x-www-form-urlencoded" \
|
|
349
|
+
-d "grant_type=client_credentials" \
|
|
350
|
+
-d "client_id=my-client-id" \
|
|
351
|
+
-d "client_secret=YOUR_SECRET" \
|
|
352
|
+
-d "scope=api.graphql"
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
Copy `access_token` into `SUPERGRAPH_TOKEN` in `.env`.
|
|
356
|
+
|
|
357
|
+
## Development
|
|
358
|
+
|
|
359
|
+
### Install dev dependencies
|
|
360
|
+
|
|
361
|
+
```bash
|
|
362
|
+
uv sync --all-packages
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
### Lint and type check
|
|
366
|
+
|
|
367
|
+
```bash
|
|
368
|
+
uv run ruff check .
|
|
369
|
+
uv run mypy packages/datalayer-graphql/src/
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
### Roadmap
|
|
373
|
+
|
|
374
|
+
- [ ] `ClientCredentialsAuth` — auto-fetch and refresh tokens from Red Hat SSO
|
|
375
|
+
- [ ] Router response header validation (`rhg-auth`, `cache-control`)
|
|
376
|
+
- [x] Federation-aware error types (`GraphQLErrorDetail` with `code`, `service`)
|
|
377
|
+
- [x] Unit tests with mock transport
|
|
378
|
+
- [x] `.graphql` file loading utility
|
|
379
|
+
|
|
380
|
+
## License
|
|
381
|
+
|
|
382
|
+
Internal Red Hat / Datalayer project.
|