Skip to content

Commit 6f348f6

Browse files
committed
feat(apiref): refine catalog generation and validation for namespaces and categories
1 parent 2b1e33b commit 6f348f6

13 files changed

Lines changed: 236 additions & 213 deletions

File tree

docs/maintainers/core-api-reference.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -82,9 +82,10 @@ GOWORK=off go -C tools/apiref run . \
8282
The command emits diagnostics only on stderr and writes deterministic,
8383
two-space-indented JSON with one trailing newline. `api.json` remains the
8484
canonical callable API. `catalog.json` contains presentation categories for
85-
global functions and real namespace roots. Categories such as `math` and
86-
`strings` are not callable Ferret namespaces. The generator contains no
87-
deployment domain.
85+
global and namespaced functions. Each catalog member identifies a function by
86+
its namespace and name; the empty namespace identifies a global function.
87+
Categories such as `math`, `io`, and `testing` are presentation concepts, not
88+
callable Ferret namespaces. The generator contains no deployment domain.
8889

8990
## Release publication
9091

scripts/publish_core_api_test.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,9 +90,11 @@ func writeCatalog(t *testing.T, path, version string) {
9090
"id": "utils",
9191
"title": "Utilities",
9292
"description": "General utility functions.",
93-
"functions": []string{"PING"},
93+
"functions": []any{map[string]any{
94+
"namespace": "",
95+
"name": "PING",
96+
}},
9497
}},
95-
"namespaceRoots": []string{},
9698
}
9799

98100
data, err := json.MarshalIndent(catalog, "", " ")

tools/apipublish/go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ go 1.25.0
44

55
require (
66
github.com/Masterminds/semver/v3 v3.5.0
7-
github.com/MontFerret/specs v1.10.0
7+
github.com/MontFerret/specs v1.11.0
88
)
99

1010
require (

tools/apipublish/go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE=
22
github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
3-
github.com/MontFerret/specs v1.10.0 h1:T3p90YO7KNU0ROaNlJeoCZ2TBfibJfOiLEr1IOpzwo4=
4-
github.com/MontFerret/specs v1.10.0/go.mod h1:ENGyEPFjrWVPWOUAsBMc1acUsqpswieTrAJfhcm576E=
3+
github.com/MontFerret/specs v1.11.0 h1:EAcy/UFhJqQm97j76ClRDrfvU4JyocP/jeGf7CxmzOw=
4+
github.com/MontFerret/specs v1.11.0/go.mod h1:ENGyEPFjrWVPWOUAsBMc1acUsqpswieTrAJfhcm576E=
55
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
66
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
77
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28=

tools/apipublish/internal/publisher/catalog.go

Lines changed: 32 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,24 @@ package publisher
33
import (
44
"errors"
55
"fmt"
6-
"strings"
76

87
"github.com/MontFerret/specs/pkg/api"
98
apicatalog "github.com/MontFerret/specs/pkg/api/catalog"
109
)
1110

11+
type functionIdentity struct {
12+
Namespace string
13+
Name string
14+
}
15+
16+
func (identity functionIdentity) String() string {
17+
if identity.Namespace == "" {
18+
return identity.Name
19+
}
20+
21+
return identity.Namespace + "::" + identity.Name
22+
}
23+
1224
func validatePair(reference *api.Reference, catalog *apicatalog.Catalog) error {
1325
problems := make([]error, 0)
1426
if catalog.ID != reference.ID {
@@ -19,53 +31,39 @@ func validatePair(reference *api.Reference, catalog *apicatalog.Catalog) error {
1931
problems = append(problems, fmt.Errorf("catalog version %q does not match API version %q", catalog.Version, reference.Version))
2032
}
2133

22-
globalFunctions := make(map[string]struct{})
23-
namespaceRoots := make(map[string]struct{})
34+
apiFunctions := make(map[functionIdentity]struct{})
35+
apiNamespaces := make(map[string]map[string]struct{}, len(reference.Namespaces))
2436
for _, namespace := range reference.Namespaces {
25-
if namespace.Name == "" {
26-
for _, function := range namespace.Functions {
27-
globalFunctions[function.Name] = struct{}{}
28-
}
29-
30-
continue
37+
functions := make(map[string]struct{}, len(namespace.Functions))
38+
apiNamespaces[namespace.Name] = functions
39+
for _, function := range namespace.Functions {
40+
functions[function.Name] = struct{}{}
41+
apiFunctions[functionIdentity{Namespace: namespace.Name, Name: function.Name}] = struct{}{}
3142
}
32-
33-
root, _, _ := strings.Cut(namespace.Name, "::")
34-
namespaceRoots[root] = struct{}{}
3543
}
3644

37-
categorized := make(map[string]string)
45+
categorized := make(map[functionIdentity]string)
3846
for _, category := range catalog.Categories {
3947
for _, function := range category.Functions {
40-
if _, exists := globalFunctions[function]; !exists {
41-
problems = append(problems, fmt.Errorf("catalog category %q references unknown global function %q", category.ID, function))
48+
identity := functionIdentity{Namespace: function.Namespace, Name: function.Name}
49+
namespace, exists := apiNamespaces[function.Namespace]
50+
if !exists {
51+
problems = append(problems, fmt.Errorf("catalog category %q references unknown API namespace %q", category.ID, function.Namespace))
52+
} else if _, exists := namespace[function.Name]; !exists {
53+
problems = append(problems, fmt.Errorf("catalog category %q references unknown function %q in API namespace %q", category.ID, function.Name, function.Namespace))
4254
}
4355

44-
if previous, exists := categorized[function]; exists {
45-
problems = append(problems, fmt.Errorf("global function %q is assigned to categories %q and %q", function, previous, category.ID))
56+
if previous, exists := categorized[identity]; exists {
57+
problems = append(problems, fmt.Errorf("function %q is assigned to categories %q and %q", identity, previous, category.ID))
4658
}
4759

48-
categorized[function] = category.ID
60+
categorized[identity] = category.ID
4961
}
5062
}
5163

52-
for function := range globalFunctions {
64+
for function := range apiFunctions {
5365
if _, exists := categorized[function]; !exists {
54-
problems = append(problems, fmt.Errorf("global function %q is not assigned to a catalog category", function))
55-
}
56-
}
57-
58-
declaredRoots := make(map[string]struct{}, len(catalog.NamespaceRoots))
59-
for _, root := range catalog.NamespaceRoots {
60-
declaredRoots[root] = struct{}{}
61-
if _, exists := namespaceRoots[root]; !exists {
62-
problems = append(problems, fmt.Errorf("catalog namespace root %q does not cover an API namespace", root))
63-
}
64-
}
65-
66-
for root := range namespaceRoots {
67-
if _, exists := declaredRoots[root]; !exists {
68-
problems = append(problems, fmt.Errorf("API namespace root %q is not declared by the catalog", root))
66+
problems = append(problems, fmt.Errorf("function %q is not assigned to a catalog category", function))
6967
}
7068
}
7169

tools/apipublish/internal/publisher/publish_test.go

Lines changed: 63 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,15 @@ func TestPublishRejectsInvalidIncomingCatalogWithoutMutation(t *testing.T) {
207207
{name: "malformed", catalog: []byte("{\n"), want: "parse generated API Catalog"},
208208
{name: "identity mismatch", catalog: catalogData(t, "2.0.0-alpha.47", "other/module"), want: "does not match API id"},
209209
{name: "version mismatch", catalog: catalogData(t, "2.0.0-alpha.46", "montferret/core"), want: "does not match API version"},
210+
{name: "unknown namespace", catalog: mutateCatalogData(t, "2.0.0-alpha.47", func(value *apicatalog.Catalog) {
211+
value.Categories[0].Functions[0].Namespace = "io::missing"
212+
}), want: "unknown API namespace"},
213+
{name: "unknown function", catalog: mutateCatalogData(t, "2.0.0-alpha.47", func(value *apicatalog.Catalog) {
214+
value.Categories[0].Functions[0].Name = "MISSING"
215+
}), want: "unknown function"},
216+
{name: "uncategorized namespaced function", catalog: mutateCatalogData(t, "2.0.0-alpha.47", func(value *apicatalog.Catalog) {
217+
value.Categories = value.Categories[1:]
218+
}), want: `function "io::fs::READ" is not assigned`},
210219
}
211220

212221
for _, test := range tests {
@@ -231,17 +240,30 @@ func referenceData(t *testing.T, version, id string) []byte {
231240
SchemaVersion: api.SchemaVersion,
232241
ID: id,
233242
Version: version,
234-
Namespaces: []api.Namespace{{
235-
Name: "",
236-
Functions: []api.Function{{
237-
Name: "PING",
238-
Signatures: []api.Signature{{
239-
Parameters: []api.Parameter{},
240-
Description: "Returns a value.",
241-
Return: &api.Return{Type: "String", Description: "Value."},
243+
Namespaces: []api.Namespace{
244+
{
245+
Name: "",
246+
Functions: []api.Function{{
247+
Name: "PING",
248+
Signatures: []api.Signature{{
249+
Parameters: []api.Parameter{},
250+
Description: "Returns a value.",
251+
Return: &api.Return{Type: "String", Description: "Value."},
252+
}},
242253
}},
243-
}},
244-
}},
254+
},
255+
{
256+
Name: "io::fs",
257+
Functions: []api.Function{{
258+
Name: "READ",
259+
Signatures: []api.Signature{{
260+
Parameters: []api.Parameter{},
261+
Description: "Reads a file.",
262+
Return: &api.Return{Type: "String", Description: "Contents."},
263+
}},
264+
}},
265+
},
266+
},
245267
}
246268

247269
data, err := json.MarshalIndent(reference, "", " ")
@@ -259,14 +281,38 @@ func catalogData(t *testing.T, version, id string) []byte {
259281
SchemaVersion: apicatalog.SchemaVersion,
260282
ID: id,
261283
Version: version,
262-
Categories: []apicatalog.Category{{
263-
ID: "utils",
264-
Title: "Utilities",
265-
Description: "General utility functions.",
266-
Functions: []string{"PING"},
267-
}},
268-
NamespaceRoots: []string{},
284+
Categories: []apicatalog.Category{
285+
{
286+
ID: "io",
287+
Title: "I/O",
288+
Description: "Input and output functions.",
289+
Functions: []apicatalog.FunctionRef{{Namespace: "io::fs", Name: "READ"}},
290+
},
291+
{
292+
ID: "utils",
293+
Title: "Utilities",
294+
Description: "General utility functions.",
295+
Functions: []apicatalog.FunctionRef{{Namespace: "", Name: "PING"}},
296+
},
297+
},
298+
}
299+
300+
data, err := json.MarshalIndent(catalog, "", " ")
301+
if err != nil {
302+
t.Fatal(err)
303+
}
304+
305+
return append(data, '\n')
306+
}
307+
308+
func mutateCatalogData(t *testing.T, version string, mutate func(*apicatalog.Catalog)) []byte {
309+
t.Helper()
310+
311+
catalog, err := apicatalog.Parse(catalogData(t, version, "montferret/core"))
312+
if err != nil {
313+
t.Fatal(err)
269314
}
315+
mutate(catalog)
270316

271317
data, err := json.MarshalIndent(catalog, "", " ")
272318
if err != nil {

tools/apiref/go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ go 1.25.0
55
require (
66
github.com/Masterminds/semver/v3 v3.5.0
77
github.com/MontFerret/ferret/v2 v2.0.0
8-
github.com/MontFerret/specs v1.10.0
8+
github.com/MontFerret/specs v1.11.0
99
golang.org/x/tools v0.48.0
1010
)
1111

tools/apiref/go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE=
22
github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
3-
github.com/MontFerret/specs v1.10.0 h1:T3p90YO7KNU0ROaNlJeoCZ2TBfibJfOiLEr1IOpzwo4=
4-
github.com/MontFerret/specs v1.10.0/go.mod h1:ENGyEPFjrWVPWOUAsBMc1acUsqpswieTrAJfhcm576E=
3+
github.com/MontFerret/specs v1.11.0 h1:EAcy/UFhJqQm97j76ClRDrfvU4JyocP/jeGf7CxmzOw=
4+
github.com/MontFerret/specs v1.11.0/go.mod h1:ENGyEPFjrWVPWOUAsBMc1acUsqpswieTrAJfhcm576E=
55
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
66
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
77
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=

0 commit comments

Comments
 (0)