@venn-lang/graphql 0.1.0
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.
- package/LICENSE +21 -0
- package/README.md +125 -0
- package/dist/index.d.ts +103 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +226 -0
- package/dist/index.js.map +1 -0
- package/package.json +54 -0
- package/src/actions/gql-action.ts +49 -0
- package/src/actions/index.ts +9 -0
- package/src/clients/fake-client.ts +34 -0
- package/src/clients/index.ts +2 -0
- package/src/clients/real-client.ts +28 -0
- package/src/index.ts +12 -0
- package/src/matchers/index.ts +4 -0
- package/src/matchers/no-graphql-errors.ts +19 -0
- package/src/plugin.ts +23 -0
- package/src/port/gql-client.port.ts +15 -0
- package/src/port/gql-client.types.ts +37 -0
- package/src/port/index.ts +2 -0
- package/src/schema/graphql-response.ts +12 -0
- package/src/schema/index.ts +1 -0
- package/src/types.ts +31 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Vinicius Borges
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# @venn-lang/graphql
|
|
2
|
+
|
|
3
|
+
> The `gql` namespace: GraphQL queries, mutations and subscriptions as Venn verbs.
|
|
4
|
+
|
|
5
|
+
Three verbs that all answer with the same `{ data, errors }` envelope, plus a `noGraphqlErrors`
|
|
6
|
+
matcher to assert on it. Every call travels through the `GqlClient` port, so the same `.vn` file runs
|
|
7
|
+
against a live server or against canned responses depending only on what the host bound at startup.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
The package ships with the stdlib, so the CLI already loads it. Inside a `.vn` file, bring the
|
|
12
|
+
namespace in with `use`:
|
|
13
|
+
|
|
14
|
+
```ruby
|
|
15
|
+
use "venn/graphql"
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Usage
|
|
19
|
+
|
|
20
|
+
```ruby
|
|
21
|
+
module demo.profile
|
|
22
|
+
|
|
23
|
+
use "venn/graphql"
|
|
24
|
+
|
|
25
|
+
flow "Profile" {
|
|
26
|
+
step "read the profile" {
|
|
27
|
+
let res = gql.query "{ me { id plan } }" { auth: "Bearer tok123" }
|
|
28
|
+
expect res noGraphqlErrors
|
|
29
|
+
expect res.data.me.plan == "pro"
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
The document is the single positional argument. Everything else rides the options map:
|
|
35
|
+
|
|
36
|
+
```ruby
|
|
37
|
+
let res = gql.mutate "mutation Rename($id: ID!, $name: String!) { rename(id: $id, name: $name) { id } }" {
|
|
38
|
+
variables: { id: "u1", name: "Alice" }
|
|
39
|
+
auth: "Bearer tok123"
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`variables` and `auth` are the only accepted option names. Anything else is `VN3001`, reported by
|
|
44
|
+
`venn check` before the flow runs and refused again when the line executes.
|
|
45
|
+
|
|
46
|
+
## Verbs
|
|
47
|
+
|
|
48
|
+
| Verb | Positional argument | Options | Result |
|
|
49
|
+
| --- | --- | --- | --- |
|
|
50
|
+
| `gql.query` | `document: string` | `variables`, `auth` | `gql.GraphqlResponse` |
|
|
51
|
+
| `gql.mutate` | `document: string` | `variables`, `auth` | `gql.GraphqlResponse` |
|
|
52
|
+
| `gql.subscribe` | `document: string` | `variables`, `auth` | `gql.GraphqlResponse` |
|
|
53
|
+
|
|
54
|
+
`query` and `mutate` both call `GqlClient.execute`; `subscribe` calls `GqlClient.subscribe`. A
|
|
55
|
+
subscription answers with one envelope, the same shape as the other two, not a live stream.
|
|
56
|
+
|
|
57
|
+
## Matcher
|
|
58
|
+
|
|
59
|
+
`expect <subject> noGraphqlErrors` passes when `errors` is absent, `null`, or an empty array, and
|
|
60
|
+
fails when it carries at least one entry. It declares `appliesTo: "GraphqlResponse"`, which the
|
|
61
|
+
editor shows on hover. The matcher lives in the plugin definition (`gqlPlugin.matchers`) and is not
|
|
62
|
+
exported from the barrel.
|
|
63
|
+
|
|
64
|
+
## Types
|
|
65
|
+
|
|
66
|
+
The plugin publishes two named types the checker and the editor read:
|
|
67
|
+
|
|
68
|
+
| Name | Shape |
|
|
69
|
+
| --- | --- |
|
|
70
|
+
| `gql.GraphqlResponse` | `{ data?: dynamic, errors?: list<gql.GraphqlError> }`, open |
|
|
71
|
+
| `gql.GraphqlError` | `{ message: string, path?: list<string \| number>, extensions?: map<dynamic> }`, open |
|
|
72
|
+
|
|
73
|
+
Both are open, because a server may answer with more than the client reads (`extensions`, above all).
|
|
74
|
+
|
|
75
|
+
## The GqlClient port
|
|
76
|
+
|
|
77
|
+
| | |
|
|
78
|
+
| --- | --- |
|
|
79
|
+
| id | `venn.port.gql-client` |
|
|
80
|
+
| version | `1` |
|
|
81
|
+
| requires | `net` |
|
|
82
|
+
| methods | `execute`, `subscribe` |
|
|
83
|
+
|
|
84
|
+
Two implementations ship with the package, as the port rule demands. `createFakeClient` replays
|
|
85
|
+
canned envelopes; `createRealClient` is a placeholder that throws `VN8090` on every call, since no
|
|
86
|
+
real transport is wired in this build. The conformance suite lives in
|
|
87
|
+
`src/clients/gql-client.suite.ts` and the fake runs it today; the real client joins it the day it
|
|
88
|
+
answers instead of throwing.
|
|
89
|
+
|
|
90
|
+
`@venn-lang/stdlib` binds `createFakeClient()` with no configuration, so out of the box every call answers
|
|
91
|
+
`{ data: {}, errors: undefined }`: `noGraphqlErrors` passes and field assertions have nothing to read.
|
|
92
|
+
To assert on real data, bind a fake of your own:
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
import { createFakeClient, GqlClientPort, okGraphqlResponse } from "@venn-lang/graphql";
|
|
96
|
+
|
|
97
|
+
const binding = {
|
|
98
|
+
port: GqlClientPort,
|
|
99
|
+
impl: createFakeClient({
|
|
100
|
+
responses: {
|
|
101
|
+
"{ me { id plan } }": okGraphqlResponse({ data: { me: { id: "u1", plan: "pro" } } }),
|
|
102
|
+
},
|
|
103
|
+
}),
|
|
104
|
+
};
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
`responses` is keyed by the exact document text; `response` sets a single fallback for every query.
|
|
108
|
+
|
|
109
|
+
## API
|
|
110
|
+
|
|
111
|
+
| Export | What it is |
|
|
112
|
+
| --- | --- |
|
|
113
|
+
| `gqlPlugin` (also the default export) | The `PluginDefinition`: namespace `gql`, `requires: ["net"]`, three actions, one matcher. |
|
|
114
|
+
| `GqlClientPort` | The port descriptor actions resolve through `ctx.port(...)`. |
|
|
115
|
+
| `createFakeClient({ response?, responses? })` | The test double. Returns the canned envelope for a query, or the fallback. |
|
|
116
|
+
| `okGraphqlResponse(overrides?)` | `{ data: {}, errors: undefined }` merged with `overrides`. |
|
|
117
|
+
| `createRealClient()` | The real client's slot. Every method throws `VN8090`. |
|
|
118
|
+
| `graphqlResponseType` | The Zod schema of the envelope, registered as the plugin's `GraphqlResponse` type. |
|
|
119
|
+
| `GqlClient`, `GqlRequest`, `GqlResponse`, `GqlError` | Types only. |
|
|
120
|
+
|
|
121
|
+
## See also
|
|
122
|
+
|
|
123
|
+
- [`@venn-lang/grpc`](../std-grpc), the same port pattern over gRPC.
|
|
124
|
+
- [`@venn-lang/http`](../std-http), the HTTP verbs and the `Response` type.
|
|
125
|
+
- [`@venn-lang/sdk`](../sdk), `defineAction` / `defineMatcher` / `definePlugin`.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { Port } from "@venn-lang/contracts";
|
|
2
|
+
import { PluginDefinition, ZodType } from "@venn-lang/sdk";
|
|
3
|
+
//#region src/port/gql-client.types.d.ts
|
|
4
|
+
/** One operation to run: the document text, its variables and any bearer token. */
|
|
5
|
+
interface GqlRequest {
|
|
6
|
+
query: string;
|
|
7
|
+
variables?: Record<string, unknown>;
|
|
8
|
+
auth?: string;
|
|
9
|
+
}
|
|
10
|
+
/** One entry of `errors`. `path` walks down `data` to the field that failed. */
|
|
11
|
+
interface GqlError {
|
|
12
|
+
message: string;
|
|
13
|
+
path?: readonly (string | number)[];
|
|
14
|
+
extensions?: Record<string, unknown>;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* The `{ data, errors }` envelope, and what `res` holds after a gql verb.
|
|
18
|
+
*
|
|
19
|
+
* GraphQL answers 200 with errors inside, so a failed operation arrives here and
|
|
20
|
+
* not as a thrown error. `expect res noGraphqlErrors` is how a flow checks.
|
|
21
|
+
*/
|
|
22
|
+
interface GqlResponse {
|
|
23
|
+
data?: unknown;
|
|
24
|
+
errors?: readonly GqlError[];
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Running one GraphQL operation against an endpoint.
|
|
28
|
+
*
|
|
29
|
+
* `execute` and `subscribe` are separate methods because the transports differ,
|
|
30
|
+
* even though both answer with the same envelope.
|
|
31
|
+
*
|
|
32
|
+
* Two implementations: `createRealClient` and `createFakeClient`.
|
|
33
|
+
*/
|
|
34
|
+
interface GqlClient {
|
|
35
|
+
execute(req: GqlRequest): Promise<GqlResponse>;
|
|
36
|
+
subscribe(req: GqlRequest): Promise<GqlResponse>;
|
|
37
|
+
}
|
|
38
|
+
//#endregion
|
|
39
|
+
//#region src/port/gql-client.port.d.ts
|
|
40
|
+
/**
|
|
41
|
+
* The port descriptor every `gql` verb resolves through `ctx.port(...)`.
|
|
42
|
+
*
|
|
43
|
+
* Declares the `net` capability, so a host that cannot reach an endpoint refuses
|
|
44
|
+
* the binding at load time with a readable diagnostic.
|
|
45
|
+
*/
|
|
46
|
+
declare const GqlClientPort: Port<GqlClient>;
|
|
47
|
+
//#endregion
|
|
48
|
+
//#region src/clients/fake-client.d.ts
|
|
49
|
+
/**
|
|
50
|
+
* A successful envelope: an empty `data` object and no `errors`.
|
|
51
|
+
*
|
|
52
|
+
* @param overrides Fields to replace on the default envelope.
|
|
53
|
+
* @returns A complete {@link GqlResponse}.
|
|
54
|
+
*/
|
|
55
|
+
declare function okGraphqlResponse(overrides?: Partial<GqlResponse>): GqlResponse;
|
|
56
|
+
/**
|
|
57
|
+
* The double: canned envelopes, and no endpoint.
|
|
58
|
+
*
|
|
59
|
+
* A document with no entry in `responses` falls back to `response`, so a test
|
|
60
|
+
* only names the documents it cares about. `execute` and `subscribe` resolve
|
|
61
|
+
* from the same table.
|
|
62
|
+
*
|
|
63
|
+
* @param args.responses Envelopes keyed by the exact document text.
|
|
64
|
+
* @param args.response What every other document gets. Defaults to
|
|
65
|
+
* {@link okGraphqlResponse}.
|
|
66
|
+
*/
|
|
67
|
+
declare function createFakeClient(args?: {
|
|
68
|
+
response?: GqlResponse;
|
|
69
|
+
responses?: Record<string, GqlResponse>;
|
|
70
|
+
}): GqlClient;
|
|
71
|
+
//#endregion
|
|
72
|
+
//#region src/clients/real-client.d.ts
|
|
73
|
+
/**
|
|
74
|
+
* The real GraphQL client. Not implemented in this build.
|
|
75
|
+
*
|
|
76
|
+
* It exists so the port has its second implementation and the failure is a named
|
|
77
|
+
* Venn error rather than a missing method.
|
|
78
|
+
*
|
|
79
|
+
* @throws VN8090 from every method.
|
|
80
|
+
*/
|
|
81
|
+
declare function createRealClient(): GqlClient;
|
|
82
|
+
//#endregion
|
|
83
|
+
//#region src/plugin.d.ts
|
|
84
|
+
/**
|
|
85
|
+
* The `gql` namespace: `query`, `mutate`, `subscribe`, the `noGraphqlErrors`
|
|
86
|
+
* matcher and the `gql.GraphqlResponse` type.
|
|
87
|
+
*
|
|
88
|
+
* Requires the `net` capability, so a host without it refuses the plugin at load
|
|
89
|
+
* time rather than failing mid-flow.
|
|
90
|
+
*/
|
|
91
|
+
declare const gqlPlugin: PluginDefinition;
|
|
92
|
+
//#endregion
|
|
93
|
+
//#region src/schema/graphql-response.d.ts
|
|
94
|
+
/**
|
|
95
|
+
* Runtime validation for `gql.GraphqlResponse`, registered on the plugin.
|
|
96
|
+
*
|
|
97
|
+
* Pairs with `gqlTypeDefs.GraphqlResponse`, which tells the checker the same
|
|
98
|
+
* shape: this one guards a value, that one types an expression.
|
|
99
|
+
*/
|
|
100
|
+
declare const graphqlResponseType: ZodType;
|
|
101
|
+
//#endregion
|
|
102
|
+
export { type GqlClient, GqlClientPort, type GqlError, type GqlRequest, type GqlResponse, createFakeClient, createRealClient, gqlPlugin as default, gqlPlugin, graphqlResponseType, okGraphqlResponse };
|
|
103
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","names":[],"sources":["../src/port/gql-client.types.ts","../src/port/gql-client.port.ts","../src/clients/fake-client.ts","../src/clients/real-client.ts","../src/plugin.ts","../src/schema/graphql-response.ts"],"mappings":";;;;UACiB;EACf;EACA,YAAY;EACZ;;;UAIe;EACf;EACA;EACA,aAAa;;;;;;;;UASE;EACf;EACA,kBAAkB;;;;;;;;;;UAWH;EACf,QAAQ,KAAK,aAAa,QAAQ;EAClC,UAAU,KAAK,aAAa,QAAQ;;;;;;;;;;cC1BzB,eAAe,KAAK;;;;;;;;;iBCDjB,kBAAkB,YAAW,QAAQ,eAAoB;;;;;;;;;;;;iBAezD,iBACd;EAAQ,WAAW;EAAa,YAAY,eAAe;IAC1D;;;;;;;;;;;iBCda,oBAAoB;;;;;;;;;;cCEvB,WAAW;;;;;;;;;cCLX,qBAAqB"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { VennError } from "@venn-lang/contracts";
|
|
2
|
+
import { arg, defineAction, defineMatcher, definePlugin, z } from "@venn-lang/sdk";
|
|
3
|
+
import { t } from "@venn-lang/types";
|
|
4
|
+
//#region src/clients/fake-client.ts
|
|
5
|
+
/**
|
|
6
|
+
* A successful envelope: an empty `data` object and no `errors`.
|
|
7
|
+
*
|
|
8
|
+
* @param overrides Fields to replace on the default envelope.
|
|
9
|
+
* @returns A complete {@link GqlResponse}.
|
|
10
|
+
*/
|
|
11
|
+
function okGraphqlResponse(overrides = {}) {
|
|
12
|
+
return {
|
|
13
|
+
data: {},
|
|
14
|
+
errors: void 0,
|
|
15
|
+
...overrides
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* The double: canned envelopes, and no endpoint.
|
|
20
|
+
*
|
|
21
|
+
* A document with no entry in `responses` falls back to `response`, so a test
|
|
22
|
+
* only names the documents it cares about. `execute` and `subscribe` resolve
|
|
23
|
+
* from the same table.
|
|
24
|
+
*
|
|
25
|
+
* @param args.responses Envelopes keyed by the exact document text.
|
|
26
|
+
* @param args.response What every other document gets. Defaults to
|
|
27
|
+
* {@link okGraphqlResponse}.
|
|
28
|
+
*/
|
|
29
|
+
function createFakeClient(args = {}) {
|
|
30
|
+
const responses = args.responses ?? {};
|
|
31
|
+
const fallback = args.response ?? okGraphqlResponse();
|
|
32
|
+
const resolve = (req) => responses[req.query] ?? fallback;
|
|
33
|
+
return {
|
|
34
|
+
execute: async (req) => resolve(req),
|
|
35
|
+
subscribe: async (req) => resolve(req)
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
//#endregion
|
|
39
|
+
//#region src/clients/real-client.ts
|
|
40
|
+
/**
|
|
41
|
+
* The real GraphQL client. Not implemented in this build.
|
|
42
|
+
*
|
|
43
|
+
* It exists so the port has its second implementation and the failure is a named
|
|
44
|
+
* Venn error rather than a missing method.
|
|
45
|
+
*
|
|
46
|
+
* @throws VN8090 from every method.
|
|
47
|
+
*/
|
|
48
|
+
function createRealClient() {
|
|
49
|
+
return {
|
|
50
|
+
async execute() {
|
|
51
|
+
return unimplemented();
|
|
52
|
+
},
|
|
53
|
+
async subscribe() {
|
|
54
|
+
return unimplemented();
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
function unimplemented() {
|
|
59
|
+
throw new VennError({
|
|
60
|
+
code: "VN8090",
|
|
61
|
+
message: "GraphQL real client not implemented in this build"
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
//#endregion
|
|
65
|
+
//#region src/port/gql-client.port.ts
|
|
66
|
+
/**
|
|
67
|
+
* The port descriptor every `gql` verb resolves through `ctx.port(...)`.
|
|
68
|
+
*
|
|
69
|
+
* Declares the `net` capability, so a host that cannot reach an endpoint refuses
|
|
70
|
+
* the binding at load time with a readable diagnostic.
|
|
71
|
+
*/
|
|
72
|
+
const GqlClientPort = {
|
|
73
|
+
id: "venn.port.gql-client",
|
|
74
|
+
version: 1,
|
|
75
|
+
requires: ["net"],
|
|
76
|
+
methods: ["execute", "subscribe"]
|
|
77
|
+
};
|
|
78
|
+
//#endregion
|
|
79
|
+
//#region src/actions/gql-action.ts
|
|
80
|
+
const paramsSchema = z.object({
|
|
81
|
+
variables: z.record(z.string(), z.unknown()).optional(),
|
|
82
|
+
auth: z.string().optional()
|
|
83
|
+
});
|
|
84
|
+
/**
|
|
85
|
+
* Build one gql verb.
|
|
86
|
+
*
|
|
87
|
+
* The document is the single positional argument and everything else rides the
|
|
88
|
+
* options map. All three verbs return the same envelope, `subscribe` included,
|
|
89
|
+
* so a flow reads one shape whatever it asked for.
|
|
90
|
+
*
|
|
91
|
+
* @param config.name The verb as a flow writes it, such as `query`.
|
|
92
|
+
* @param config.transport Which {@link GqlClient} method carries it.
|
|
93
|
+
*/
|
|
94
|
+
function gqlAction(config) {
|
|
95
|
+
return defineAction({
|
|
96
|
+
name: config.name,
|
|
97
|
+
doc: `GraphQL ${config.name} operation.`,
|
|
98
|
+
params: paramsSchema.optional(),
|
|
99
|
+
args: [arg("document", t.string, "The query, mutation or subscription text.")],
|
|
100
|
+
result: t.ref("gql.GraphqlResponse"),
|
|
101
|
+
run: (ctx, input) => dispatch({
|
|
102
|
+
client: ctx.port(GqlClientPort),
|
|
103
|
+
transport: config.transport,
|
|
104
|
+
input
|
|
105
|
+
})
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
function dispatch(args) {
|
|
109
|
+
const request = buildRequest(args.input);
|
|
110
|
+
return args.transport === "subscribe" ? args.client.subscribe(request) : args.client.execute(request);
|
|
111
|
+
}
|
|
112
|
+
function buildRequest(input) {
|
|
113
|
+
const params = input.params ?? {};
|
|
114
|
+
return {
|
|
115
|
+
query: String(input.args[0] ?? ""),
|
|
116
|
+
variables: params.variables,
|
|
117
|
+
auth: params.auth
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
//#endregion
|
|
121
|
+
//#region src/actions/index.ts
|
|
122
|
+
/** The gql namespace's verbs. Adding one is a single line here. */
|
|
123
|
+
const gqlActions = [
|
|
124
|
+
gqlAction({
|
|
125
|
+
name: "query",
|
|
126
|
+
transport: "execute"
|
|
127
|
+
}),
|
|
128
|
+
gqlAction({
|
|
129
|
+
name: "mutate",
|
|
130
|
+
transport: "execute"
|
|
131
|
+
}),
|
|
132
|
+
gqlAction({
|
|
133
|
+
name: "subscribe",
|
|
134
|
+
transport: "subscribe"
|
|
135
|
+
})
|
|
136
|
+
];
|
|
137
|
+
//#endregion
|
|
138
|
+
//#region src/matchers/no-graphql-errors.ts
|
|
139
|
+
/**
|
|
140
|
+
* `expect res noGraphqlErrors`: passes when `errors` is absent or empty.
|
|
141
|
+
*
|
|
142
|
+
* GraphQL reports failures inside a 200 response, so this is the check that
|
|
143
|
+
* `expect res status 200` cannot make.
|
|
144
|
+
*/
|
|
145
|
+
const noGraphqlErrors = defineMatcher({
|
|
146
|
+
name: "noGraphqlErrors",
|
|
147
|
+
appliesTo: "GraphqlResponse",
|
|
148
|
+
test: ({ subject }) => isEmpty(subject.errors),
|
|
149
|
+
message: () => "expected the GraphQL response to carry no errors"
|
|
150
|
+
});
|
|
151
|
+
function isEmpty(errors) {
|
|
152
|
+
if (errors === void 0 || errors === null) return true;
|
|
153
|
+
return Array.isArray(errors) && errors.length === 0;
|
|
154
|
+
}
|
|
155
|
+
//#endregion
|
|
156
|
+
//#region src/matchers/index.ts
|
|
157
|
+
const gqlMatchers = [noGraphqlErrors];
|
|
158
|
+
//#endregion
|
|
159
|
+
//#region src/schema/graphql-response.ts
|
|
160
|
+
/**
|
|
161
|
+
* Runtime validation for `gql.GraphqlResponse`, registered on the plugin.
|
|
162
|
+
*
|
|
163
|
+
* Pairs with `gqlTypeDefs.GraphqlResponse`, which tells the checker the same
|
|
164
|
+
* shape: this one guards a value, that one types an expression.
|
|
165
|
+
*/
|
|
166
|
+
const graphqlResponseType = z.object({
|
|
167
|
+
data: z.unknown().optional(),
|
|
168
|
+
errors: z.array(z.unknown()).optional()
|
|
169
|
+
});
|
|
170
|
+
//#endregion
|
|
171
|
+
//#region src/types.ts
|
|
172
|
+
/**
|
|
173
|
+
* The types the plugin publishes to flows: `gql.GraphqlResponse` and
|
|
174
|
+
* `gql.GraphqlError`.
|
|
175
|
+
*
|
|
176
|
+
* They mirror `port/gql-client.types.ts` by hand, under the names the rest of
|
|
177
|
+
* the plugin already answers to: `noGraphqlErrors` applies to `GraphqlResponse`,
|
|
178
|
+
* so the type a flow reaches for is the same word.
|
|
179
|
+
*/
|
|
180
|
+
const gqlTypeDefs = {
|
|
181
|
+
/**
|
|
182
|
+
* The `{ data, errors }` envelope every gql verb gives back.
|
|
183
|
+
*
|
|
184
|
+
* Open, because a server may answer with more than the two entries the client
|
|
185
|
+
* reads, `extensions` above all.
|
|
186
|
+
*/
|
|
187
|
+
GraphqlResponse: t.record({
|
|
188
|
+
data: t.dynamic,
|
|
189
|
+
errors: t.list(t.ref("gql.GraphqlError"))
|
|
190
|
+
}, {
|
|
191
|
+
optional: ["data", "errors"],
|
|
192
|
+
open: true
|
|
193
|
+
}),
|
|
194
|
+
/** One entry of `errors`. `path` walks down `data` to the field that failed. */
|
|
195
|
+
GraphqlError: t.record({
|
|
196
|
+
message: t.string,
|
|
197
|
+
path: t.list(t.union(t.string, t.number)),
|
|
198
|
+
extensions: t.map(t.dynamic)
|
|
199
|
+
}, {
|
|
200
|
+
optional: ["path", "extensions"],
|
|
201
|
+
open: true
|
|
202
|
+
})
|
|
203
|
+
};
|
|
204
|
+
//#endregion
|
|
205
|
+
//#region src/plugin.ts
|
|
206
|
+
/**
|
|
207
|
+
* The `gql` namespace: `query`, `mutate`, `subscribe`, the `noGraphqlErrors`
|
|
208
|
+
* matcher and the `gql.GraphqlResponse` type.
|
|
209
|
+
*
|
|
210
|
+
* Requires the `net` capability, so a host without it refuses the plugin at load
|
|
211
|
+
* time rather than failing mid-flow.
|
|
212
|
+
*/
|
|
213
|
+
const gqlPlugin = definePlugin({
|
|
214
|
+
name: "venn/graphql",
|
|
215
|
+
version: "0.0.0",
|
|
216
|
+
namespace: "gql",
|
|
217
|
+
requires: ["net"],
|
|
218
|
+
actions: gqlActions,
|
|
219
|
+
matchers: gqlMatchers,
|
|
220
|
+
types: { GraphqlResponse: graphqlResponseType },
|
|
221
|
+
typeDefs: gqlTypeDefs
|
|
222
|
+
});
|
|
223
|
+
//#endregion
|
|
224
|
+
export { GqlClientPort, createFakeClient, createRealClient, gqlPlugin as default, gqlPlugin, graphqlResponseType, okGraphqlResponse };
|
|
225
|
+
|
|
226
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../src/clients/fake-client.ts","../src/clients/real-client.ts","../src/port/gql-client.port.ts","../src/actions/gql-action.ts","../src/actions/index.ts","../src/matchers/no-graphql-errors.ts","../src/matchers/index.ts","../src/schema/graphql-response.ts","../src/types.ts","../src/plugin.ts"],"sourcesContent":["import type { GqlClient, GqlRequest, GqlResponse } from \"../port/index.js\";\n\n/**\n * A successful envelope: an empty `data` object and no `errors`.\n *\n * @param overrides Fields to replace on the default envelope.\n * @returns A complete {@link GqlResponse}.\n */\nexport function okGraphqlResponse(overrides: Partial<GqlResponse> = {}): GqlResponse {\n return { data: {}, errors: undefined, ...overrides };\n}\n\n/**\n * The double: canned envelopes, and no endpoint.\n *\n * A document with no entry in `responses` falls back to `response`, so a test\n * only names the documents it cares about. `execute` and `subscribe` resolve\n * from the same table.\n *\n * @param args.responses Envelopes keyed by the exact document text.\n * @param args.response What every other document gets. Defaults to\n * {@link okGraphqlResponse}.\n */\nexport function createFakeClient(\n args: { response?: GqlResponse; responses?: Record<string, GqlResponse> } = {},\n): GqlClient {\n const responses = args.responses ?? {};\n const fallback = args.response ?? okGraphqlResponse();\n const resolve = (req: GqlRequest): GqlResponse => responses[req.query] ?? fallback;\n return {\n execute: async (req) => resolve(req),\n subscribe: async (req) => resolve(req),\n };\n}\n","import { VennError } from \"@venn-lang/contracts\";\nimport type { GqlClient } from \"../port/index.js\";\n\n/**\n * The real GraphQL client. Not implemented in this build.\n *\n * It exists so the port has its second implementation and the failure is a named\n * Venn error rather than a missing method.\n *\n * @throws VN8090 from every method.\n */\nexport function createRealClient(): GqlClient {\n return {\n async execute() {\n return unimplemented();\n },\n async subscribe() {\n return unimplemented();\n },\n };\n}\n\nfunction unimplemented(): never {\n throw new VennError({\n code: \"VN8090\",\n message: \"GraphQL real client not implemented in this build\",\n });\n}\n","import type { Port } from \"@venn-lang/contracts\";\nimport type { GqlClient } from \"./gql-client.types.js\";\n\n/**\n * The port descriptor every `gql` verb resolves through `ctx.port(...)`.\n *\n * Declares the `net` capability, so a host that cannot reach an endpoint refuses\n * the binding at load time with a readable diagnostic.\n */\nexport const GqlClientPort: Port<GqlClient> = {\n id: \"venn.port.gql-client\",\n version: 1,\n requires: [\"net\"],\n methods: [\"execute\", \"subscribe\"],\n};\n","import { type ActionDefinition, type ActionInput, arg, defineAction, z } from \"@venn-lang/sdk\";\nimport { t } from \"@venn-lang/types\";\nimport { type GqlClient, GqlClientPort, type GqlRequest } from \"../port/index.js\";\n\nconst paramsSchema = z.object({\n variables: z.record(z.string(), z.unknown()).optional(),\n auth: z.string().optional(),\n});\n\n/** Which port method a verb rides. `query` and `mutate` execute; `subscribe` streams. */\ntype GqlTransport = \"execute\" | \"subscribe\";\n\n/**\n * Build one gql verb.\n *\n * The document is the single positional argument and everything else rides the\n * options map. All three verbs return the same envelope, `subscribe` included,\n * so a flow reads one shape whatever it asked for.\n *\n * @param config.name The verb as a flow writes it, such as `query`.\n * @param config.transport Which {@link GqlClient} method carries it.\n */\nexport function gqlAction(config: { name: string; transport: GqlTransport }): ActionDefinition {\n return defineAction({\n name: config.name,\n doc: `GraphQL ${config.name} operation.`,\n params: paramsSchema.optional(),\n args: [arg(\"document\", t.string, \"The query, mutation or subscription text.\")],\n result: t.ref(\"gql.GraphqlResponse\"),\n run: (ctx, input) =>\n dispatch({ client: ctx.port(GqlClientPort), transport: config.transport, input }),\n });\n}\n\nfunction dispatch(args: {\n client: GqlClient;\n transport: GqlTransport;\n input: ActionInput<unknown>;\n}): Promise<unknown> {\n const request = buildRequest(args.input);\n return args.transport === \"subscribe\"\n ? args.client.subscribe(request)\n : args.client.execute(request);\n}\n\nfunction buildRequest(input: ActionInput<unknown>): GqlRequest {\n const params = (input.params ?? {}) as { variables?: Record<string, unknown>; auth?: string };\n return { query: String(input.args[0] ?? \"\"), variables: params.variables, auth: params.auth };\n}\n","import type { ActionDefinition } from \"@venn-lang/sdk\";\nimport { gqlAction } from \"./gql-action.js\";\n\n/** The gql namespace's verbs. Adding one is a single line here. */\nexport const gqlActions: ActionDefinition[] = [\n gqlAction({ name: \"query\", transport: \"execute\" }),\n gqlAction({ name: \"mutate\", transport: \"execute\" }),\n gqlAction({ name: \"subscribe\", transport: \"subscribe\" }),\n];\n","import { defineMatcher, type MatcherDefinition } from \"@venn-lang/sdk\";\n\n/**\n * `expect res noGraphqlErrors`: passes when `errors` is absent or empty.\n *\n * GraphQL reports failures inside a 200 response, so this is the check that\n * `expect res status 200` cannot make.\n */\nexport const noGraphqlErrors: MatcherDefinition = defineMatcher({\n name: \"noGraphqlErrors\",\n appliesTo: \"GraphqlResponse\",\n test: ({ subject }) => isEmpty((subject as { errors?: unknown }).errors),\n message: () => \"expected the GraphQL response to carry no errors\",\n});\n\nfunction isEmpty(errors: unknown): boolean {\n if (errors === undefined || errors === null) return true;\n return Array.isArray(errors) && errors.length === 0;\n}\n","import type { MatcherDefinition } from \"@venn-lang/sdk\";\nimport { noGraphqlErrors } from \"./no-graphql-errors.js\";\n\nexport const gqlMatchers: MatcherDefinition[] = [noGraphqlErrors];\n","import { type ZodType, z } from \"@venn-lang/sdk\";\n\n/**\n * Runtime validation for `gql.GraphqlResponse`, registered on the plugin.\n *\n * Pairs with `gqlTypeDefs.GraphqlResponse`, which tells the checker the same\n * shape: this one guards a value, that one types an expression.\n */\nexport const graphqlResponseType: ZodType = z.object({\n data: z.unknown().optional(),\n errors: z.array(z.unknown()).optional(),\n});\n","import { type TypeSpec, t } from \"@venn-lang/types\";\n\n/**\n * The types the plugin publishes to flows: `gql.GraphqlResponse` and\n * `gql.GraphqlError`.\n *\n * They mirror `port/gql-client.types.ts` by hand, under the names the rest of\n * the plugin already answers to: `noGraphqlErrors` applies to `GraphqlResponse`,\n * so the type a flow reaches for is the same word.\n */\nexport const gqlTypeDefs: Readonly<Record<string, TypeSpec>> = {\n /**\n * The `{ data, errors }` envelope every gql verb gives back.\n *\n * Open, because a server may answer with more than the two entries the client\n * reads, `extensions` above all.\n */\n GraphqlResponse: t.record(\n { data: t.dynamic, errors: t.list(t.ref(\"gql.GraphqlError\")) },\n { optional: [\"data\", \"errors\"], open: true },\n ),\n /** One entry of `errors`. `path` walks down `data` to the field that failed. */\n GraphqlError: t.record(\n {\n message: t.string,\n path: t.list(t.union(t.string, t.number)),\n extensions: t.map(t.dynamic),\n },\n { optional: [\"path\", \"extensions\"], open: true },\n ),\n};\n","import { definePlugin, type PluginDefinition } from \"@venn-lang/sdk\";\nimport { gqlActions } from \"./actions/index.js\";\nimport { gqlMatchers } from \"./matchers/index.js\";\nimport { graphqlResponseType } from \"./schema/index.js\";\nimport { gqlTypeDefs } from \"./types.js\";\n\n/**\n * The `gql` namespace: `query`, `mutate`, `subscribe`, the `noGraphqlErrors`\n * matcher and the `gql.GraphqlResponse` type.\n *\n * Requires the `net` capability, so a host without it refuses the plugin at load\n * time rather than failing mid-flow.\n */\nexport const gqlPlugin: PluginDefinition = definePlugin({\n name: \"venn/graphql\",\n version: \"0.0.0\",\n namespace: \"gql\",\n requires: [\"net\"],\n actions: gqlActions,\n matchers: gqlMatchers,\n types: { GraphqlResponse: graphqlResponseType },\n typeDefs: gqlTypeDefs,\n});\n"],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@venn-lang/graphql",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The gql namespace: GraphQL queries, mutations and subscriptions as Venn verbs.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"venn",
|
|
7
|
+
"testing",
|
|
8
|
+
"e2e",
|
|
9
|
+
"graphql"
|
|
10
|
+
],
|
|
11
|
+
"homepage": "https://github.com/venn-lang/venn/tree/main/packages/std-graphql#readme",
|
|
12
|
+
"bugs": "https://github.com/venn-lang/venn/issues",
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/venn-lang/venn.git",
|
|
16
|
+
"directory": "packages/std-graphql"
|
|
17
|
+
},
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"author": "Vinicius Borges",
|
|
20
|
+
"type": "module",
|
|
21
|
+
"sideEffects": false,
|
|
22
|
+
"exports": {
|
|
23
|
+
".": {
|
|
24
|
+
"development": "./src/index.ts",
|
|
25
|
+
"types": "./dist/index.d.ts",
|
|
26
|
+
"import": "./dist/index.js",
|
|
27
|
+
"default": "./dist/index.js"
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"dist",
|
|
32
|
+
"src",
|
|
33
|
+
"!src/**/*.test.ts",
|
|
34
|
+
"!src/**/*.suite.ts"
|
|
35
|
+
],
|
|
36
|
+
"publishConfig": {
|
|
37
|
+
"access": "public"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@venn-lang/contracts": "0.1.0",
|
|
41
|
+
"@venn-lang/types": "0.1.0",
|
|
42
|
+
"@venn-lang/sdk": "0.1.0"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"tsdown": "^0.22.14",
|
|
46
|
+
"typescript": "^7.0.2",
|
|
47
|
+
"vitest": "^4.1.10"
|
|
48
|
+
},
|
|
49
|
+
"scripts": {
|
|
50
|
+
"build": "tsdown",
|
|
51
|
+
"test": "vitest run",
|
|
52
|
+
"typecheck": "tsc --noEmit"
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { type ActionDefinition, type ActionInput, arg, defineAction, z } from "@venn-lang/sdk";
|
|
2
|
+
import { t } from "@venn-lang/types";
|
|
3
|
+
import { type GqlClient, GqlClientPort, type GqlRequest } from "../port/index.js";
|
|
4
|
+
|
|
5
|
+
const paramsSchema = z.object({
|
|
6
|
+
variables: z.record(z.string(), z.unknown()).optional(),
|
|
7
|
+
auth: z.string().optional(),
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
/** Which port method a verb rides. `query` and `mutate` execute; `subscribe` streams. */
|
|
11
|
+
type GqlTransport = "execute" | "subscribe";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Build one gql verb.
|
|
15
|
+
*
|
|
16
|
+
* The document is the single positional argument and everything else rides the
|
|
17
|
+
* options map. All three verbs return the same envelope, `subscribe` included,
|
|
18
|
+
* so a flow reads one shape whatever it asked for.
|
|
19
|
+
*
|
|
20
|
+
* @param config.name The verb as a flow writes it, such as `query`.
|
|
21
|
+
* @param config.transport Which {@link GqlClient} method carries it.
|
|
22
|
+
*/
|
|
23
|
+
export function gqlAction(config: { name: string; transport: GqlTransport }): ActionDefinition {
|
|
24
|
+
return defineAction({
|
|
25
|
+
name: config.name,
|
|
26
|
+
doc: `GraphQL ${config.name} operation.`,
|
|
27
|
+
params: paramsSchema.optional(),
|
|
28
|
+
args: [arg("document", t.string, "The query, mutation or subscription text.")],
|
|
29
|
+
result: t.ref("gql.GraphqlResponse"),
|
|
30
|
+
run: (ctx, input) =>
|
|
31
|
+
dispatch({ client: ctx.port(GqlClientPort), transport: config.transport, input }),
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function dispatch(args: {
|
|
36
|
+
client: GqlClient;
|
|
37
|
+
transport: GqlTransport;
|
|
38
|
+
input: ActionInput<unknown>;
|
|
39
|
+
}): Promise<unknown> {
|
|
40
|
+
const request = buildRequest(args.input);
|
|
41
|
+
return args.transport === "subscribe"
|
|
42
|
+
? args.client.subscribe(request)
|
|
43
|
+
: args.client.execute(request);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function buildRequest(input: ActionInput<unknown>): GqlRequest {
|
|
47
|
+
const params = (input.params ?? {}) as { variables?: Record<string, unknown>; auth?: string };
|
|
48
|
+
return { query: String(input.args[0] ?? ""), variables: params.variables, auth: params.auth };
|
|
49
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ActionDefinition } from "@venn-lang/sdk";
|
|
2
|
+
import { gqlAction } from "./gql-action.js";
|
|
3
|
+
|
|
4
|
+
/** The gql namespace's verbs. Adding one is a single line here. */
|
|
5
|
+
export const gqlActions: ActionDefinition[] = [
|
|
6
|
+
gqlAction({ name: "query", transport: "execute" }),
|
|
7
|
+
gqlAction({ name: "mutate", transport: "execute" }),
|
|
8
|
+
gqlAction({ name: "subscribe", transport: "subscribe" }),
|
|
9
|
+
];
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { GqlClient, GqlRequest, GqlResponse } from "../port/index.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A successful envelope: an empty `data` object and no `errors`.
|
|
5
|
+
*
|
|
6
|
+
* @param overrides Fields to replace on the default envelope.
|
|
7
|
+
* @returns A complete {@link GqlResponse}.
|
|
8
|
+
*/
|
|
9
|
+
export function okGraphqlResponse(overrides: Partial<GqlResponse> = {}): GqlResponse {
|
|
10
|
+
return { data: {}, errors: undefined, ...overrides };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The double: canned envelopes, and no endpoint.
|
|
15
|
+
*
|
|
16
|
+
* A document with no entry in `responses` falls back to `response`, so a test
|
|
17
|
+
* only names the documents it cares about. `execute` and `subscribe` resolve
|
|
18
|
+
* from the same table.
|
|
19
|
+
*
|
|
20
|
+
* @param args.responses Envelopes keyed by the exact document text.
|
|
21
|
+
* @param args.response What every other document gets. Defaults to
|
|
22
|
+
* {@link okGraphqlResponse}.
|
|
23
|
+
*/
|
|
24
|
+
export function createFakeClient(
|
|
25
|
+
args: { response?: GqlResponse; responses?: Record<string, GqlResponse> } = {},
|
|
26
|
+
): GqlClient {
|
|
27
|
+
const responses = args.responses ?? {};
|
|
28
|
+
const fallback = args.response ?? okGraphqlResponse();
|
|
29
|
+
const resolve = (req: GqlRequest): GqlResponse => responses[req.query] ?? fallback;
|
|
30
|
+
return {
|
|
31
|
+
execute: async (req) => resolve(req),
|
|
32
|
+
subscribe: async (req) => resolve(req),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { VennError } from "@venn-lang/contracts";
|
|
2
|
+
import type { GqlClient } from "../port/index.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The real GraphQL client. Not implemented in this build.
|
|
6
|
+
*
|
|
7
|
+
* It exists so the port has its second implementation and the failure is a named
|
|
8
|
+
* Venn error rather than a missing method.
|
|
9
|
+
*
|
|
10
|
+
* @throws VN8090 from every method.
|
|
11
|
+
*/
|
|
12
|
+
export function createRealClient(): GqlClient {
|
|
13
|
+
return {
|
|
14
|
+
async execute() {
|
|
15
|
+
return unimplemented();
|
|
16
|
+
},
|
|
17
|
+
async subscribe() {
|
|
18
|
+
return unimplemented();
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function unimplemented(): never {
|
|
24
|
+
throw new VennError({
|
|
25
|
+
code: "VN8090",
|
|
26
|
+
message: "GraphQL real client not implemented in this build",
|
|
27
|
+
});
|
|
28
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `gql` plugin: `query`, `mutate` and `subscribe`, plus the
|
|
3
|
+
* `noGraphqlErrors` matcher.
|
|
4
|
+
*
|
|
5
|
+
* All three verbs cross the `GqlClient` port and answer with the same
|
|
6
|
+
* `{ data, errors }` envelope, so a flow reads one shape whatever it asked for.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export * from "./clients/index.js";
|
|
10
|
+
export { gqlPlugin, gqlPlugin as default } from "./plugin.js";
|
|
11
|
+
export * from "./port/index.js";
|
|
12
|
+
export * from "./schema/index.js";
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { defineMatcher, type MatcherDefinition } from "@venn-lang/sdk";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `expect res noGraphqlErrors`: passes when `errors` is absent or empty.
|
|
5
|
+
*
|
|
6
|
+
* GraphQL reports failures inside a 200 response, so this is the check that
|
|
7
|
+
* `expect res status 200` cannot make.
|
|
8
|
+
*/
|
|
9
|
+
export const noGraphqlErrors: MatcherDefinition = defineMatcher({
|
|
10
|
+
name: "noGraphqlErrors",
|
|
11
|
+
appliesTo: "GraphqlResponse",
|
|
12
|
+
test: ({ subject }) => isEmpty((subject as { errors?: unknown }).errors),
|
|
13
|
+
message: () => "expected the GraphQL response to carry no errors",
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
function isEmpty(errors: unknown): boolean {
|
|
17
|
+
if (errors === undefined || errors === null) return true;
|
|
18
|
+
return Array.isArray(errors) && errors.length === 0;
|
|
19
|
+
}
|
package/src/plugin.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { definePlugin, type PluginDefinition } from "@venn-lang/sdk";
|
|
2
|
+
import { gqlActions } from "./actions/index.js";
|
|
3
|
+
import { gqlMatchers } from "./matchers/index.js";
|
|
4
|
+
import { graphqlResponseType } from "./schema/index.js";
|
|
5
|
+
import { gqlTypeDefs } from "./types.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The `gql` namespace: `query`, `mutate`, `subscribe`, the `noGraphqlErrors`
|
|
9
|
+
* matcher and the `gql.GraphqlResponse` type.
|
|
10
|
+
*
|
|
11
|
+
* Requires the `net` capability, so a host without it refuses the plugin at load
|
|
12
|
+
* time rather than failing mid-flow.
|
|
13
|
+
*/
|
|
14
|
+
export const gqlPlugin: PluginDefinition = definePlugin({
|
|
15
|
+
name: "venn/graphql",
|
|
16
|
+
version: "0.0.0",
|
|
17
|
+
namespace: "gql",
|
|
18
|
+
requires: ["net"],
|
|
19
|
+
actions: gqlActions,
|
|
20
|
+
matchers: gqlMatchers,
|
|
21
|
+
types: { GraphqlResponse: graphqlResponseType },
|
|
22
|
+
typeDefs: gqlTypeDefs,
|
|
23
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Port } from "@venn-lang/contracts";
|
|
2
|
+
import type { GqlClient } from "./gql-client.types.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The port descriptor every `gql` verb resolves through `ctx.port(...)`.
|
|
6
|
+
*
|
|
7
|
+
* Declares the `net` capability, so a host that cannot reach an endpoint refuses
|
|
8
|
+
* the binding at load time with a readable diagnostic.
|
|
9
|
+
*/
|
|
10
|
+
export const GqlClientPort: Port<GqlClient> = {
|
|
11
|
+
id: "venn.port.gql-client",
|
|
12
|
+
version: 1,
|
|
13
|
+
requires: ["net"],
|
|
14
|
+
methods: ["execute", "subscribe"],
|
|
15
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** One operation to run: the document text, its variables and any bearer token. */
|
|
2
|
+
export interface GqlRequest {
|
|
3
|
+
query: string;
|
|
4
|
+
variables?: Record<string, unknown>;
|
|
5
|
+
auth?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** One entry of `errors`. `path` walks down `data` to the field that failed. */
|
|
9
|
+
export interface GqlError {
|
|
10
|
+
message: string;
|
|
11
|
+
path?: readonly (string | number)[];
|
|
12
|
+
extensions?: Record<string, unknown>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The `{ data, errors }` envelope, and what `res` holds after a gql verb.
|
|
17
|
+
*
|
|
18
|
+
* GraphQL answers 200 with errors inside, so a failed operation arrives here and
|
|
19
|
+
* not as a thrown error. `expect res noGraphqlErrors` is how a flow checks.
|
|
20
|
+
*/
|
|
21
|
+
export interface GqlResponse {
|
|
22
|
+
data?: unknown;
|
|
23
|
+
errors?: readonly GqlError[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Running one GraphQL operation against an endpoint.
|
|
28
|
+
*
|
|
29
|
+
* `execute` and `subscribe` are separate methods because the transports differ,
|
|
30
|
+
* even though both answer with the same envelope.
|
|
31
|
+
*
|
|
32
|
+
* Two implementations: `createRealClient` and `createFakeClient`.
|
|
33
|
+
*/
|
|
34
|
+
export interface GqlClient {
|
|
35
|
+
execute(req: GqlRequest): Promise<GqlResponse>;
|
|
36
|
+
subscribe(req: GqlRequest): Promise<GqlResponse>;
|
|
37
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type ZodType, z } from "@venn-lang/sdk";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Runtime validation for `gql.GraphqlResponse`, registered on the plugin.
|
|
5
|
+
*
|
|
6
|
+
* Pairs with `gqlTypeDefs.GraphqlResponse`, which tells the checker the same
|
|
7
|
+
* shape: this one guards a value, that one types an expression.
|
|
8
|
+
*/
|
|
9
|
+
export const graphqlResponseType: ZodType = z.object({
|
|
10
|
+
data: z.unknown().optional(),
|
|
11
|
+
errors: z.array(z.unknown()).optional(),
|
|
12
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { graphqlResponseType } from "./graphql-response.js";
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type TypeSpec, t } from "@venn-lang/types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The types the plugin publishes to flows: `gql.GraphqlResponse` and
|
|
5
|
+
* `gql.GraphqlError`.
|
|
6
|
+
*
|
|
7
|
+
* They mirror `port/gql-client.types.ts` by hand, under the names the rest of
|
|
8
|
+
* the plugin already answers to: `noGraphqlErrors` applies to `GraphqlResponse`,
|
|
9
|
+
* so the type a flow reaches for is the same word.
|
|
10
|
+
*/
|
|
11
|
+
export const gqlTypeDefs: Readonly<Record<string, TypeSpec>> = {
|
|
12
|
+
/**
|
|
13
|
+
* The `{ data, errors }` envelope every gql verb gives back.
|
|
14
|
+
*
|
|
15
|
+
* Open, because a server may answer with more than the two entries the client
|
|
16
|
+
* reads, `extensions` above all.
|
|
17
|
+
*/
|
|
18
|
+
GraphqlResponse: t.record(
|
|
19
|
+
{ data: t.dynamic, errors: t.list(t.ref("gql.GraphqlError")) },
|
|
20
|
+
{ optional: ["data", "errors"], open: true },
|
|
21
|
+
),
|
|
22
|
+
/** One entry of `errors`. `path` walks down `data` to the field that failed. */
|
|
23
|
+
GraphqlError: t.record(
|
|
24
|
+
{
|
|
25
|
+
message: t.string,
|
|
26
|
+
path: t.list(t.union(t.string, t.number)),
|
|
27
|
+
extensions: t.map(t.dynamic),
|
|
28
|
+
},
|
|
29
|
+
{ optional: ["path", "extensions"], open: true },
|
|
30
|
+
),
|
|
31
|
+
};
|