From 584421fb2a3172a87ae1374d8e63695fba63a444 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sat, 26 Sep 2026 11:32:14 +0200 Subject: [PATCH 1/4] feat(pluginkit): adopt the plugin host and its wiring generator --- pluginkit/.golangci.yml | 47 +++ pluginkit/CHANGELOG.md | 48 +++ pluginkit/go.mod | 3 + pluginkit/host.go | 118 +++++++ pluginkit/host_test.go | 276 ++++++++++++++++ pluginkit/pluginkit.go | 42 +++ pluginkit/protect.go | 22 ++ pluginkit/protect_test.go | 68 ++++ pluginkit/seed_test.go | 100 ++++++ pluginkit/wire/wire.go | 248 +++++++++++++++ pluginkit/wire/wire_test.go | 618 ++++++++++++++++++++++++++++++++++++ 11 files changed, 1590 insertions(+) create mode 100644 pluginkit/.golangci.yml create mode 100644 pluginkit/CHANGELOG.md create mode 100644 pluginkit/go.mod create mode 100644 pluginkit/host.go create mode 100644 pluginkit/host_test.go create mode 100644 pluginkit/pluginkit.go create mode 100644 pluginkit/protect.go create mode 100644 pluginkit/protect_test.go create mode 100644 pluginkit/seed_test.go create mode 100644 pluginkit/wire/wire.go create mode 100644 pluginkit/wire/wire_test.go diff --git a/pluginkit/.golangci.yml b/pluginkit/.golangci.yml new file mode 100644 index 0000000..61ba6cc --- /dev/null +++ b/pluginkit/.golangci.yml @@ -0,0 +1,47 @@ +version: "2" + +linters: + default: standard + enable: + - cyclop + - depguard + - gocognit + - lll + - misspell + - revive + - unconvert + - unparam + settings: + cyclop: + max-complexity: 10 + gocognit: + min-complexity: 15 + lll: + line-length: 120 + revive: + rules: + - name: blank-imports + disabled: true + - name: exported + depguard: + rules: + dependency-purity: + list-mode: strict + allow: + - $gostd + - github.com/gopherium/framework/pluginkit + exclusions: + rules: + - path: _test\.go + linters: + - cyclop + - gocognit + +formatters: + enable: + - gofmt + - goimports + settings: + goimports: + local-prefixes: + - github.com/gopherium/framework diff --git a/pluginkit/CHANGELOG.md b/pluginkit/CHANGELOG.md new file mode 100644 index 0000000..ba2177e --- /dev/null +++ b/pluginkit/CHANGELOG.md @@ -0,0 +1,48 @@ +# Changelog + +Releases of this module are tagged `pluginkit/vX.Y.Z`. Releases up to +0.5.0 were tagged `vX.Y.Z` in `github.com/gopherium/pluginkit`. + +## Unreleased + +- The module moved to `github.com/gopherium/framework/pluginkit`. +- The module needs Go 1.27.1. + +## 0.5.0 - 2026-08-14 + +- `wire.Config` gains optional `GoRegistryPath` and `GoRegistryPackage` + fields writing an importable registry whose `All` registers every + plugin, for test code that must compose the full set. + +## 0.4.0 - 2026-08-14 + +- `wire.Config` gains an optional `Roots` list naming the plugin root + directories scanned in order, defaulting to `plugins`. An id present + in more than one root is rejected. + +## 0.3.0 - 2026-08-05 + +- New optional `Seeder` capability, `Seed(ctx) error`, for plugins that can + fill their own schema with development data. +- `Host.Seed` asks every `Seeder` in registration order and stops at the + first failure, with the same panic isolation as the other host calls. + Seeding stays outside `Start`, so booting never writes sample data. + +## 0.2.0 - 2026-07-26 + +- `wire.Config` gains an optional `TSLicense` field for applications whose + generated TypeScript wiring carries a different license than the Go one. + It defaults to `License` when empty. + +## 0.1.0 - 2026-07-26 + +Initial release: + +- Lifecycle contract: `Plugin`, `Migrator`, `RouteProvider`, + `PublicPathProvider`. +- `Host`: migrate-before-start, in-order start with reverse-order stop, + rollback on failed start, panic isolation per plugin call. +- `Protect`: exact-match public-path passthrough around caller-supplied + middleware. +- `wire`: plugin manifest loading/validation and Go + TypeScript wiring + generation, parameterized by the consuming application. diff --git a/pluginkit/go.mod b/pluginkit/go.mod new file mode 100644 index 0000000..ded1338 --- /dev/null +++ b/pluginkit/go.mod @@ -0,0 +1,3 @@ +module github.com/gopherium/framework/pluginkit + +go 1.27.1 diff --git a/pluginkit/host.go b/pluginkit/host.go new file mode 100644 index 0000000..79a6b64 --- /dev/null +++ b/pluginkit/host.go @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: Apache-2.0 + +package pluginkit + +import ( + "context" + "errors" + "fmt" + "net/http" +) + +// Host starts and stops a fixed set of plugins. +type Host struct { + plugins []Plugin +} + +// NewHost returns a [Host] managing plugins. It panics when two +// plugins share an ID. +func NewHost(plugins ...Plugin) *Host { + seen := make(map[string]struct{}, len(plugins)) + for _, p := range plugins { + if _, ok := seen[p.ID()]; ok { + panic(fmt.Sprintf("pluginkit: duplicate id %q", p.ID())) + } + seen[p.ID()] = struct{}{} + } + return &Host{plugins: plugins} +} + +// Start migrates every [Migrator] plugin, then starts every plugin in registration +// order, stopping the already-started ones in reverse order when a start fails. +func (h *Host) Start(ctx context.Context) error { + for _, p := range h.plugins { + migrator, ok := p.(Migrator) + if !ok { + continue + } + if err := safeCall(ctx, p.ID(), "migrate", migrator.Migrate); err != nil { + return err + } + } + for i, p := range h.plugins { + if err := safeCall(ctx, p.ID(), "start", p.Start); err != nil { + return errors.Join(err, h.stopDownFrom(ctx, i-1)) + } + } + return nil +} + +// Seed asks every [Seeder] plugin to fill its schema in registration order, +// stopping at the first failure. +func (h *Host) Seed(ctx context.Context) error { + for _, p := range h.plugins { + seeder, ok := p.(Seeder) + if !ok { + continue + } + if err := safeCall(ctx, p.ID(), "seed", seeder.Seed); err != nil { + return err + } + } + return nil +} + +// Routes returns the HTTP handler of every [RouteProvider] plugin, +// keyed by plugin ID. +func (h *Host) Routes() map[string]http.Handler { + routes := make(map[string]http.Handler) + for _, p := range h.plugins { + if provider, ok := p.(RouteProvider); ok { + routes[p.ID()] = provider.Routes() + } + } + return routes +} + +// PublicPaths returns the session-exempt paths of every +// [PublicPathProvider] plugin, keyed by plugin ID. +func (h *Host) PublicPaths() map[string][]string { + paths := make(map[string][]string) + for _, p := range h.plugins { + if provider, ok := p.(PublicPathProvider); ok { + paths[p.ID()] = provider.PublicPaths() + } + } + return paths +} + +// Stop stops every plugin in reverse registration order, continuing +// past failures and returning them joined. +func (h *Host) Stop(ctx context.Context) error { + return h.stopDownFrom(ctx, len(h.plugins)-1) +} + +// stopDownFrom stops plugins from index down to zero in reverse order, collecting and joining any errors. +func (h *Host) stopDownFrom(ctx context.Context, index int) error { + var errs []error + for i := index; i >= 0; i-- { + if err := safeCall(ctx, h.plugins[i].ID(), "stop", h.plugins[i].Stop); err != nil { + errs = append(errs, err) + } + } + return errors.Join(errs...) +} + +// safeCall runs fn, wrapping any returned error and converting any panic into an error tagged with the +// plugin id and operation. +func safeCall(ctx context.Context, id, operation string, fn func(context.Context) error) (err error) { + defer func() { + if recovered := recover(); recovered != nil { + err = fmt.Errorf("pluginkit: %s %s panicked: %v", id, operation, recovered) + } + }() + if err := fn(ctx); err != nil { + return fmt.Errorf("pluginkit: %s %s: %w", id, operation, err) + } + return nil +} diff --git a/pluginkit/host_test.go b/pluginkit/host_test.go new file mode 100644 index 0000000..62674b8 --- /dev/null +++ b/pluginkit/host_test.go @@ -0,0 +1,276 @@ +// SPDX-License-Identifier: Apache-2.0 + +package pluginkit_test + +import ( + "context" + "errors" + "net/http" + "reflect" + "slices" + "testing" + + "github.com/gopherium/framework/pluginkit" +) + +var ( + _ pluginkit.Plugin = (*fakePlugin)(nil) + _ pluginkit.Migrator = (*migratingPlugin)(nil) + _ pluginkit.RouteProvider = (*routedPlugin)(nil) + _ pluginkit.PublicPathProvider = (*publicPathsPlugin)(nil) +) + +type publicPathsPlugin struct { + fakePlugin + paths []string +} + +func (p *publicPathsPlugin) PublicPaths() []string { + return p.paths +} + +func TestHostCollectsPublicPaths(t *testing.T) { + t.Parallel() + + var calls []string + host := pluginkit.NewHost( + &publicPathsPlugin{fakePlugin: fakePlugin{id: "hooked", calls: &calls}, paths: []string{"/webhook"}}, + &fakePlugin{id: "plain", calls: &calls}, + ) + + got := host.PublicPaths() + + want := map[string][]string{"hooked": {"/webhook"}} + if !reflect.DeepEqual(want, got) { + t.Errorf("PublicPaths() = %v, want %v", got, want) + } +} + +type routedPlugin struct { + fakePlugin + handler http.Handler +} + +func (r *routedPlugin) Routes() http.Handler { + return r.handler +} + +type fakePlugin struct { + id string + startErr error + stopErr error + startPanic bool + stopPanic bool + calls *[]string +} + +func (f *fakePlugin) ID() string { + return f.id +} + +func (f *fakePlugin) Start(_ context.Context) error { + *f.calls = append(*f.calls, f.id+" start") + if f.startPanic { + panic("boom") + } + return f.startErr +} + +func (f *fakePlugin) Stop(_ context.Context) error { + *f.calls = append(*f.calls, f.id+" stop") + if f.stopPanic { + panic("boom") + } + return f.stopErr +} + +type migratingPlugin struct { + fakePlugin + migrateErr error + migratePanic bool +} + +func (m *migratingPlugin) Migrate(_ context.Context) error { + *m.calls = append(*m.calls, m.id+" migrate") + if m.migratePanic { + panic("boom") + } + return m.migrateErr +} + +func TestHostStartsInOrderAndStopsInReverse(t *testing.T) { + t.Parallel() + + var calls []string + host := pluginkit.NewHost( + &fakePlugin{id: "alpha", calls: &calls}, + &fakePlugin{id: "beta", calls: &calls}, + ) + + if err := host.Start(t.Context()); err != nil { + t.Fatalf("Start() error = %v, want nil", err) + } + if err := host.Stop(t.Context()); err != nil { + t.Fatalf("Stop() error = %v, want nil", err) + } + + want := []string{"alpha start", "beta start", "beta stop", "alpha stop"} + if !slices.Equal(want, calls) { + t.Errorf("lifecycle calls = %v, want %v", calls, want) + } +} + +func TestNewHostPanicsOnDuplicateIDs(t *testing.T) { + t.Parallel() + + defer func() { + if recover() == nil { + t.Fatal("NewHost() did not panic, want a duplicate id panic") + } + }() + + var calls []string + pluginkit.NewHost( + &fakePlugin{id: "feed", calls: &calls}, + &fakePlugin{id: "feed", calls: &calls}, + ) +} + +func TestHostCollectsRoutesFromProviders(t *testing.T) { + t.Parallel() + + var calls []string + handler := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}) + host := pluginkit.NewHost( + &fakePlugin{id: "alpha", calls: &calls}, + &routedPlugin{fakePlugin: fakePlugin{id: "beta", calls: &calls}, handler: handler}, + ) + + routes := host.Routes() + + if len(routes) != 1 { + t.Fatalf("Routes() returned %d entries, want 1", len(routes)) + } + if _, ok := routes["beta"]; !ok { + t.Error(`Routes() has no entry for "beta", want its handler`) + } +} + +func TestHostMigratesBeforeStarting(t *testing.T) { + t.Parallel() + + var calls []string + host := pluginkit.NewHost( + &fakePlugin{id: "alpha", calls: &calls}, + &migratingPlugin{fakePlugin: fakePlugin{id: "beta", calls: &calls}}, + ) + + if err := host.Start(t.Context()); err != nil { + t.Fatalf("Start() error = %v, want nil", err) + } + + want := []string{"beta migrate", "alpha start", "beta start"} + if !slices.Equal(want, calls) { + t.Errorf("migration ordering = %v, want %v", calls, want) + } +} + +func TestHostAbortsWhenMigrationFails(t *testing.T) { + t.Parallel() + + errSchema := errors.New("schema exploded") + var calls []string + host := pluginkit.NewHost( + &fakePlugin{id: "alpha", calls: &calls}, + &migratingPlugin{fakePlugin: fakePlugin{id: "beta", calls: &calls}, migrateErr: errSchema}, + ) + + startErr := host.Start(t.Context()) + + if !errors.Is(startErr, errSchema) { + t.Fatalf("Start() error = %v, want %v in its chain", startErr, errSchema) + } + want := []string{"beta migrate"} + if !slices.Equal(want, calls) { + t.Errorf("abort calls = %v, want %v", calls, want) + } +} + +func TestHostRecoversMigrationPanic(t *testing.T) { + t.Parallel() + + var calls []string + host := pluginkit.NewHost( + &migratingPlugin{fakePlugin: fakePlugin{id: "beta", calls: &calls}, migratePanic: true}, + ) + + if err := host.Start(t.Context()); err == nil { + t.Fatal("Start() error = nil, want a recovered panic error") + } +} + +func TestHostRollsBackWhenStartFails(t *testing.T) { + t.Parallel() + + errBoot := errors.New("boot failed") + var calls []string + host := pluginkit.NewHost( + &fakePlugin{id: "alpha", calls: &calls}, + &fakePlugin{id: "beta", startErr: errBoot, calls: &calls}, + &fakePlugin{id: "gamma", calls: &calls}, + ) + + startErr := host.Start(t.Context()) + + if !errors.Is(startErr, errBoot) { + t.Fatalf("Start() error = %v, want %v in its chain", startErr, errBoot) + } + want := []string{"alpha start", "beta start", "alpha stop"} + if !slices.Equal(want, calls) { + t.Errorf("rollback calls = %v, want %v", calls, want) + } +} + +func TestHostRecoversStartPanic(t *testing.T) { + t.Parallel() + + var calls []string + host := pluginkit.NewHost( + &fakePlugin{id: "alpha", calls: &calls}, + &fakePlugin{id: "beta", startPanic: true, calls: &calls}, + ) + + startErr := host.Start(t.Context()) + + if startErr == nil { + t.Fatal("Start() error = nil, want a recovered panic error") + } + want := []string{"alpha start", "beta start", "alpha stop"} + if !slices.Equal(want, calls) { + t.Errorf("rollback calls = %v, want %v", calls, want) + } +} + +func TestHostStopCollectsAllFailures(t *testing.T) { + t.Parallel() + + errAlpha := errors.New("alpha refused") + var calls []string + host := pluginkit.NewHost( + &fakePlugin{id: "alpha", stopErr: errAlpha, calls: &calls}, + &fakePlugin{id: "beta", stopPanic: true, calls: &calls}, + ) + if err := host.Start(t.Context()); err != nil { + t.Fatalf("Start() error = %v, want nil", err) + } + + stopErr := host.Stop(t.Context()) + + if !errors.Is(stopErr, errAlpha) { + t.Fatalf("Stop() error = %v, want %v in its chain", stopErr, errAlpha) + } + want := []string{"alpha start", "beta start", "beta stop", "alpha stop"} + if !slices.Equal(want, calls) { + t.Errorf("stop calls = %v, want %v", calls, want) + } +} diff --git a/pluginkit/pluginkit.go b/pluginkit/pluginkit.go new file mode 100644 index 0000000..322b1b3 --- /dev/null +++ b/pluginkit/pluginkit.go @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Package pluginkit provides compile-time plugin infrastructure: a lifecycle +// contract, a plugin host, and route guarding for plugin namespaces. +package pluginkit + +import ( + "context" + "net/http" +) + +// Plugin is an independently addable unit of functionality with a +// managed lifecycle. +type Plugin interface { + ID() string + Start(ctx context.Context) error + Stop(ctx context.Context) error +} + +// Migrator is implemented by plugins that own database schema, which +// the host migrates before starting any plugin. +type Migrator interface { + Migrate(ctx context.Context) error +} + +// Seeder is implemented by plugins that can fill their own schema with +// development data, which the host asks for outside the start path. +type Seeder interface { + Seed(ctx context.Context) error +} + +// RouteProvider is implemented by plugins that expose HTTP endpoints +// under their own namespace. +type RouteProvider interface { + Routes() http.Handler +} + +// PublicPathProvider is implemented by plugins declaring namespace-relative paths that must stay +// reachable without a session. Paths match exactly, for every HTTP method, and all else stays protected. +type PublicPathProvider interface { + PublicPaths() []string +} diff --git a/pluginkit/protect.go b/pluginkit/protect.go new file mode 100644 index 0000000..0696303 --- /dev/null +++ b/pluginkit/protect.go @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: Apache-2.0 + +package pluginkit + +import "net/http" + +// Protect wraps a plugin handler in the caller-supplied middleware and serves the +// plugin's declared public paths untouched (exact match, per [PublicPathProvider]). +func Protect(handler http.Handler, publicPaths []string, wrap func(http.Handler) http.Handler) http.Handler { + public := make(map[string]struct{}, len(publicPaths)) + for _, path := range publicPaths { + public[path] = struct{}{} + } + protected := wrap(handler) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if _, ok := public[r.URL.Path]; ok { + handler.ServeHTTP(w, r) + return + } + protected.ServeHTTP(w, r) + }) +} diff --git a/pluginkit/protect_test.go b/pluginkit/protect_test.go new file mode 100644 index 0000000..95ac2fc --- /dev/null +++ b/pluginkit/protect_test.go @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: Apache-2.0 + +package pluginkit_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gopherium/framework/pluginkit" +) + +func protectFixture() http.Handler { + plugin := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + wrap := func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Wrapped", "yes") + next.ServeHTTP(w, r) + }) + } + return pluginkit.Protect(plugin, []string{"/webhook", "/rss.xml"}, wrap) +} + +func TestProtectLetsPublicPathsThroughUntouched(t *testing.T) { + t.Parallel() + + for _, method := range []string{http.MethodGet, http.MethodPost} { + recorder := httptest.NewRecorder() + + protectFixture().ServeHTTP(recorder, httptest.NewRequest(method, "/webhook", nil)) + + if recorder.Code != http.StatusOK { + t.Errorf("%s /webhook status = %d, want %d", method, recorder.Code, http.StatusOK) + } + if recorder.Header().Get("X-Wrapped") != "" { + t.Errorf("%s /webhook was wrapped, want the public passthrough", method) + } + } +} + +func TestProtectWrapsEverythingElse(t *testing.T) { + t.Parallel() + + tests := map[string]string{ + "plain path": "/messages", + "trailing slash variant": "/webhook/", + "prefix of a public path": "/rss", + "public path as a prefix": "/rss.xml/extra", + "case-sensitive mismatch": "/Webhook", + "query does not affect it": "/other?path=/webhook", + } + + for testName, path := range tests { + t.Run(testName, func(t *testing.T) { + t.Parallel() + + recorder := httptest.NewRecorder() + + protectFixture().ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, path, nil)) + + if recorder.Header().Get("X-Wrapped") != "yes" { + t.Errorf("GET %s was not wrapped, want the protecting middleware applied", path) + } + }) + } +} diff --git a/pluginkit/seed_test.go b/pluginkit/seed_test.go new file mode 100644 index 0000000..7ffabd9 --- /dev/null +++ b/pluginkit/seed_test.go @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: Apache-2.0 + +package pluginkit_test + +import ( + "context" + "errors" + "slices" + "testing" + + "github.com/gopherium/framework/pluginkit" +) + +var _ pluginkit.Seeder = (*seedingPlugin)(nil) + +type seedingPlugin struct { + fakePlugin + seedErr error + seedPanic bool +} + +func (s *seedingPlugin) Seed(_ context.Context) error { + *s.calls = append(*s.calls, s.id+" seed") + if s.seedPanic { + panic("boom") + } + return s.seedErr +} + +func TestHostSeedsEverySeederInOrder(t *testing.T) { + t.Parallel() + + var calls []string + host := pluginkit.NewHost( + &seedingPlugin{fakePlugin: fakePlugin{id: "alpha", calls: &calls}}, + &fakePlugin{id: "beta", calls: &calls}, + &seedingPlugin{fakePlugin: fakePlugin{id: "gamma", calls: &calls}}, + ) + + if err := host.Seed(t.Context()); err != nil { + t.Fatalf("Seed() error = %v, want nil", err) + } + + want := []string{"alpha seed", "gamma seed"} + if !slices.Equal(want, calls) { + t.Errorf("seed ordering = %v, want %v", calls, want) + } +} + +func TestHostStopsSeedingAtTheFirstFailure(t *testing.T) { + t.Parallel() + + errSeed := errors.New("seed exploded") + var calls []string + host := pluginkit.NewHost( + &seedingPlugin{fakePlugin: fakePlugin{id: "alpha", calls: &calls}, seedErr: errSeed}, + &seedingPlugin{fakePlugin: fakePlugin{id: "beta", calls: &calls}}, + ) + + err := host.Seed(t.Context()) + + if !errors.Is(err, errSeed) { + t.Fatalf("Seed() error = %v, want %v in its chain", err, errSeed) + } + if !slices.Equal([]string{"alpha seed"}, calls) { + t.Errorf("calls = %v, want seeding to stop at the failure", calls) + } +} + +func TestHostTagsASeedPanicWithItsPlugin(t *testing.T) { + t.Parallel() + + var calls []string + host := pluginkit.NewHost( + &seedingPlugin{fakePlugin: fakePlugin{id: "alpha", calls: &calls}, seedPanic: true}, + ) + + err := host.Seed(t.Context()) + + if err == nil { + t.Fatal("Seed() error = nil, want the panic reported") + } + if err.Error() != "pluginkit: alpha seed panicked: boom" { + t.Errorf("error = %q, want it to name the plugin and the operation", err) + } +} + +func TestHostSeedsNothingWithoutASeeder(t *testing.T) { + t.Parallel() + + var calls []string + host := pluginkit.NewHost(&fakePlugin{id: "alpha", calls: &calls}) + + if err := host.Seed(t.Context()); err != nil { + t.Fatalf("Seed() error = %v, want nil", err) + } + if len(calls) != 0 { + t.Errorf("calls = %v, want none", calls) + } +} diff --git a/pluginkit/wire/wire.go b/pluginkit/wire/wire.go new file mode 100644 index 0000000..3a1c3b8 --- /dev/null +++ b/pluginkit/wire/wire.go @@ -0,0 +1,248 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Package wire generates an application's plugin wiring files from the +// plugin.json manifest of every directory under each configured plugin root. +package wire + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" +) + +// Config parameterizes generation for the consuming application. +type Config struct { + SDKImport string + FrontendSDK string + GoWiringPath string + TSWiringPath string + License string + TSLicense string + Roots []string + + GoRegistryPath string + GoRegistryPackage 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 +} + +// tsLicense returns the license for the TypeScript wiring file. +func (c Config) tsLicense() string { + if c.TSLicense != "" { + return c.TSLicense + } + return c.License +} + +// validateConfig checks that every Config field is set. +func validateConfig(cfg Config) error { + if cfg.SDKImport == "" || cfg.FrontendSDK == "" || cfg.GoWiringPath == "" || + cfg.TSWiringPath == "" || cfg.License == "" { + return errors.New("wire: every Config field is required") + } + if (cfg.GoRegistryPath == "") != (cfg.GoRegistryPackage == "") { + return errors.New("wire: GoRegistryPath and GoRegistryPackage are required together") + } + return nil +} + +type manifest struct { + ID string `json:"id"` + Name string `json:"name"` + Backend string `json:"backend"` + Frontend string `json:"frontend"` +} + +var idPattern = regexp.MustCompile(`^[a-z][a-z0-9-]*$`) + +// loadManifests loads and validates 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("pluginwire: 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") + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("pluginwire: %s: %w", path, err) + } + var m manifest + if err := json.Unmarshal(data, &m); err != nil { + return nil, fmt.Errorf("pluginwire: %s: %w", path, err) + } + if err := validate(m, entry.Name()); err != nil { + return nil, fmt.Errorf("pluginwire: %s: %w", path, err) + } + manifests = append(manifests, m) + } + return manifests, nil +} + +// 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 _, m := range loaded { + if previous, ok := seen[m.ID]; ok { + return nil, fmt.Errorf("pluginwire: plugin %s appears under %s and %s", m.ID, previous, pluginRoot) + } + seen[m.ID] = pluginRoot + } + manifests = append(manifests, loaded...) + } + return manifests, nil +} + +// validate checks that manifest m has a well-formed id matching dir, a name, and at least one of backend or frontend. +func validate(m manifest, dir string) error { + if !idPattern.MatchString(m.ID) { + return fmt.Errorf("id %q must match %s", m.ID, idPattern) + } + if m.ID != dir { + return fmt.Errorf("id %q does not match directory %q", m.ID, dir) + } + if m.Name == "" { + return errors.New("name is required") + } + if m.Backend == "" && m.Frontend == "" { + return errors.New("at least one of backend or frontend is required") + } + return nil +} + +// goName returns id as a valid Go identifier. +func goName(id string) string { + return strings.ReplaceAll(id, "-", "_") +} + +// generatedHeader renders the SPDX and generated-code header for license. +func generatedHeader(license string) string { + return "// SPDX-License-Identifier: " + license + "\n\n" + + "// Code generated by pluginwire. DO NOT EDIT.\n\n" +} + +// generateGo renders the generated Go plugin-wiring file. +func generateGo(cfg Config, manifests []manifest) []byte { + return renderRegistration(cfg, manifests, "main", "", "registerPlugins") +} + +// generateRegistry renders the generated importable plugin registry file. +func generateRegistry(cfg Config, manifests []manifest) []byte { + doc := "// All registers every plugin and returns them in registration order.\n" + return renderRegistration(cfg, manifests, cfg.GoRegistryPackage, doc, "All") +} + +// renderRegistration renders one Go file registering every backend plugin through the named function. +func renderRegistration(cfg Config, manifests []manifest, pkg, doc, funcName string) []byte { + backends := make([]manifest, 0, len(manifests)) + for _, m := range manifests { + if m.Backend != "" { + backends = append(backends, m) + } + } + + var b strings.Builder + b.WriteString(generatedHeader(cfg.License)) + fmt.Fprintf(&b, "package %s\n\nimport (\n", pkg) + for _, m := range backends { + fmt.Fprintf(&b, "\t%s %q\n", goName(m.ID), m.Backend) + } + if len(backends) > 0 { + b.WriteString("\n") + } + fmt.Fprintf(&b, "\t%q\n)\n\n", cfg.SDKImport) + b.WriteString(doc) + if len(backends) == 0 { + fmt.Fprintf(&b, "func %s(_ sdk.Deps) ([]sdk.Plugin, error) {\n\treturn []sdk.Plugin{}, nil\n}\n", funcName) + return []byte(b.String()) + } + fmt.Fprintf( + &b, + "func %s(deps sdk.Deps) ([]sdk.Plugin, error) {\n\tplugins := make([]sdk.Plugin, 0, %d)\n", + funcName, + len(backends), + ) + for _, m := range backends { + name := goName(m.ID) + fmt.Fprintf( + &b, + "\t%sPlugin, err := %s.Register(deps)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n"+ + "\tplugins = append(plugins, %sPlugin)\n", + name, + name, + name, + ) + } + b.WriteString("\treturn plugins, nil\n}\n") + return []byte(b.String()) +} + +// generateTS renders the generated TypeScript plugin-wiring file. +func generateTS(cfg Config, manifests []manifest) []byte { + frontends := make([]manifest, 0, len(manifests)) + for _, m := range manifests { + if m.Frontend != "" { + frontends = append(frontends, m) + } + } + + var b strings.Builder + b.WriteString(generatedHeader(cfg.tsLicense())) + fmt.Fprintf(&b, "import type { FrontendPlugin } from '%s'\n", cfg.FrontendSDK) + names := make([]string, 0, len(frontends)) + for _, m := range frontends { + name := goName(m.ID) + names = append(names, name) + fmt.Fprintf(&b, "import { plugin as %s } from '%s'\n", name, m.Frontend) + } + fmt.Fprintf(&b, "\nexport const plugins: FrontendPlugin[] = [%s]\n", strings.Join(names, ", ")) + return []byte(b.String()) +} + +// Run loads the plugin manifests under root and writes the application's +// generated Go and TypeScript wiring files 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 + } + goPath := filepath.Join(root, filepath.FromSlash(cfg.GoWiringPath)) + if err := os.WriteFile(goPath, generateGo(cfg, manifests), 0o644); err != nil { + return fmt.Errorf("pluginwire: %w", err) + } + tsPath := filepath.Join(root, filepath.FromSlash(cfg.TSWiringPath)) + if err := os.WriteFile(tsPath, generateTS(cfg, manifests), 0o644); err != nil { + return fmt.Errorf("pluginwire: %w", err) + } + if cfg.GoRegistryPath == "" { + return nil + } + registryPath := filepath.Join(root, filepath.FromSlash(cfg.GoRegistryPath)) + if err := os.WriteFile(registryPath, generateRegistry(cfg, manifests), 0o644); err != nil { + return fmt.Errorf("pluginwire: %w", err) + } + return nil +} diff --git a/pluginkit/wire/wire_test.go b/pluginkit/wire/wire_test.go new file mode 100644 index 0000000..c17817c --- /dev/null +++ b/pluginkit/wire/wire_test.go @@ -0,0 +1,618 @@ +// SPDX-License-Identifier: Apache-2.0 + +package wire + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +var testConfig = Config{ + SDKImport: "example.com/myapp/sdk", + FrontendSDK: "@myapp/frontend-sdk", + GoWiringPath: "cmd/myapp/plugins_gen.go", + TSWiringPath: "frontend/src/plugins/index.ts", + License: "Apache-2.0", +} + +const feedManifest = `{ + "id": "feed", + "name": "Feed", + "backend": "example.com/myapp/plugins/feed", + "frontend": "@myapp/plugin-feed" +}` + +func writePlugin(t *testing.T, root, dir, manifestJSON string) { + t.Helper() + writePluginIn(t, root, "plugins", dir, manifestJSON) +} + +func writePluginIn(t *testing.T, root, pluginRoot, dir, manifestJSON string) { + t.Helper() + pluginDir := filepath.Join(root, pluginRoot, dir) + if err := os.MkdirAll(pluginDir, 0o755); err != nil { + t.Fatalf("creating %s: %v", pluginDir, err) + } + if manifestJSON == "" { + return + } + if err := os.WriteFile(filepath.Join(pluginDir, "plugin.json"), []byte(manifestJSON), 0o644); err != nil { + t.Fatalf("writing manifest: %v", err) + } +} + +func TestLoadManifestsReadsValidPlugins(t *testing.T) { + t.Parallel() + + root := t.TempDir() + writePlugin(t, root, "photo-gallery", + `{"id": "photo-gallery", "name": "Photo Gallery", "backend": "example.com/gallery"}`) + writePlugin(t, root, "feed", feedManifest) + if err := os.WriteFile(filepath.Join(root, "plugins", "README.md"), []byte("not a plugin"), 0o644); err != nil { + t.Fatalf("writing stray file: %v", err) + } + + manifests, err := loadManifests(filepath.Join(root, "plugins")) + + if err != nil { + t.Fatalf("loadManifests() error = %v, want nil", err) + } + if len(manifests) != 2 { + t.Fatalf("manifests = %d, want 2", len(manifests)) + } + if manifests[0].ID != "feed" || manifests[1].ID != "photo-gallery" { + t.Errorf("order = [%q, %q], want directory order [%q, %q]", + manifests[0].ID, manifests[1].ID, "feed", "photo-gallery") + } + if manifests[0].Backend != "example.com/myapp/plugins/feed" { + t.Errorf("backend = %q, want the module import path", manifests[0].Backend) + } + if manifests[0].Frontend != "@myapp/plugin-feed" { + t.Errorf("frontend = %q, want the npm package name", manifests[0].Frontend) + } +} + +func TestLoadManifestsRejectsInvalidPlugins(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + dir string + manifest string + }{ + "missing manifest": {dir: "ghost", manifest: ""}, + "malformed json": {dir: "broken", manifest: `{`}, + "missing id": {dir: "anon", manifest: `{"name": "Anon", "backend": "example.com/anon"}`}, + "uppercase id": { + dir: "Loud", + manifest: `{"id": "Loud", "name": "Loud", "backend": "example.com/loud"}`, + }, + "id and directory disagree": { + dir: "alias", + manifest: `{"id": "other", "name": "Alias", "backend": "example.com/alias"}`, + }, + "missing name": {dir: "nameless", manifest: `{"id": "nameless", "backend": "example.com/nameless"}`}, + "neither backend nor frontend": {dir: "hollow", manifest: `{"id": "hollow", "name": "Hollow"}`}, + } + + for testName, tc := range tests { + t.Run(testName, func(t *testing.T) { + t.Parallel() + + root := t.TempDir() + writePlugin(t, root, tc.dir, tc.manifest) + + if _, err := loadManifests(filepath.Join(root, "plugins")); err == nil { + t.Fatal("loadManifests() error = nil, want a validation error") + } + }) + } +} + +func TestGenerateGoWiresBackendPlugins(t *testing.T) { + t.Parallel() + + got := string(generateGo(testConfig, []manifest{ + {ID: "photo-gallery", Name: "Photo Gallery", Backend: "example.com/gallery"}, + { + ID: "feed", + Name: "Feed", + Backend: "example.com/myapp/plugins/feed", + Frontend: "@myapp/plugin-feed", + }, + })) + + want := `// SPDX-License-Identifier: Apache-2.0 + +// Code generated by pluginwire. DO NOT EDIT. + +package main + +import ( + photo_gallery "example.com/gallery" + feed "example.com/myapp/plugins/feed" + + "example.com/myapp/sdk" +) + +func registerPlugins(deps sdk.Deps) ([]sdk.Plugin, error) { + plugins := make([]sdk.Plugin, 0, 2) + photo_galleryPlugin, err := photo_gallery.Register(deps) + if err != nil { + return nil, err + } + plugins = append(plugins, photo_galleryPlugin) + feedPlugin, err := feed.Register(deps) + if err != nil { + return nil, err + } + plugins = append(plugins, feedPlugin) + return plugins, nil +} +` + if got != want { + t.Errorf("generateGo() = %q, want %q", got, want) + } +} + +func TestGenerateGoWithoutBackendPlugins(t *testing.T) { + t.Parallel() + + got := string(generateGo(testConfig, []manifest{ + {ID: "pretty", Name: "Pretty", Frontend: "@myapp/plugin-pretty"}, + })) + + want := `// SPDX-License-Identifier: Apache-2.0 + +// Code generated by pluginwire. DO NOT EDIT. + +package main + +import ( + "example.com/myapp/sdk" +) + +func registerPlugins(_ sdk.Deps) ([]sdk.Plugin, error) { + return []sdk.Plugin{}, nil +} +` + if got != want { + t.Errorf("generateGo() = %q, want %q", got, want) + } +} + +func TestGenerateTSWiresFrontendPlugins(t *testing.T) { + t.Parallel() + + got := string(generateTS(testConfig, []manifest{ + { + ID: "photo-gallery", + Name: "Photo Gallery", + Backend: "example.com/gallery", + Frontend: "@myapp/plugin-photo-gallery", + }, + {ID: "feed", Name: "Feed", Frontend: "@myapp/plugin-feed"}, + })) + + want := `// SPDX-License-Identifier: Apache-2.0 + +// Code generated by pluginwire. DO NOT EDIT. + +import type { FrontendPlugin } from '@myapp/frontend-sdk' +import { plugin as photo_gallery } from '@myapp/plugin-photo-gallery' +import { plugin as feed } from '@myapp/plugin-feed' + +export const plugins: FrontendPlugin[] = [photo_gallery, feed] +` + if got != want { + t.Errorf("generateTS() = %q, want %q", got, want) + } +} + +func TestGenerateTSUsesTSLicenseWhenSet(t *testing.T) { + t.Parallel() + + cfg := testConfig + cfg.TSLicense = "AGPL-3.0-or-later" + + goSrc := string(generateGo(cfg, []manifest{{ID: "feed", Name: "Feed", Backend: "example.com/myapp/plugins/feed"}})) + tsSrc := string(generateTS(cfg, []manifest{{ID: "feed", Name: "Feed", Frontend: "@myapp/plugin-feed"}})) + + if want := "// SPDX-License-Identifier: Apache-2.0\n"; goSrc[:len(want)] != want { + t.Errorf("generateGo() header = %q, want the License field %q", goSrc[:len(want)], want) + } + if want := "// SPDX-License-Identifier: AGPL-3.0-or-later\n"; tsSrc[:len(want)] != want { + t.Errorf("generateTS() header = %q, want the TSLicense field %q", tsSrc[:len(want)], want) + } +} + +func TestGenerateTSWithoutFrontendPlugins(t *testing.T) { + t.Parallel() + + got := string(generateTS(testConfig, []manifest{ + {ID: "headless", Name: "Headless", Backend: "example.com/headless"}, + })) + + want := `// SPDX-License-Identifier: Apache-2.0 + +// Code generated by pluginwire. DO NOT EDIT. + +import type { FrontendPlugin } from '@myapp/frontend-sdk' + +export const plugins: FrontendPlugin[] = [] +` + if got != want { + t.Errorf("generateTS() = %q, want %q", got, want) + } +} + +func TestGenerateRegistryRendersEveryBackend(t *testing.T) { + t.Parallel() + + cfg := testConfig + cfg.GoRegistryPath = "internal/graphroot/registry_gen.go" + cfg.GoRegistryPackage = "graphroot" + + got := string(generateRegistry(cfg, []manifest{ + {ID: "photo-gallery", Name: "Photo Gallery", Backend: "example.com/gallery"}, + {ID: "feed", Name: "Feed", Backend: "example.com/myapp/plugins/feed", Frontend: "@myapp/plugin-feed"}, + })) + + want := `// SPDX-License-Identifier: Apache-2.0 + +// Code generated by pluginwire. DO NOT EDIT. + +package graphroot + +import ( + photo_gallery "example.com/gallery" + feed "example.com/myapp/plugins/feed" + + "example.com/myapp/sdk" +) + +// All registers every plugin and returns them in registration order. +func All(deps sdk.Deps) ([]sdk.Plugin, error) { + plugins := make([]sdk.Plugin, 0, 2) + photo_galleryPlugin, err := photo_gallery.Register(deps) + if err != nil { + return nil, err + } + plugins = append(plugins, photo_galleryPlugin) + feedPlugin, err := feed.Register(deps) + if err != nil { + return nil, err + } + plugins = append(plugins, feedPlugin) + return plugins, nil +} +` + if got != want { + t.Errorf("generateRegistry() = %q, want %q", got, want) + } +} + +func TestGenerateRegistryWithoutBackendPlugins(t *testing.T) { + t.Parallel() + + cfg := testConfig + cfg.GoRegistryPath = "internal/graphroot/registry_gen.go" + cfg.GoRegistryPackage = "graphroot" + + got := string(generateRegistry(cfg, []manifest{ + {ID: "pretty", Name: "Pretty", Frontend: "@myapp/plugin-pretty"}, + })) + + want := `// SPDX-License-Identifier: Apache-2.0 + +// Code generated by pluginwire. DO NOT EDIT. + +package graphroot + +import ( + "example.com/myapp/sdk" +) + +// All registers every plugin and returns them in registration order. +func All(_ sdk.Deps) ([]sdk.Plugin, error) { + return []sdk.Plugin{}, nil +} +` + if got != want { + t.Errorf("generateRegistry() = %q, want %q", got, want) + } +} + +func TestRunWritesTheRegistryWhenConfigured(t *testing.T) { + t.Parallel() + + root := t.TempDir() + writePlugin(t, root, "feed", feedManifest) + for _, dir := range []string{"cmd/myapp", "frontend/src/plugins", "internal/graphroot"} { + if err := os.MkdirAll(filepath.Join(root, dir), 0o755); err != nil { + t.Fatalf("creating %s: %v", dir, err) + } + } + cfg := testConfig + cfg.GoRegistryPath = "internal/graphroot/registry_gen.go" + cfg.GoRegistryPackage = "graphroot" + + if err := Run(root, cfg); err != nil { + t.Fatalf("Run() error = %v, want nil", err) + } + + registry, err := os.ReadFile(filepath.Join(root, "internal", "graphroot", "registry_gen.go")) + if err != nil { + t.Fatalf("reading the generated registry: %v", err) + } + manifests, err := loadManifests(filepath.Join(root, "plugins")) + if err != nil { + t.Fatalf("loadManifests() error = %v, want nil", err) + } + if string(registry) != string(generateRegistry(cfg, manifests)) { + t.Errorf("registry_gen.go = %q, want the generated registry", registry) + } +} + +func TestRunSkipsTheRegistryWhenUnset(t *testing.T) { + t.Parallel() + + root := t.TempDir() + writePlugin(t, root, "feed", feedManifest) + for _, dir := range []string{"cmd/myapp", "frontend/src/plugins", "internal/graphroot"} { + if err := os.MkdirAll(filepath.Join(root, dir), 0o755); err != nil { + t.Fatalf("creating %s: %v", dir, err) + } + } + + if err := Run(root, testConfig); err != nil { + t.Fatalf("Run() error = %v, want nil", err) + } + + if _, err := os.Stat(filepath.Join(root, "internal", "graphroot", "registry_gen.go")); !os.IsNotExist(err) { + t.Errorf("registry_gen.go exists without registry config, stat error = %v", err) + } +} + +func TestRunRejectsAHalfConfiguredRegistry(t *testing.T) { + t.Parallel() + + tests := map[string]func(cfg Config) Config{ + "path without package": func(cfg Config) Config { + cfg.GoRegistryPath = "internal/graphroot/registry_gen.go" + return cfg + }, + "package without path": func(cfg Config) Config { + cfg.GoRegistryPackage = "graphroot" + return cfg + }, + } + + for testName, mutate := range tests { + t.Run(testName, func(t *testing.T) { + t.Parallel() + + root := t.TempDir() + writePlugin(t, root, "feed", feedManifest) + for _, dir := range []string{"cmd/myapp", "frontend/src/plugins", "internal/graphroot"} { + if err := os.MkdirAll(filepath.Join(root, dir), 0o755); err != nil { + t.Fatalf("creating %s: %v", dir, err) + } + } + + if err := Run(root, mutate(testConfig)); err == nil { + t.Fatal("Run() error = nil, want a config validation error") + } + }) + } +} + +func TestRunWritesWiringFiles(t *testing.T) { + t.Parallel() + + root := t.TempDir() + writePlugin(t, root, "feed", feedManifest) + for _, dir := range []string{"cmd/myapp", "frontend/src/plugins"} { + if err := os.MkdirAll(filepath.Join(root, dir), 0o755); err != nil { + t.Fatalf("creating %s: %v", dir, err) + } + } + + if err := Run(root, testConfig); err != nil { + t.Fatalf("Run() error = %v, want nil", err) + } + + goSrc, err := os.ReadFile(filepath.Join(root, "cmd", "myapp", "plugins_gen.go")) + if err != nil { + t.Fatalf("reading generated Go wiring: %v", err) + } + tsSrc, err := os.ReadFile(filepath.Join(root, "frontend", "src", "plugins", "index.ts")) + if err != nil { + t.Fatalf("reading generated TS wiring: %v", err) + } + manifests, err := loadManifests(filepath.Join(root, "plugins")) + if err != nil { + t.Fatalf("loadManifests() error = %v, want nil", err) + } + if string(goSrc) != string(generateGo(testConfig, manifests)) { + t.Errorf("plugins_gen.go = %q, want the generated wiring", goSrc) + } + if string(tsSrc) != string(generateTS(testConfig, manifests)) { + t.Errorf("index.ts = %q, want the generated registry", tsSrc) + } +} + +func TestRunScansEveryRootInOrder(t *testing.T) { + t.Parallel() + + root := t.TempDir() + writePlugin(t, root, "feed", feedManifest) + writePluginIn(t, root, "enterprise", "tenancy", + `{"id": "tenancy", "name": "Tenancy", "backend": "example.com/enterprise/tenancy"}`) + for _, dir := range []string{"cmd/myapp", "frontend/src/plugins"} { + if err := os.MkdirAll(filepath.Join(root, dir), 0o755); err != nil { + t.Fatalf("creating %s: %v", dir, err) + } + } + cfg := testConfig + cfg.Roots = []string{"plugins", "enterprise"} + + if err := Run(root, cfg); err != nil { + t.Fatalf("Run() error = %v, want nil", err) + } + + goSrc, err := os.ReadFile(filepath.Join(root, "cmd", "myapp", "plugins_gen.go")) + if err != nil { + t.Fatalf("reading generated Go wiring: %v", err) + } + feedAt := strings.Index(string(goSrc), "example.com/myapp/plugins/feed") + tenancyAt := strings.Index(string(goSrc), "example.com/enterprise/tenancy") + if feedAt < 0 || tenancyAt < 0 { + t.Fatalf("plugins_gen.go = %q, want imports from both roots", goSrc) + } + if feedAt > tenancyAt { + t.Errorf("plugins_gen.go orders the enterprise root before plugins, want root order") + } +} + +func TestRunEmptySecondRootReproducesDefaultBytes(t *testing.T) { + t.Parallel() + + defaultRoot := t.TempDir() + overlayRoot := t.TempDir() + for _, root := range []string{defaultRoot, overlayRoot} { + writePlugin(t, root, "feed", feedManifest) + for _, dir := range []string{"cmd/myapp", "frontend/src/plugins"} { + if err := os.MkdirAll(filepath.Join(root, dir), 0o755); err != nil { + t.Fatalf("creating %s: %v", dir, err) + } + } + } + if err := os.MkdirAll(filepath.Join(overlayRoot, "enterprise"), 0o755); err != nil { + t.Fatalf("creating the empty enterprise root: %v", err) + } + readme := filepath.Join(overlayRoot, "enterprise", "README.md") + if err := os.WriteFile(readme, []byte("enterprise plugins land here"), 0o644); err != nil { + t.Fatalf("writing the enterprise README: %v", err) + } + overlayConfig := testConfig + overlayConfig.Roots = []string{"plugins", "enterprise"} + + if err := Run(defaultRoot, testConfig); err != nil { + t.Fatalf("Run() with the default root: %v", err) + } + if err := Run(overlayRoot, overlayConfig); err != nil { + t.Fatalf("Run() with the empty enterprise root: %v", err) + } + + for _, generated := range []string{"cmd/myapp/plugins_gen.go", "frontend/src/plugins/index.ts"} { + defaultBytes, err := os.ReadFile(filepath.Join(defaultRoot, filepath.FromSlash(generated))) + if err != nil { + t.Fatalf("reading %s from the default run: %v", generated, err) + } + overlayBytes, err := os.ReadFile(filepath.Join(overlayRoot, filepath.FromSlash(generated))) + if err != nil { + t.Fatalf("reading %s from the overlay run: %v", generated, err) + } + if string(defaultBytes) != string(overlayBytes) { + t.Errorf("%s differs between the default and the empty enterprise root", generated) + } + } +} + +func TestRunRejectsDuplicateIDAcrossRoots(t *testing.T) { + t.Parallel() + + root := t.TempDir() + writePlugin(t, root, "feed", feedManifest) + writePluginIn(t, root, "enterprise", "feed", feedManifest) + cfg := testConfig + cfg.Roots = []string{"plugins", "enterprise"} + + err := Run(root, cfg) + + if err == nil { + t.Fatal("Run() error = nil, want a duplicate id error") + } + if !strings.Contains(err.Error(), "feed") { + t.Errorf("error = %q, want it to name the duplicated id", err) + } +} + +func TestRunRejectsIncompleteConfig(t *testing.T) { + t.Parallel() + + tests := map[string]func(cfg Config) Config{ + "missing sdk import": func(cfg Config) Config { cfg.SDKImport = ""; return cfg }, + "missing frontend sdk": func(cfg Config) Config { cfg.FrontendSDK = ""; return cfg }, + "missing go wiring path": func(cfg Config) Config { cfg.GoWiringPath = ""; return cfg }, + "missing ts wiring path": func(cfg Config) Config { cfg.TSWiringPath = ""; return cfg }, + "missing license": func(cfg Config) Config { cfg.License = ""; return cfg }, + } + + for testName, mutate := range tests { + t.Run(testName, func(t *testing.T) { + t.Parallel() + + root := t.TempDir() + writePlugin(t, root, "feed", feedManifest) + + if err := Run(root, mutate(testConfig)); err == nil { + t.Fatal("Run() error = nil, want a config validation error") + } + }) + } +} + +func TestRunReportsFailures(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + roots []string + prepare func(t *testing.T, root string) + }{ + "missing plugins directory": { + prepare: func(_ *testing.T, _ string) {}, + }, + "missing named root": { + roots: []string{"plugins", "enterprise"}, + prepare: func(t *testing.T, root string) { + writePlugin(t, root, "feed", feedManifest) + for _, dir := range []string{"cmd/myapp", "frontend/src/plugins"} { + if err := os.MkdirAll(filepath.Join(root, dir), 0o755); err != nil { + t.Fatalf("creating %s: %v", dir, err) + } + } + }, + }, + "unwritable go wiring": { + prepare: func(t *testing.T, root string) { + writePlugin(t, root, "feed", feedManifest) + }, + }, + "unwritable ts wiring": { + prepare: func(t *testing.T, root string) { + writePlugin(t, root, "feed", feedManifest) + if err := os.MkdirAll(filepath.Join(root, "cmd", "myapp"), 0o755); err != nil { + t.Fatalf("creating cmd/myapp: %v", err) + } + }, + }, + } + + for testName, tc := range tests { + t.Run(testName, func(t *testing.T) { + t.Parallel() + + root := t.TempDir() + tc.prepare(t, root) + cfg := testConfig + cfg.Roots = tc.roots + + if err := Run(root, cfg); err == nil { + t.Fatal("Run() error = nil, want an error") + } + }) + } +} From c96ab4eca196a8ea5f42e6203015119e55b91134 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sat, 26 Sep 2026 11:32:14 +0200 Subject: [PATCH 2/4] feat(pluginkit/graphwire): adopt the graph wiring generator --- pluginkit/graphwire/.golangci.yml | 48 +++ pluginkit/graphwire/CHANGELOG.md | 62 +++ pluginkit/graphwire/generate.go | 250 ++++++++++++ pluginkit/graphwire/go.mod | 5 + pluginkit/graphwire/go.sum | 12 + pluginkit/graphwire/graphwire.go | 347 ++++++++++++++++ pluginkit/graphwire/graphwire_test.go | 567 ++++++++++++++++++++++++++ 7 files changed, 1291 insertions(+) create mode 100644 pluginkit/graphwire/.golangci.yml create mode 100644 pluginkit/graphwire/CHANGELOG.md create mode 100644 pluginkit/graphwire/generate.go create mode 100644 pluginkit/graphwire/go.mod create mode 100644 pluginkit/graphwire/go.sum create mode 100644 pluginkit/graphwire/graphwire.go create mode 100644 pluginkit/graphwire/graphwire_test.go diff --git a/pluginkit/graphwire/.golangci.yml b/pluginkit/graphwire/.golangci.yml new file mode 100644 index 0000000..ce24a23 --- /dev/null +++ b/pluginkit/graphwire/.golangci.yml @@ -0,0 +1,48 @@ +version: "2" + +linters: + default: standard + enable: + - cyclop + - depguard + - gocognit + - lll + - misspell + - revive + - unconvert + - unparam + settings: + cyclop: + max-complexity: 10 + gocognit: + min-complexity: 15 + lll: + line-length: 120 + revive: + rules: + - name: blank-imports + disabled: true + - name: exported + depguard: + rules: + dependency-purity: + list-mode: strict + allow: + - $gostd + - github.com/gopherium/framework/pluginkit + - github.com/vektah/gqlparser/v2 + exclusions: + rules: + - path: _test\.go + linters: + - cyclop + - gocognit + +formatters: + enable: + - gofmt + - goimports + settings: + goimports: + local-prefixes: + - github.com/gopherium/framework diff --git a/pluginkit/graphwire/CHANGELOG.md b/pluginkit/graphwire/CHANGELOG.md new file mode 100644 index 0000000..c00c257 --- /dev/null +++ b/pluginkit/graphwire/CHANGELOG.md @@ -0,0 +1,62 @@ +# Changelog + +All notable changes to the `graphwire` module are documented in this +file. The format follows +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the module +follows [Semantic Versioning](https://semver.org/). While at v0.x, minor +releases may contain breaking changes. + +Releases of this module are tagged `pluginkit/graphwire/vX.Y.Z`. +Releases up to 0.3.0 were tagged `graphwire/vX.Y.Z` in +`github.com/gopherium/pluginkit`. The module lives beside the +stdlib-only `pluginkit` module so its gqlparser dependency never enters +`pluginkit` itself. + +## [Unreleased] + +### Changed + +- The module moved to `github.com/gopherium/framework/pluginkit/graphwire`. +- The module needs Go 1.27.1. + +## [0.3.0] - 2026-08-14 + +### Added + +- `Config` gains an optional `Roots` list naming the plugin root + directories scanned in order, defaulting to `plugins`. Each plugin's + SDL is read from the root its manifest came from, and an id present + in more than one root is rejected. + +## [0.2.1] - 2026-08-07 + +### Fixed + +- Composite resolver structs embedded two sets sharing an unqualified + type name, such as a core and a plugin `QueryResolvers`, which Go + rejects as a duplicate field. The generator now names each contributed + set through a package local type alias before embedding it, so shared + types compile with any number of contributors. + +## [0.2.0] - 2026-08-07 + +### Added + +- `Config.Package` and `Config.SDKImport`, generating into a named + importable package with exported identifiers instead of package main, + plus the `FromPlugins` assembler that locates each graphql plugin's + resolver sets among the registered plugins by type assertion and + composes the root, failing loudly when a flagged plugin is absent. + +## [0.1.0] - 2026-08-07 + +### Added + +- `Run` and `Config`, generating an application's graph resolver root + from the plugin manifests and SDL under plugins/. Plugins opt in with + `"graphql": true` in plugin.json and export one `Resolvers` set + per GraphQL type they define or extend. The generator parses SDL with + gqlparser, derives the resolver bearing types the same way gqlgen does + (root types, argumented fields, and goField forceResolver fields), and + emits one composite struct per shared type plus the ResolverRoot + accessors. diff --git a/pluginkit/graphwire/generate.go b/pluginkit/graphwire/generate.go new file mode 100644 index 0000000..6ad1fdc --- /dev/null +++ b/pluginkit/graphwire/generate.go @@ -0,0 +1,250 @@ +// SPDX-License-Identifier: Apache-2.0 + +package graphwire + +import ( + "fmt" + "sort" + "strings" +) + +// naming carries the package name and identifier casing of the generated file. +type naming struct { + pkg string + packageMode bool +} + +// namingFor resolves the generated naming from the config. +func namingFor(cfg Config) naming { + if cfg.Package == "" { + return naming{pkg: "main"} + } + return naming{pkg: cfg.Package, packageMode: true} +} + +// rootFunc returns the root constructor name. +func (n naming) rootFunc() string { + if n.packageMode { + return "NewGraphRoot" + } + return "newGraphRoot" +} + +// ifaceName returns the contributor interface name for field. +func (n naming) ifaceName(field string) string { + name := field + "GraphResolvers" + if n.packageMode { + return strings.ToUpper(field[:1]) + field[1:] + "GraphResolvers" + } + return name +} + +// generatedHeader renders the SPDX and generated-code header for license. +func generatedHeader(license string) string { + return "// SPDX-License-Identifier: " + license + "\n\n" + + "// Code generated by pluginwire. DO NOT EDIT.\n\n" +} + +// generate renders the unformatted resolver root wiring file. +func generate(cfg Config, core contributor, plugins []contributor) []byte { + n := namingFor(cfg) + var b strings.Builder + b.WriteString(generatedHeader(cfg.License)) + fmt.Fprintf(&b, "package %s\n\n", n.pkg) + writeImports(&b, cfg, plugins, n) + if len(plugins) == 0 { + writePassthrough(&b, n) + return []byte(b.String()) + } + writeContributorInterface(&b, core, "the core contributes to the graph", n) + for _, plugin := range plugins { + writeContributorInterface(&b, plugin, "the "+plugin.field+" plugin contributes to the graph", n) + } + types, contributorsOf := typeContributors(core, plugins) + writeComposites(&b, types, contributorsOf) + writeRoot(&b, core, plugins, n) + if n.packageMode { + writeFromPlugins(&b, core, plugins, n) + } + writeAccessors(&b, types, contributorsOf) + return []byte(b.String()) +} + +// imported is one aliased import of the generated file. +type imported struct{ alias, path string } + +// wiringImports returns the generated file's imports sorted by path. +func wiringImports(cfg Config, plugins []contributor, n naming) []imported { + imports := []imported{{"graph", cfg.ExecImport}} + if len(plugins) > 0 { + imports = append(imports, imported{goName(pathBase(cfg.CoreImport)), cfg.CoreImport}) + } + if n.packageMode { + imports = append(imports, imported{"sdk", cfg.SDKImport}) + if len(plugins) > 0 { + imports = append(imports, imported{"", "errors"}) + } + } + for _, plugin := range plugins { + imports = append(imports, imported{plugin.alias, plugin.path}) + } + sort.Slice(imports, func(i, j int) bool { return imports[i].path < imports[j].path }) + return imports +} + +// writeImports renders the aliased import block. +func writeImports(b *strings.Builder, cfg Config, plugins []contributor, n naming) { + b.WriteString("import (\n") + for _, entry := range wiringImports(cfg, plugins, n) { + if entry.alias == "" { + fmt.Fprintf(b, "\t%q\n", entry.path) + continue + } + fmt.Fprintf(b, "\t%s %q\n", entry.alias, entry.path) + } + b.WriteString(")\n\n") +} + +// pathBase returns the last segment of an import path. +func pathBase(path string) string { + if i := strings.LastIndex(path, "/"); i >= 0 { + return path[i+1:] + } + return path +} + +// writePassthrough renders the zero plugin root. +func writePassthrough(b *strings.Builder, n naming) { + fmt.Fprintf(b, "// %s returns the core resolver root, no plugin extends the graph.\n", n.rootFunc()) + fmt.Fprintf(b, "func %s(core graph.ResolverRoot) graph.ResolverRoot {\n\treturn core\n}\n", n.rootFunc()) + if !n.packageMode { + return + } + b.WriteString("\n// FromPlugins composes the resolver root, no registered plugin extends the graph.\n") + b.WriteString("func FromPlugins(core graph.ResolverRoot, _ []sdk.Plugin) (graph.ResolverRoot, error) {\n") + b.WriteString("\treturn core, nil\n}\n") +} + +// writeContributorInterface renders one contributor's resolver set interface. +func writeContributorInterface(b *strings.Builder, c contributor, owner string, n naming) { + fmt.Fprintf(b, "// %s lists the resolver sets %s.\n", n.ifaceName(c.field), owner) + fmt.Fprintf(b, "type %s interface {\n", n.ifaceName(c.field)) + for _, typeName := range c.types { + fmt.Fprintf(b, "\t%sResolvers() %s.%sResolvers\n", typeName, c.alias, typeName) + } + b.WriteString("}\n\n") +} + +// typeContributors returns every contributed type and its contributors in order. +func typeContributors(core contributor, plugins []contributor) ([]string, map[string][]contributor) { + contributorsOf := map[string][]contributor{} + for _, typeName := range core.types { + contributorsOf[typeName] = append(contributorsOf[typeName], core) + } + for _, plugin := range plugins { + for _, typeName := range plugin.types { + contributorsOf[typeName] = append(contributorsOf[typeName], plugin) + } + } + types := make([]string, 0, len(contributorsOf)) + for typeName := range contributorsOf { + types = append(types, typeName) + } + sort.Strings(types) + return types, contributorsOf +} + +// writeComposites renders one merged resolver struct per shared type, naming +// each contributed set through an alias so the embedded fields stay distinct. +func writeComposites(b *strings.Builder, types []string, contributorsOf map[string][]contributor) { + for _, typeName := range types { + contributors := contributorsOf[typeName] + if len(contributors) < 2 { + continue + } + for _, c := range contributors { + fmt.Fprintf(b, "// %s%sResolvers names the %s %s resolver set for embedding.\n", + c.field, typeName, c.field, typeName) + fmt.Fprintf(b, "type %s%sResolvers = %s.%sResolvers\n\n", c.field, typeName, c.alias, typeName) + } + fmt.Fprintf(b, "// composed%sResolver merges every contributed %s resolver set.\n", typeName, typeName) + fmt.Fprintf(b, "type composed%sResolver struct {\n", typeName) + for _, c := range contributors { + fmt.Fprintf(b, "\t%s%sResolvers\n", c.field, typeName) + } + b.WriteString("}\n\n") + } +} + +// writeRoot renders the root type and its constructor. +func writeRoot(b *strings.Builder, core contributor, plugins []contributor, n naming) { + b.WriteString("// graphRoot composes the core and plugin resolver sets into the resolver root.\n") + b.WriteString("type graphRoot struct {\n") + fmt.Fprintf(b, "\t%s %s\n", core.field, n.ifaceName(core.field)) + for _, plugin := range plugins { + fmt.Fprintf(b, "\t%s %s\n", plugin.field, n.ifaceName(plugin.field)) + } + b.WriteString("}\n\n") + fmt.Fprintf(b, "// %s composes the core resolver sets with every graphql plugin's.\n", n.rootFunc()) + fmt.Fprintf(b, "func %s(\n", n.rootFunc()) + fmt.Fprintf(b, "\t%s %s,\n", core.param, n.ifaceName(core.field)) + for _, plugin := range plugins { + fmt.Fprintf(b, "\t%s %s,\n", plugin.param, n.ifaceName(plugin.field)) + } + b.WriteString(") graph.ResolverRoot {\n\treturn graphRoot{") + assignments := []string{core.field + ": " + core.param} + for _, plugin := range plugins { + assignments = append(assignments, plugin.field+": "+plugin.param) + } + b.WriteString(strings.Join(assignments, ", ")) + b.WriteString("}\n}\n\n") +} + +// writeFromPlugins renders the assembler locating each plugin's resolver sets. +func writeFromPlugins(b *strings.Builder, core contributor, plugins []contributor, n naming) { + b.WriteString("// FromPlugins finds each graphql plugin among the registered plugins" + + " and composes the resolver root.\n") + fmt.Fprintf(b, "func FromPlugins(core %s, plugins []sdk.Plugin) (graph.ResolverRoot, error) {\n", + n.ifaceName(core.field)) + for _, plugin := range plugins { + fmt.Fprintf(b, "\tvar %s %s\n", plugin.param, n.ifaceName(plugin.field)) + } + b.WriteString("\tfor _, plugin := range plugins {\n") + for _, plugin := range plugins { + fmt.Fprintf(b, "\t\tif candidate, ok := plugin.(%s); ok {\n\t\t\t%s = candidate\n\t\t}\n", + n.ifaceName(plugin.field), plugin.param) + } + b.WriteString("\t}\n") + for _, plugin := range plugins { + fmt.Fprintf(b, "\tif %s == nil {\n\t\treturn nil, errors.New(%q)\n\t}\n", + plugin.param, + n.pkg+": no registered plugin provides the "+plugin.field+" resolver sets") + } + params := []string{"core"} + for _, plugin := range plugins { + params = append(params, plugin.param) + } + fmt.Fprintf(b, "\treturn %s(%s), nil\n}\n\n", n.rootFunc(), strings.Join(params, ", ")) +} + +// writeAccessors renders one ResolverRoot accessor per contributed type. +func writeAccessors(b *strings.Builder, types []string, contributorsOf map[string][]contributor) { + for i, typeName := range types { + fmt.Fprintf(b, "// %s returns the %s resolver set.\n", typeName, typeName) + fmt.Fprintf(b, "func (g graphRoot) %s() graph.%sResolver {\n", typeName, typeName) + contributors := contributorsOf[typeName] + if len(contributors) == 1 { + fmt.Fprintf(b, "\treturn g.%s.%sResolvers()\n", contributors[0].field, typeName) + } else { + fmt.Fprintf(b, "\treturn composed%sResolver{\n", typeName) + for _, c := range contributors { + fmt.Fprintf(b, "\t\tg.%s.%sResolvers(),\n", c.field, typeName) + } + b.WriteString("\t}\n") + } + b.WriteString("}\n") + if i < len(types)-1 { + b.WriteString("\n") + } + } +} diff --git a/pluginkit/graphwire/go.mod b/pluginkit/graphwire/go.mod new file mode 100644 index 0000000..2da833c --- /dev/null +++ b/pluginkit/graphwire/go.mod @@ -0,0 +1,5 @@ +module github.com/gopherium/framework/pluginkit/graphwire + +go 1.27.1 + +require github.com/vektah/gqlparser/v2 v2.5.36 diff --git a/pluginkit/graphwire/go.sum b/pluginkit/graphwire/go.sum new file mode 100644 index 0000000..a6f0b67 --- /dev/null +++ b/pluginkit/graphwire/go.sum @@ -0,0 +1,12 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/vektah/gqlparser/v2 v2.5.36 h1:CN9mKVHgMkc+XftdOWIhb4HEL8wKSYkFAqhf8booa7s= +github.com/vektah/gqlparser/v2 v2.5.36/go.mod h1:cAJ9qwVgPaUkWv6Gn8vn0mqOE0Ui5Pn56wNy5396XWo= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/pluginkit/graphwire/graphwire.go b/pluginkit/graphwire/graphwire.go new file mode 100644 index 0000000..037f953 --- /dev/null +++ b/pluginkit/graphwire/graphwire.go @@ -0,0 +1,347 @@ +// 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" + "os" + "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: goName(filepath.Base(cfg.CoreImport)), + path: cfg.CoreImport, + field: "core", + param: "core", + types: types, + }, nil +} + +// pluginContributors scans every graphql flagged plugin into contributor entries. +func pluginContributors(root string, manifests []manifest) ([]contributor, error) { + var contributors []contributor + for _, m := range manifests { + if !m.GraphQL { + continue + } + 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, 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 +} diff --git a/pluginkit/graphwire/graphwire_test.go b/pluginkit/graphwire/graphwire_test.go new file mode 100644 index 0000000..8f84179 --- /dev/null +++ b/pluginkit/graphwire/graphwire_test.go @@ -0,0 +1,567 @@ +// SPDX-License-Identifier: Apache-2.0 + +package graphwire + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +var testConfig = Config{ + ExecImport: "example.com/myapp/graph", + CoreImport: "example.com/myapp/internal/graphres", + CoreSchemaGlobs: []string{"graph/schema/*.graphqls"}, + WiringPath: "cmd/myapp/graph_gen.go", + License: "Apache-2.0", +} + +const coreSchema = ` +directive @goField(forceResolver: Boolean, name: String) on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +type Query { + things: [Thing!]! +} + +type Mutation { + makeThing(name: String!): Thing! +} + +type Thing { + id: ID! + name: String! + owner: Owner @goField(forceResolver: true) +} + +type Owner { + id: ID! + things(limit: Int): [Thing!]! +} +` + +const alphaSchema = ` +extend type Query { + alphaItems: [AlphaItem!]! +} + +type AlphaItem { + id: ID! + thing: Thing @goField(forceResolver: true) +} + +extend type Thing { + alphaItems: [AlphaItem!]! @goField(forceResolver: true) +} +` + +const betaSchema = ` +extend type Query { + betaCount: Int! +} +` + +// writeCore writes the core schema fixture under root. +func writeCore(t *testing.T, root string) { + t.Helper() + dir := filepath.Join(root, "graph", "schema") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("creating %s: %v", dir, err) + } + if err := os.WriteFile(filepath.Join(dir, "core.graphqls"), []byte(coreSchema), 0o644); err != nil { + t.Fatalf("writing core schema: %v", err) + } +} + +// writePlugin writes a plugin fixture with a manifest and optional SDL. +func writePlugin(t *testing.T, root, id, manifestJSON, sdl string) { + t.Helper() + writePluginIn(t, root, "plugins", id, manifestJSON, sdl) +} + +// writePluginIn writes a plugin fixture under the named plugin root. +func writePluginIn(t *testing.T, root, pluginRoot, id, manifestJSON, sdl string) { + t.Helper() + pluginDir := filepath.Join(root, pluginRoot, id) + if err := os.MkdirAll(pluginDir, 0o755); err != nil { + t.Fatalf("creating %s: %v", pluginDir, err) + } + if err := os.WriteFile(filepath.Join(pluginDir, "plugin.json"), []byte(manifestJSON), 0o644); err != nil { + t.Fatalf("writing manifest: %v", err) + } + if sdl == "" { + return + } + graphDir := filepath.Join(pluginDir, "graph") + if err := os.MkdirAll(graphDir, 0o755); err != nil { + t.Fatalf("creating %s: %v", graphDir, err) + } + if err := os.WriteFile(filepath.Join(graphDir, "schema.graphqls"), []byte(sdl), 0o644); err != nil { + t.Fatalf("writing plugin schema: %v", err) + } +} + +// generated runs the generator and returns the wiring file contents. +func generated(t *testing.T, root string) string { + t.Helper() + return generatedWith(t, root, testConfig) +} + +// generatedWith runs the generator under cfg and returns the wiring file contents. +func generatedWith(t *testing.T, root string, cfg Config) string { + t.Helper() + if err := os.MkdirAll(filepath.Join(root, "cmd", "myapp"), 0o755); err != nil { + t.Fatalf("creating the output directory: %v", err) + } + if err := Run(root, cfg); err != nil { + t.Fatalf("Run() error = %v, want nil", err) + } + content, err := os.ReadFile(filepath.Join(root, "cmd", "myapp", "graph_gen.go")) + if err != nil { + t.Fatalf("reading the wiring file: %v", err) + } + return string(content) +} + +const zeroPluginWiring = `// SPDX-License-Identifier: Apache-2.0 + +// Code generated by pluginwire. DO NOT EDIT. + +package main + +import ( + graph "example.com/myapp/graph" +) + +// newGraphRoot returns the core resolver root, no plugin extends the graph. +func newGraphRoot(core graph.ResolverRoot) graph.ResolverRoot { + return core +} +` + +func TestZeroGraphQLPluginsYieldACoreOnlyRoot(t *testing.T) { + t.Parallel() + + root := t.TempDir() + writeCore(t, root) + writePlugin(t, root, "feed", + `{"id": "feed", "name": "Feed", "backend": "example.com/myapp/plugins/feed"}`, "") + + if got := generated(t, root); got != zeroPluginWiring { + t.Errorf("wiring mismatch\n got:\n%s\nwant:\n%s", got, zeroPluginWiring) + } +} + +const composedWiring = `// SPDX-License-Identifier: Apache-2.0 + +// Code generated by pluginwire. DO NOT EDIT. + +package main + +import ( + graph "example.com/myapp/graph" + graphres "example.com/myapp/internal/graphres" + alpha "example.com/myapp/plugins/alpha" + beta "example.com/myapp/plugins/beta" +) + +// coreGraphResolvers lists the resolver sets the core contributes to the graph. +type coreGraphResolvers interface { + MutationResolvers() graphres.MutationResolvers + OwnerResolvers() graphres.OwnerResolvers + QueryResolvers() graphres.QueryResolvers + ThingResolvers() graphres.ThingResolvers +} + +// alphaGraphResolvers lists the resolver sets the alpha plugin contributes to the graph. +type alphaGraphResolvers interface { + AlphaItemResolvers() alpha.AlphaItemResolvers + QueryResolvers() alpha.QueryResolvers + ThingResolvers() alpha.ThingResolvers +} + +// betaGraphResolvers lists the resolver sets the beta plugin contributes to the graph. +type betaGraphResolvers interface { + QueryResolvers() beta.QueryResolvers +} + +// coreQueryResolvers names the core Query resolver set for embedding. +type coreQueryResolvers = graphres.QueryResolvers + +// alphaQueryResolvers names the alpha Query resolver set for embedding. +type alphaQueryResolvers = alpha.QueryResolvers + +// betaQueryResolvers names the beta Query resolver set for embedding. +type betaQueryResolvers = beta.QueryResolvers + +// composedQueryResolver merges every contributed Query resolver set. +type composedQueryResolver struct { + coreQueryResolvers + alphaQueryResolvers + betaQueryResolvers +} + +// coreThingResolvers names the core Thing resolver set for embedding. +type coreThingResolvers = graphres.ThingResolvers + +// alphaThingResolvers names the alpha Thing resolver set for embedding. +type alphaThingResolvers = alpha.ThingResolvers + +// composedThingResolver merges every contributed Thing resolver set. +type composedThingResolver struct { + coreThingResolvers + alphaThingResolvers +} + +// graphRoot composes the core and plugin resolver sets into the resolver root. +type graphRoot struct { + core coreGraphResolvers + alpha alphaGraphResolvers + beta betaGraphResolvers +} + +// newGraphRoot composes the core resolver sets with every graphql plugin's. +func newGraphRoot( + core coreGraphResolvers, + alphaPlugin alphaGraphResolvers, + betaPlugin betaGraphResolvers, +) graph.ResolverRoot { + return graphRoot{core: core, alpha: alphaPlugin, beta: betaPlugin} +} + +// AlphaItem returns the AlphaItem resolver set. +func (g graphRoot) AlphaItem() graph.AlphaItemResolver { + return g.alpha.AlphaItemResolvers() +} + +// Mutation returns the Mutation resolver set. +func (g graphRoot) Mutation() graph.MutationResolver { + return g.core.MutationResolvers() +} + +// Owner returns the Owner resolver set. +func (g graphRoot) Owner() graph.OwnerResolver { + return g.core.OwnerResolvers() +} + +// Query returns the Query resolver set. +func (g graphRoot) Query() graph.QueryResolver { + return composedQueryResolver{ + g.core.QueryResolvers(), + g.alpha.QueryResolvers(), + g.beta.QueryResolvers(), + } +} + +// Thing returns the Thing resolver set. +func (g graphRoot) Thing() graph.ThingResolver { + return composedThingResolver{ + g.core.ThingResolvers(), + g.alpha.ThingResolvers(), + } +} +` + +const alphaManifest = `{ + "id": "alpha", "name": "Alpha", + "backend": "example.com/myapp/plugins/alpha", "graphql": true +}` + +var packageConfig = Config{ + ExecImport: "example.com/myapp/graph", + CoreImport: "example.com/myapp/internal/graphres", + CoreSchemaGlobs: []string{"graph/schema/*.graphqls"}, + WiringPath: "internal/graphroot/graphroot_gen.go", + License: "Apache-2.0", + Package: "graphroot", + SDKImport: "example.com/myapp/sdk", +} + +// generatedPackage runs the generator in package mode and returns the wiring. +func generatedPackage(t *testing.T, root string) string { + t.Helper() + if err := os.MkdirAll(filepath.Join(root, "internal", "graphroot"), 0o755); err != nil { + t.Fatalf("creating the output directory: %v", err) + } + if err := Run(root, packageConfig); err != nil { + t.Fatalf("Run() error = %v, want nil", err) + } + content, err := os.ReadFile(filepath.Join(root, "internal", "graphroot", "graphroot_gen.go")) + if err != nil { + t.Fatalf("reading the wiring file: %v", err) + } + return string(content) +} + +const zeroPluginPackageWiring = `// SPDX-License-Identifier: Apache-2.0 + +// Code generated by pluginwire. DO NOT EDIT. + +package graphroot + +import ( + graph "example.com/myapp/graph" + sdk "example.com/myapp/sdk" +) + +// NewGraphRoot returns the core resolver root, no plugin extends the graph. +func NewGraphRoot(core graph.ResolverRoot) graph.ResolverRoot { + return core +} + +// FromPlugins composes the resolver root, no registered plugin extends the graph. +func FromPlugins(core graph.ResolverRoot, _ []sdk.Plugin) (graph.ResolverRoot, error) { + return core, nil +} +` + +func TestPackageModeZeroPluginsExportsThePassthrough(t *testing.T) { + t.Parallel() + + root := t.TempDir() + writeCore(t, root) + writePlugin(t, root, "feed", + `{"id": "feed", "name": "Feed", "backend": "example.com/myapp/plugins/feed"}`, "") + + if got := generatedPackage(t, root); got != zeroPluginPackageWiring { + t.Errorf("wiring mismatch\n got:\n%s\nwant:\n%s", got, zeroPluginPackageWiring) + } +} + +func TestPackageModeComposesAndAssemblesFromPlugins(t *testing.T) { + t.Parallel() + + root := t.TempDir() + writeCore(t, root) + writePlugin(t, root, "alpha", alphaManifest, alphaSchema) + + got := generatedPackage(t, root) + + wants := []string{ + "package graphroot", + "type CoreGraphResolvers interface {", + "type AlphaGraphResolvers interface {", + "func NewGraphRoot(", + "core CoreGraphResolvers,", + "alphaPlugin AlphaGraphResolvers,", + "func FromPlugins(core CoreGraphResolvers, plugins []sdk.Plugin) (graph.ResolverRoot, error) {", + "if candidate, ok := plugin.(AlphaGraphResolvers); ok {", + `return nil, errors.New("graphroot: no registered plugin provides the alpha resolver sets")`, + } + for _, want := range wants { + if !strings.Contains(got, want) { + t.Errorf("wiring misses %q\n%s", want, got) + } + } +} + +func TestFromPluginsLetsALaterPluginOverrideAnEarlierOne(t *testing.T) { + t.Parallel() + + root := t.TempDir() + writeCore(t, root) + writePlugin(t, root, "alpha", alphaManifest, alphaSchema) + + got := generatedPackage(t, root) + + unconditional := "if candidate, ok := plugin.(AlphaGraphResolvers); ok {\n\t\t\talphaPlugin = candidate\n\t\t}" + if !strings.Contains(got, unconditional) { + t.Errorf("wiring guards the assignment, a later plugin must override an earlier one\n%s", got) + } + loop := got[strings.Index(got, "for _, plugin := range plugins {"):] + loop = loop[:strings.Index(loop, "\n\t}")] + if strings.Contains(loop, "break") { + t.Errorf("the assignment loop breaks early, a later plugin must override an earlier one\n%s", loop) + } +} + +func TestPackageModeRequiresTheSDKImport(t *testing.T) { + t.Parallel() + + root := t.TempDir() + writeCore(t, root) + incomplete := packageConfig + incomplete.SDKImport = "" + + err := Run(root, incomplete) + + if err == nil || !strings.Contains(err.Error(), "SDKImport") { + t.Errorf("Run() error = %v, want the missing SDKImport reported", err) + } +} + +func TestPluginsComposeWithTheCoreSets(t *testing.T) { + t.Parallel() + + root := t.TempDir() + writeCore(t, root) + writePlugin(t, root, "alpha", alphaManifest, alphaSchema) + writePlugin(t, root, "beta", + `{"id": "beta", "name": "Beta", "backend": "example.com/myapp/plugins/beta", "graphql": true}`, + betaSchema) + + if got := generated(t, root); got != composedWiring { + t.Errorf("wiring mismatch\n got:\n%s\nwant:\n%s", got, composedWiring) + } +} + +func TestUnflaggedPluginsContributeNothing(t *testing.T) { + t.Parallel() + + root := t.TempDir() + writeCore(t, root) + writePlugin(t, root, "alpha", alphaManifest, alphaSchema) + writePlugin(t, root, "beta", + `{"id": "beta", "name": "Beta", "backend": "example.com/myapp/plugins/beta"}`, + betaSchema) + + if got := generated(t, root); strings.Contains(got, "beta") { + t.Errorf("wiring mentions the unflagged beta plugin:\n%s", got) + } +} + +func TestRunWiresAPluginFromASecondRoot(t *testing.T) { + t.Parallel() + + root := t.TempDir() + writeCore(t, root) + writePlugin(t, root, "alpha", + `{"id": "alpha", "name": "Alpha", "backend": "example.com/myapp/plugins/alpha", "graphql": true}`, + alphaSchema) + writePluginIn(t, root, "enterprise", "tenancy", + `{"id": "tenancy", "name": "Tenancy", "backend": "example.com/enterprise/tenancy", "graphql": true}`, + betaSchema) + cfg := testConfig + cfg.Roots = []string{"plugins", "enterprise"} + + got := generatedWith(t, root, cfg) + + if !strings.Contains(got, "example.com/myapp/plugins/alpha") || + !strings.Contains(got, "example.com/enterprise/tenancy") { + t.Fatalf("wiring = %q, want contributors from both roots", got) + } + alphaAt := strings.Index(got, "alphaPlugin") + tenancyAt := strings.Index(got, "tenancyPlugin") + if alphaAt < 0 || tenancyAt < 0 { + t.Fatalf("wiring = %q, want composed parameters for both plugins", got) + } + if alphaAt > tenancyAt { + t.Errorf("wiring composes the enterprise root before plugins, want root order") + } +} + +func TestRunEmptySecondRootReproducesDefaultBytes(t *testing.T) { + t.Parallel() + + defaultRoot := t.TempDir() + overlayRoot := t.TempDir() + for _, root := range []string{defaultRoot, overlayRoot} { + writeCore(t, root) + writePlugin(t, root, "alpha", + `{"id": "alpha", "name": "Alpha", "backend": "example.com/myapp/plugins/alpha", "graphql": true}`, + alphaSchema) + } + if err := os.MkdirAll(filepath.Join(overlayRoot, "enterprise"), 0o755); err != nil { + t.Fatalf("creating the empty enterprise root: %v", err) + } + readme := filepath.Join(overlayRoot, "enterprise", "README.md") + if err := os.WriteFile(readme, []byte("enterprise plugins land here"), 0o644); err != nil { + t.Fatalf("writing the enterprise README: %v", err) + } + overlayConfig := testConfig + overlayConfig.Roots = []string{"plugins", "enterprise"} + + defaultWiring := generated(t, defaultRoot) + overlayWiring := generatedWith(t, overlayRoot, overlayConfig) + + if defaultWiring != overlayWiring { + t.Errorf("wiring differs between the default and the empty enterprise root") + } +} + +func TestRunRejectsDuplicateIDAcrossRoots(t *testing.T) { + t.Parallel() + + root := t.TempDir() + writeCore(t, root) + writePlugin(t, root, "alpha", + `{"id": "alpha", "name": "Alpha", "backend": "example.com/myapp/plugins/alpha", "graphql": true}`, + alphaSchema) + writePluginIn(t, root, "enterprise", "alpha", + `{"id": "alpha", "name": "Alpha", "backend": "example.com/enterprise/alpha", "graphql": true}`, + alphaSchema) + cfg := testConfig + cfg.Roots = []string{"plugins", "enterprise"} + + err := Run(root, cfg) + + if err == nil { + t.Fatal("Run() error = nil, want a duplicate id error") + } + if !strings.Contains(err.Error(), "alpha") { + t.Errorf("error = %q, want it to name the duplicated id", err) + } +} + +func TestRunReportsAMissingNamedRoot(t *testing.T) { + t.Parallel() + + root := t.TempDir() + writeCore(t, root) + writePlugin(t, root, "alpha", + `{"id": "alpha", "name": "Alpha", "backend": "example.com/myapp/plugins/alpha", "graphql": true}`, + alphaSchema) + if err := os.MkdirAll(filepath.Join(root, "cmd", "myapp"), 0o755); err != nil { + t.Fatalf("creating the output directory: %v", err) + } + cfg := testConfig + cfg.Roots = []string{"plugins", "enterprise"} + + if err := Run(root, cfg); err == nil { + t.Fatal("Run() error = nil, want a missing root error") + } +} + +func TestUnreadableSDLFailsLoudly(t *testing.T) { + t.Parallel() + + root := t.TempDir() + writeCore(t, root) + writePlugin(t, root, "alpha", alphaManifest, "type {{{ nope") + + err := Run(root, testConfig) + + if err == nil || !strings.Contains(err.Error(), "schema.graphqls") { + t.Errorf("Run() error = %v, want the failing SDL path named", err) + } +} + +func TestFlaggedPluginWithoutSchemaFails(t *testing.T) { + t.Parallel() + + root := t.TempDir() + writeCore(t, root) + writePlugin(t, root, "alpha", alphaManifest, "") + + err := Run(root, testConfig) + + if err == nil || !strings.Contains(err.Error(), "graph/*.graphqls") { + t.Errorf("Run() error = %v, want the missing SDL reported", err) + } +} + +func TestGraphQLPluginsRequireABackend(t *testing.T) { + t.Parallel() + + root := t.TempDir() + writeCore(t, root) + writePlugin(t, root, "alpha", + `{"id": "alpha", "name": "Alpha", "frontend": "@myapp/alpha", "graphql": true}`, + alphaSchema) + + err := Run(root, testConfig) + + if err == nil || !strings.Contains(err.Error(), "backend") { + t.Errorf("Run() error = %v, want the missing backend reported", err) + } +} From 8de5a1d4289548c676e2740c1fbac83e30fa0d9f Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sat, 26 Sep 2026 11:32:15 +0200 Subject: [PATCH 3/4] ci: test, lint and scan pluginkit and graphwire --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a6462a6..c71750c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ jobs: strategy: fail-fast: false matrix: - module: ["mailkit", "gonsole"] + module: ["mailkit", "gonsole", "pluginkit", "pluginkit/graphwire"] defaults: run: working-directory: ${{ matrix.module }} @@ -95,7 +95,7 @@ jobs: strategy: fail-fast: false matrix: - module: ["mailkit", "gonsole", "gonsole/auth"] + module: ["mailkit", "gonsole", "gonsole/auth", "pluginkit", "pluginkit/graphwire"] steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: @@ -117,7 +117,7 @@ jobs: strategy: fail-fast: false matrix: - module: ["mailkit", "gonsole", "gonsole/auth"] + module: ["mailkit", "gonsole", "gonsole/auth", "pluginkit", "pluginkit/graphwire"] defaults: run: working-directory: ${{ matrix.module }} From 954a2250aea310921e082608e0e7f6d952472489 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sat, 26 Sep 2026 11:32:15 +0200 Subject: [PATCH 4/4] docs: list pluginkit and graphwire among the modules --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index cd6a837..3bbe334 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,11 @@ need and ignore the rest. TypeScript applications, published to npm as `@gopherium/gottext`. - [`mailkit`](mailkit/) renders mail from template files and sends it over SMTP. +- [`pluginkit`](pluginkit/) migrates, starts and stops the compiled + plugins of an application, guards their routes, and generates their + wiring. +- [`pluginkit/graphwire`](pluginkit/graphwire/) generates the GraphQL + resolver root of an application from the schemas of its plugins. ## Design