[Proposal] Two-tier plugin model: internal plugins + external PDK #2636
malinthaprasan
started this conversation in
General
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
-
1. Why we need this
Two very different kinds of code need to extend platform-api. We trust them differently, and — this is the important part — we owe them different promises about not breaking their code later:
Features we build inside this repo that aren't ready to ship yet (or are turned off for some other reason) but still need to be committed. Example: the
eventgatewayplugin (WebSub / WebBroker). This code lives in the repo, is compiled with it, and genuinely needs deep access to everything.Extensions in a separate repo that import platform-api as a library. Example:
api-cloud. These need a small, stable set of things to build against. If we let them reach into the repository layer (or raw internal service types), then every small refactor we do becomes a breaking change for them. Repository method signatures change often, and we don't want to freeze them.The model we have today (one public
Plugintype with aDepsthat hands over raw repositories) treats both the same. It gives external code the same deep, unstable access that in-repo code gets. This proposal splits extension into two tiers, so the only code we promise to keep stable is a small, deliberate public surface.2. The two tiers
eventgatewayapi-cloudinternal/plugin.Pluginpdk.Plugin(public)Deps)internal/pdk+api(both public)builtinPlugins()(build tag)platform.WithPlugin(...)The rule of thumb: the only code we promise to keep stable is the external surface (
pdk+api). Everything under it —service,repository,model— stays ininternal/and we can change it whenever we like. Internal plugins never count here, because they are rebuilt along with the repo.3. The external surface: the
pdkpackagepdk("Plugin Development Kit") is the one public package an external plugin builds against. It holds only interfaces, small value types, and helpers to read the request — no real logic. It imports onlyapi(the public OpenAPI DTOs),config, and the standard library.3.1 What an external plugin must implement
3.2
Deps— capabilities, not repositoriespdk.Depsgives a plugin the platform's capabilities as interfaces grouped by area, and only in public types. It never hands over repositories, DB handles, or the concrete internal service types.4. How it works — a list of allowed methods, not a wrapper layer
The capability interfaces are not a hand-written pass-through layer — that would not scale to ~25 services and ~200 methods. Instead we lean on a Go feature: a type satisfies an interface just by having the right methods. Our existing internal services already have these methods, so wiring them up is a plain assignment, with no extra adapter code.
What this gives us:
pdkinterface (as long as the service already has it with public types).pdkmethod can only use public types, somodel.*orrepository.*simply can't leak out.pdkinterface relies on,var _ pdk.APIs = (*APIService)(nil)stops compiling. That forces a real decision about the public contract before we release.4.1 The rule: only public types in
pdksignaturesEvery type used in a
pdkinterface method must be public: anapi.*DTO, a type owned bypdk(likepdk.ListOptions), or a plain built-in type.For each service method we want to expose, one of two things is true:
api.*). Example:GetGateway(gatewayID, orgID string) (*api.GatewayResponse, error). Just list it as-is.repository.ListOptions,model.*). Example:ListGateways(orgID *string, opts repository.ListOptions). Give the exposed version a public signature instead — takepdk.ListOptions, or plain values likelimit, offset int, or add a small clean method on the service. Convert back to the internal type inside the method, where we're still ininternal/.We do not put pagination in its own
querypackage. The sharedListOptionslives inpdk, because it's part of the public contract and shows up in many capabilities.5. Server wiring — one loop, two tiers
The two plugin types differ only in the
Depsthey get. The server keeps a single startup loop by wrapping each externalpdk.Pluginso it looks like an internalplugin.Plugin:The server takes both tiers:
cmd/main.go→internal = builtinPlugins(),external = nil.platform.App.Run→internal = nil,external = a.plugins(fromWithPlugin).Both tiers then go through the same loop (Init → merge OpenAPI scopes → RegisterRoutes → optional
AuthSkipPathProvider/ core-service hooks) and are shut down the same way.5.1 The
platformfaçadeplatformis the package a wrapper imports to build and run the server:New,Run, and theWith*options. It's the only package a wrapper needs to start things;pdkis the package it builds its plugins against. The split is clean —platformruns the server,pdksays what a plugin is and what it can reach.The façade works in terms of the external surface (
pdk) and never exposesinternal/plugin. It aliases the two contract types, so a wrapper can call them eitherplatform.Plugin/platform.Depsorpdk.Plugin/pdk.Deps(we can drop the aliases if we'd rather make wrappers importpdkdirectly):So a wrapper's
mainlooks like this:Two things follow from the façade working in terms of
pdk:pdk.Pluginand getspdk.DepsinInit— the capabilities (deps.APIs.…,deps.Gateways.…), never raw repositories or the DB. The type system makes sure of this.pdk(pdk.OrganizationFromRequest, …).platformcan keep thin aliases for convenience — see Section 9 step 4.The built-in tier does not go through
platform:cmd/main.gopassesbuiltinPlugins()straight intoStartPlatformAPIServeras theinternalargument, and they keep their fullplugin.Deps.5.2 Plugins can add middleware to the HTTP chain
Today the server wraps the mux in a fixed chain (see
internal/server/server.go):A plugin can add its own middleware at one of two fixed spots — before this chain or after it. We expose only these two on purpose. It lets a plugin either wrap the whole request or run with the authenticated identity, without freezing the internal order of the platform's own middleware (which we want to stay free to change). We do not let a plugin insert middleware between platform steps (say, between auth and the scope enforcer) — that would turn the internal chain order into part of the public contract.
The two spots
pdkrequest helpers. Good for per-tenant rate limiting, audit, or request enrichment. It must still scope by the org from context (GO-AUTH-005).The pdk surface
Server wiring
The server already collects things from each plugin in its startup loop (skip-paths, OpenAPI scopes). It collects middleware the same way, then slots the two groups into the chain when it builds it:
Notes:
plugin.MiddlewareProvidermirrorspdk.MiddlewareProvider(the same mirror-and-forward setup used forAuthSkipPathProvidertoday), so internal and external plugins share one wiring path.Example (external plugin)
6. Package layout
The only new public package is
pdk.service,repository, andmodelall stay ininternal/: thepdkinterfaces speakapi.*, somodelnever has to be made public.7. What we promise not to break
pdk+apiare the whole promise. Version them, and review any change to them carefully.internal/serviceand below can change freely. Thevar _ pdk.APIs = (*APIService)(nil)lines turn any breaking change into a build error in our own code, before we release.8. What building a plugin feels like, per tier
Internal (eventgateway): implement
internal/plugin.Plugin; usedeps.APIRepo, the concrete services, the DB — full access. Put it behind theexperimentalbuild tag. Ship it when it's ready.External (api-cloud): implement
pdk.Pluginand grab thepdk.DepsinInit. One plugin holds the whole api-cloud extension — not one plugin per resource. It keeps its own data (its own repositories, wired up by api-cloud, not by the PDK) and uses the PDK to fill in data from platform-api. It registers the routes for all api-cloud resources in oneRegisterRoutes, and grows by adding more handlers and more repositories to the same plugin.Environments is one such resource: api-cloud stores each environment (including the ID of the gateway it's linked to) in its own repository, then calls
deps.Gatewaysto get that gateway's details from platform-api. The repositories inside platform-api are out of reach — the type system won't allow it.Beta Was this translation helpful? Give feedback.
All reactions