Skip to content

Commit 5b45ece

Browse files
authored
Merge pull request #624 from pitabwire/openapi-static
feat(openapi): compile-time specs and scaffolding tools
2 parents 20d1be3 + 123c292 commit 5b45ece

File tree

20 files changed

+1384
-10
lines changed

20 files changed

+1384
-10
lines changed

.github/workflows/gemini-pr-review.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,7 @@ jobs:
154154
- name: 'Run Gemini PR Review'
155155
uses: 'google-github-actions/run-gemini-cli@v0'
156156
id: 'gemini_pr_review'
157+
continue-on-error: true
157158
env:
158159
GITHUB_TOKEN: '${{ steps.generate_token.outputs.token || secrets.GITHUB_TOKEN }}'
159160
PR_NUMBER: '${{ steps.get_pr.outputs.pr_number || steps.get_pr_comment.outputs.pr_number }}'

.github/workflows/golangci-lint.yml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,13 @@ jobs:
1515
- uses: actions/checkout@v6.0.2
1616
- name: golangci-lint
1717
uses: golangci/golangci-lint-action@v9
18+
env:
19+
GOLANGCI_LINT_CACHE: /tmp/golangci-lint
1820
with:
1921
# Optional: version of golangci-lint to use in form of v1.2 or v1.2.3 or `latest` to use the latest version
2022
version: latest
2123
args: --timeout=5m
22-
skip-cache: false
24+
skip-cache: true
2325

2426
# Optional: working directory, useful for monorepos
2527
# working-directory: somedir
@@ -37,4 +39,4 @@ jobs:
3739
# skip-pkg-cache: true
3840

3941
# Optional: if set to true then the action don't cache or restore ~/.cache/go-build.
40-
# skip-build-cache: true
42+
# skip-build-cache: true

blueprint/blueprint.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package blueprint
2+
3+
type Blueprint struct {
4+
Service string `json:"service" yaml:"service"`
5+
HTTP []HTTPRoute `json:"http" yaml:"http"`
6+
Plugins []Plugin `json:"plugins" yaml:"plugins"`
7+
Queues []Queue `json:"queues" yaml:"queues"`
8+
}
9+
10+
type HTTPRoute struct {
11+
Name string `json:"name" yaml:"name"`
12+
Method string `json:"method" yaml:"method"`
13+
Route string `json:"route" yaml:"route"`
14+
Handler string `json:"handler" yaml:"handler"`
15+
Override bool `json:"override" yaml:"override"`
16+
Remove bool `json:"remove" yaml:"remove"`
17+
}
18+
19+
type Plugin struct {
20+
Name string `json:"name" yaml:"name"`
21+
Config map[string]string `json:"config" yaml:"config"`
22+
Override bool `json:"override" yaml:"override"`
23+
Remove bool `json:"remove" yaml:"remove"`
24+
}
25+
26+
type Queue struct {
27+
Name string `json:"name" yaml:"name"`
28+
Publisher string `json:"publisher" yaml:"publisher"`
29+
Subscriber string `json:"subscriber" yaml:"subscriber"`
30+
Topic string `json:"topic" yaml:"topic"`
31+
Override bool `json:"override" yaml:"override"`
32+
Remove bool `json:"remove" yaml:"remove"`
33+
}

blueprint/merge.go

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
package blueprint
2+
3+
import "fmt"
4+
5+
func Merge(base, overlay Blueprint) (Blueprint, error) {
6+
out := base
7+
8+
if overlay.Service != "" {
9+
if out.Service == "" {
10+
out.Service = overlay.Service
11+
} else if out.Service != overlay.Service {
12+
return Blueprint{}, fmt.Errorf("service mismatch: base %q overlay %q", out.Service, overlay.Service)
13+
}
14+
}
15+
16+
out.HTTP = mergeHTTP(out.HTTP, overlay.HTTP)
17+
out.Plugins = mergePlugins(out.Plugins, overlay.Plugins)
18+
out.Queues = mergeQueues(out.Queues, overlay.Queues)
19+
20+
return out, nil
21+
}
22+
23+
func mergeHTTP(base, overlay []HTTPRoute) []HTTPRoute {
24+
return mergeList(
25+
base,
26+
overlay,
27+
func(item HTTPRoute) string { return item.Name },
28+
func(_ HTTPRoute, src HTTPRoute) HTTPRoute { return src },
29+
func(item HTTPRoute) bool { return item.Override },
30+
func(item HTTPRoute) bool { return item.Remove },
31+
)
32+
}
33+
34+
func mergePlugins(base, overlay []Plugin) []Plugin {
35+
return mergeList(
36+
base,
37+
overlay,
38+
func(item Plugin) string { return item.Name },
39+
func(_ Plugin, src Plugin) Plugin { return src },
40+
func(item Plugin) bool { return item.Override },
41+
func(item Plugin) bool { return item.Remove },
42+
)
43+
}
44+
45+
func mergeQueues(base, overlay []Queue) []Queue {
46+
return mergeList(
47+
base,
48+
overlay,
49+
func(item Queue) string { return item.Name },
50+
func(_ Queue, src Queue) Queue { return src },
51+
func(item Queue) bool { return item.Override },
52+
func(item Queue) bool { return item.Remove },
53+
)
54+
}
55+
56+
type keyFunc[T any] func(T) string
57+
type replaceFunc[T any] func(dst, src T) T
58+
59+
type flagFunc[T any] func(T) bool
60+
61+
func mergeList[T any](
62+
base, overlay []T,
63+
key keyFunc[T],
64+
replace replaceFunc[T],
65+
override flagFunc[T],
66+
remove flagFunc[T],
67+
) []T {
68+
index := make(map[string]int, len(base))
69+
out := make([]T, 0, len(base)+len(overlay))
70+
for i, item := range base {
71+
k := key(item)
72+
if k == "" {
73+
continue
74+
}
75+
index[k] = len(out)
76+
out = append(out, item)
77+
_ = i
78+
}
79+
80+
for _, item := range overlay {
81+
k := key(item)
82+
if k == "" {
83+
continue
84+
}
85+
if remove(item) {
86+
if idx, ok := index[k]; ok {
87+
out[idx] = *new(T)
88+
delete(index, k)
89+
}
90+
continue
91+
}
92+
if idx, ok := index[k]; ok {
93+
if override(item) {
94+
out[idx] = replace(out[idx], item)
95+
}
96+
continue
97+
}
98+
index[k] = len(out)
99+
out = append(out, item)
100+
}
101+
102+
// Compact removed items
103+
clean := out[:0]
104+
for _, item := range out {
105+
if key(item) == "" {
106+
continue
107+
}
108+
clean = append(clean, item)
109+
}
110+
return clean
111+
}

blueprint/merge_test.go

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
package blueprint_test
2+
3+
import (
4+
"testing"
5+
6+
"github.com/pitabwire/frame/blueprint"
7+
)
8+
9+
func TestMerge_Additive(t *testing.T) {
10+
base := blueprint.Blueprint{
11+
Service: "users",
12+
HTTP: []blueprint.HTTPRoute{
13+
{Name: "list", Method: "GET", Route: "/users", Handler: "List"},
14+
},
15+
Plugins: []blueprint.Plugin{{Name: "logging"}},
16+
}
17+
overlay := blueprint.Blueprint{
18+
HTTP: []blueprint.HTTPRoute{
19+
{Name: "create", Method: "POST", Route: "/users", Handler: "Create"},
20+
},
21+
Plugins: []blueprint.Plugin{{Name: "metrics"}},
22+
}
23+
24+
out, err := blueprint.Merge(base, overlay)
25+
if err != nil {
26+
t.Fatalf("merge: %v", err)
27+
}
28+
29+
if len(out.HTTP) != 2 {
30+
t.Fatalf("expected 2 routes, got %d", len(out.HTTP))
31+
}
32+
if len(out.Plugins) != 2 {
33+
t.Fatalf("expected 2 plugins, got %d", len(out.Plugins))
34+
}
35+
}
36+
37+
func TestMerge_Override(t *testing.T) {
38+
base := blueprint.Blueprint{
39+
HTTP: []blueprint.HTTPRoute{
40+
{Name: "list", Method: "GET", Route: "/users", Handler: "List"},
41+
},
42+
}
43+
overlay := blueprint.Blueprint{
44+
HTTP: []blueprint.HTTPRoute{
45+
{Name: "list", Method: "GET", Route: "/users", Handler: "ListV2", Override: true},
46+
},
47+
}
48+
49+
out, err := blueprint.Merge(base, overlay)
50+
if err != nil {
51+
t.Fatalf("merge: %v", err)
52+
}
53+
54+
if out.HTTP[0].Handler != "ListV2" {
55+
t.Fatalf("expected override to apply")
56+
}
57+
}
58+
59+
func TestMerge_Remove(t *testing.T) {
60+
base := blueprint.Blueprint{
61+
HTTP: []blueprint.HTTPRoute{
62+
{Name: "list", Method: "GET", Route: "/users", Handler: "List"},
63+
},
64+
}
65+
overlay := blueprint.Blueprint{
66+
HTTP: []blueprint.HTTPRoute{
67+
{Name: "list", Remove: true},
68+
},
69+
}
70+
71+
out, err := blueprint.Merge(base, overlay)
72+
if err != nil {
73+
t.Fatalf("merge: %v", err)
74+
}
75+
76+
if len(out.HTTP) != 0 {
77+
t.Fatalf("expected route removed, got %d", len(out.HTTP))
78+
}
79+
}
80+
81+
func TestMerge_ServiceMismatch(t *testing.T) {
82+
base := blueprint.Blueprint{Service: "users"}
83+
overlay := blueprint.Blueprint{Service: "billing"}
84+
85+
_, err := blueprint.Merge(base, overlay)
86+
if err == nil {
87+
t.Fatalf("expected mismatch error")
88+
}
89+
}

docs/ai-assistants.md

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,19 +24,36 @@ This is the canonical pattern that matches Frame’s API.
2424

2525
```text
2626
/cmd/myservice/main.go
27-
/internal/handlers/...
28-
/internal/plugins/...
29-
/internal/clients/...
27+
/pkg/handlers/...
28+
/pkg/plugins/...
29+
/pkg/clients/...
30+
/pkg/openapi/...
3031
/configs/...
3132
```
3233

34+
For monorepos, prefer:
35+
36+
```text
37+
/cmd
38+
/monolith
39+
/users
40+
/billing
41+
/apps
42+
/users
43+
/billing
44+
/pkg
45+
/plugins
46+
/openapi
47+
```
48+
3349
## How to Ask AI for Frame Code
3450

3551
Use these prompt patterns:
3652

3753
- “Generate a new HTTP service using Frame, using the canonical `ctx, svc := frame.NewService(...)` bootstrap pattern.”
3854
- “Create a new Frame plugin as a `WithXxx` option that registers a queue subscriber.”
3955
- “Add a datastore setup using `WithDatastore` and a migration step.”
56+
- “Generate OpenAPI specs with `frame-openapi` and register `openapi.Option()`.”
4057

4158
## Frame Plugin Mental Model
4259

@@ -46,3 +63,13 @@ A plugin is a `frame.Option` helper that configures a `Service` and registers st
4663

4764
- Don’t assume `NewService` returns `*Service` directly; it returns `(context.Context, *Service)`.
4865
- Don’t assume `WithName` accepts a handler; use `WithHTTPHandler` separately.
66+
67+
## Blueprint Extension Rules (AI-Safe)
68+
69+
When working with Frame Blueprints, always **extend** by default:
70+
71+
- Add new items without removing or replacing existing ones.
72+
- Use `override: true` to modify existing definitions.
73+
- Use `remove: true` to delete definitions.
74+
75+
This preserves system stability while allowing incremental expansion.

0 commit comments

Comments
 (0)