Repository navigation
Expand file tree
/
Copy pathgraphwire.go
More file actions
372 lines (343 loc) · 10.8 KB
/
Copy pathgraphwire.go
File metadata and controls
372 lines (343 loc) · 10.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
// SPDX-License-Identifier: Apache-2.0
// Package graphwire generates an application's graph resolver root from the
// plugin manifests and SDL of every directory under each configured plugin root.
package graphwire
import (
"encoding/json"
"errors"
"fmt"
"go/format"
"go/token"
"os"
"path"
"path/filepath"
"regexp"
"sort"
"strings"
"github.com/vektah/gqlparser/v2/ast"
"github.com/vektah/gqlparser/v2/parser"
)
// Config parameterizes the resolver root generation for the consuming application.
type Config struct {
// ExecImport is the gqlgen exec package declaring ResolverRoot.
ExecImport string
// CoreImport is the package exporting the core resolver sets.
CoreImport string
// CoreSchemaGlobs locate the core SDL files relative to the root.
CoreSchemaGlobs []string
// WiringPath is the generated file destination relative to the root.
WiringPath string
// License is the SPDX identifier of the generated header.
License string
// Package names the generated package, exporting its identifiers and
// the FromPlugins assembler. Empty generates an unexported package main.
Package string
// SDKImport is the package declaring the plugin interface, required
// with Package for the FromPlugins assembler.
SDKImport string
// Roots lists the plugin root directories scanned in order, empty scanning plugins.
Roots []string
}
// validateConfig checks that every Config field is set.
func validateConfig(cfg Config) error {
fields := []struct {
name string
set bool
}{
{"ExecImport", cfg.ExecImport != ""},
{"CoreImport", cfg.CoreImport != ""},
{"CoreSchemaGlobs", len(cfg.CoreSchemaGlobs) > 0},
{"WiringPath", cfg.WiringPath != ""},
{"License", cfg.License != ""},
}
for _, field := range fields {
if !field.set {
return fmt.Errorf("graphwire: Config.%s is required", field.name)
}
}
if cfg.Package != "" && cfg.SDKImport == "" {
return errors.New("graphwire: Config.SDKImport is required with Config.Package")
}
return nil
}
// manifest is the subset of plugin.json the graph wiring reads.
type manifest struct {
ID string `json:"id"`
Backend string `json:"backend"`
GraphQL bool `json:"graphql"`
root string
}
// roots returns the plugin root directories scanned in order, defaulting to plugins.
func (c Config) roots() []string {
if len(c.Roots) == 0 {
return []string{"plugins"}
}
return c.Roots
}
// loadRoots loads the manifests under every plugin root in order, rejecting an id present in more than one root.
func loadRoots(dir string, roots []string) ([]manifest, error) {
var manifests []manifest
seen := make(map[string]string, len(roots))
for _, pluginRoot := range roots {
loaded, err := loadManifests(filepath.Join(dir, pluginRoot))
if err != nil {
return nil, err
}
for i, m := range loaded {
if previous, ok := seen[m.ID]; ok {
return nil, fmt.Errorf("graphwire: plugin %s appears under %s and %s", m.ID, previous, pluginRoot)
}
seen[m.ID] = pluginRoot
loaded[i].root = pluginRoot
}
manifests = append(manifests, loaded...)
}
return manifests, nil
}
var idPattern = regexp.MustCompile(`^[a-z][a-z0-9-]*$`)
// loadManifests loads the plugin manifest in each subdirectory of dir.
func loadManifests(dir string) ([]manifest, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return nil, fmt.Errorf("graphwire: reading plugins directory: %w", err)
}
manifests := make([]manifest, 0, len(entries))
for _, entry := range entries {
if !entry.IsDir() {
continue
}
path := filepath.Join(dir, entry.Name(), "plugin.json")
m, err := loadManifest(path, entry.Name())
if err != nil {
return nil, err
}
manifests = append(manifests, m)
}
return manifests, nil
}
// loadManifest reads and validates one plugin manifest.
func loadManifest(path, dir string) (manifest, error) {
data, err := os.ReadFile(path)
if err != nil {
return manifest{}, fmt.Errorf("graphwire: %s: %w", path, err)
}
var m manifest
if err := json.Unmarshal(data, &m); err != nil {
return manifest{}, fmt.Errorf("graphwire: %s: %w", path, err)
}
if err := validateManifest(m, path, dir); err != nil {
return manifest{}, err
}
return m, nil
}
// validateManifest checks the manifest fields the graph wiring relies on.
func validateManifest(m manifest, path, dir string) error {
if !idPattern.MatchString(m.ID) || m.ID != dir {
return fmt.Errorf("graphwire: %s: id %q does not match directory %q", path, m.ID, dir)
}
if m.GraphQL && m.Backend == "" {
return fmt.Errorf("graphwire: %s: graphql plugins require a backend", path)
}
return nil
}
// goName returns id as a valid Go identifier.
func goName(id string) string {
return strings.ReplaceAll(id, "-", "_")
}
// contributor is one package contributing resolver sets to the graph.
type contributor struct {
alias string
path string
field string
param string
types []string
}
// isRootType reports whether name is a GraphQL operation root type.
func isRootType(name string) bool {
return name == "Query" || name == "Mutation" || name == "Subscription"
}
// forcesResolver reports whether field carries goField(forceResolver: true).
func forcesResolver(field *ast.FieldDefinition) bool {
directive := field.Directives.ForName("goField")
if directive == nil {
return false
}
arg := directive.Arguments.ForName("forceResolver")
return arg != nil && arg.Value != nil && arg.Value.Raw == "true"
}
// collectResolverType records def when it carries resolver backed fields.
func collectResolverType(set map[string]bool, def *ast.Definition) {
if def.Kind != ast.Object {
return
}
if needsResolverSet(def) {
set[def.Name] = true
}
}
// needsResolverSet reports whether def gets a gqlgen resolver interface.
func needsResolverSet(def *ast.Definition) bool {
if isRootType(def.Name) {
return len(def.Fields) > 0
}
for _, field := range def.Fields {
if len(field.Arguments) > 0 || forcesResolver(field) {
return true
}
}
return false
}
// resolverTypes returns the GraphQL types of the SDL files needing resolver sets.
func resolverTypes(files []string) ([]string, error) {
set := map[string]bool{}
for _, path := range files {
if err := collectFileTypes(set, path); err != nil {
return nil, err
}
}
names := make([]string, 0, len(set))
for name := range set {
names = append(names, name)
}
sort.Strings(names)
return names, nil
}
// collectFileTypes records the resolver bearing types of one SDL file.
func collectFileTypes(set map[string]bool, path string) error {
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("graphwire: %s: %w", path, err)
}
doc, err := parser.ParseSchema(&ast.Source{Name: path, Input: string(data)})
if err != nil {
return fmt.Errorf("graphwire: %s: %w", path, err)
}
for _, def := range append(doc.Definitions, doc.Extensions...) {
collectResolverType(set, def)
}
return nil
}
// globFiles expands the globs under root, in glob order.
func globFiles(root string, globs []string) ([]string, error) {
var files []string
for _, pattern := range globs {
matches, err := filepath.Glob(filepath.Join(root, filepath.FromSlash(pattern)))
if err != nil {
return nil, fmt.Errorf("graphwire: glob %s: %w", pattern, err)
}
sort.Strings(matches)
files = append(files, matches...)
}
return files, nil
}
// coreContributor scans the core SDL into its contributor entry.
func coreContributor(root string, cfg Config) (contributor, error) {
files, err := globFiles(root, cfg.CoreSchemaGlobs)
if err != nil {
return contributor{}, err
}
if len(files) == 0 {
return contributor{}, errors.New("graphwire: no core schema files match the configured globs")
}
types, err := resolverTypes(files)
if err != nil {
return contributor{}, err
}
return contributor{
alias: coreImportName(cfg),
path: cfg.CoreImport,
field: "core",
param: "core",
types: types,
}, nil
}
// coreImportName returns the Go name the generated wiring imports the core package under.
func coreImportName(cfg Config) string {
return goName(path.Base(cfg.CoreImport))
}
// reservedNames are the Go names the generated wiring owns, keyed by whether it writes a named package.
var reservedNames = map[bool]map[string]bool{
false: {"core": true, "graph": true, "init": true, "main": true},
true: {"core": true, "graph": true, "init": true, "sdk": true, "errors": true, "error": true, "nil": true},
}
// refuseCollision rejects a graphql plugin id whose Go name collides with a name of the generated wiring.
func refuseCollision(cfg Config, id string) error {
name := goName(id)
if token.IsKeyword(name) || reservedNames[namingFor(cfg).packageMode][name] || name == coreImportName(cfg) {
return fmt.Errorf("graphwire: plugin %s: its Go name %s collides with the generated wiring", id, name)
}
return nil
}
// pluginContributors scans every graphql flagged plugin into contributor entries.
func pluginContributors(root string, cfg Config, manifests []manifest) ([]contributor, error) {
var contributors []contributor
for _, m := range manifests {
if !m.GraphQL {
continue
}
if err := refuseCollision(cfg, m.ID); err != nil {
return nil, err
}
scanned, err := scanPlugin(root, m)
if err != nil {
return nil, err
}
contributors = append(contributors, scanned)
}
return contributors, nil
}
// scanPlugin reads one graphql plugin's SDL into its contributor entry.
func scanPlugin(root string, m manifest) (contributor, error) {
files, err := globFiles(root, []string{m.root + "/" + m.ID + "/graph/*.graphqls"})
if err != nil {
return contributor{}, err
}
if len(files) == 0 {
return contributor{}, fmt.Errorf("graphwire: plugin %s declares graphql but has no graph/*.graphqls", m.ID)
}
types, err := resolverTypes(files)
if err != nil {
return contributor{}, err
}
if len(types) == 0 {
return contributor{}, fmt.Errorf("graphwire: plugin %s declares graphql but contributes no resolver types", m.ID)
}
name := goName(m.ID)
return contributor{
alias: name,
path: m.Backend,
field: name,
param: name + "Plugin",
types: types,
}, nil
}
// Run loads the manifests and SDL under root and writes the resolver root wiring per cfg.
func Run(root string, cfg Config) error {
if err := validateConfig(cfg); err != nil {
return err
}
manifests, err := loadRoots(root, cfg.roots())
if err != nil {
return err
}
core, err := coreContributor(root, cfg)
if err != nil {
return err
}
plugins, err := pluginContributors(root, cfg, manifests)
if err != nil {
return err
}
return writeWiring(root, cfg, core, plugins)
}
// writeWiring formats and writes the generated resolver root file.
func writeWiring(root string, cfg Config, core contributor, plugins []contributor) error {
source, err := format.Source(generate(cfg, core, plugins))
if err != nil {
return fmt.Errorf("graphwire: formatting the wiring: %w", err)
}
path := filepath.Join(root, filepath.FromSlash(cfg.WiringPath))
if err := os.WriteFile(path, source, 0o644); err != nil {
return fmt.Errorf("graphwire: %w", err)
}
return nil
}