diff --git a/cmd/fabrica/config_security_test.go b/cmd/fabrica/config_security_test.go index 90e8dc0..49ac399 100644 --- a/cmd/fabrica/config_security_test.go +++ b/cmd/fabrica/config_security_test.go @@ -22,12 +22,12 @@ func TestValidateConfig_AuthZRequiresAuthN(t *testing.T) { } } -func TestValidateConfig_RequiresStorage(t *testing.T) { +func TestValidateConfig_AllowsDisabledStorage(t *testing.T) { cfg := config.NewDefaultConfig("test", "example.com/test") cfg.Features.Storage.Enabled = false - if err := config.ValidateConfig(cfg); err == nil { - t.Fatalf("expected storage-disabled config to be rejected") + if err := config.ValidateConfig(cfg); err != nil { + t.Fatalf("expected storage-disabled config to be valid: %v", err) } } diff --git a/cmd/fabrica/generate.go b/cmd/fabrica/generate.go index 676e538..f81e643 100644 --- a/cmd/fabrica/generate.go +++ b/cmd/fabrica/generate.go @@ -128,6 +128,25 @@ Examples: return err } + effectiveHandlers := handlers + effectiveStorage := storage + effectiveClient := client + effectiveOpenAPI := openapi + if all { + // Use config settings if present, otherwise default to true for all + if config != nil { + effectiveHandlers = config.Generation.Handlers + effectiveStorage = config.Generation.Storage + effectiveClient = config.Generation.Client + effectiveOpenAPI = config.Generation.OpenAPI + } else { + effectiveHandlers = true + effectiveStorage = true + effectiveClient = true + effectiveOpenAPI = true + } + } + if debug { if apisConfig != nil { fmt.Println("🔍 Discovering resources in apis///...") @@ -203,17 +222,17 @@ Examples: } // Generate server code (handlers, storage, openapi) - if all || handlers || storage || openapi { + if effectiveHandlers || effectiveStorage || effectiveOpenAPI { if debug { fmt.Println("📦 Generating server code...") } - if err := generateCodeWithRunner(projectRoot, modulePath, "cmd/server", "main", all || handlers, all || storage, all || openapi, false, debug, resolvedFabricaSource); err != nil { + if err := generateCodeWithRunner(projectRoot, modulePath, "cmd/server", "main", effectiveHandlers, effectiveStorage, effectiveOpenAPI, false, debug, resolvedFabricaSource); err != nil { return fmt.Errorf("failed to generate server code: %w", err) } } // Generate client code - if all || client { + if effectiveClient { fmt.Println("📦 Generating client code...") if err := generateCodeWithRunner(projectRoot, modulePath, "pkg/client", "client", false, false, false, true, debug, resolvedFabricaSource); err != nil { return fmt.Errorf("failed to generate client code: %w", err) @@ -230,7 +249,7 @@ Examples: // Auto-generate Ent client code if using Ent storage storageType := detectStorageType() - if storageType == "ent" && (all || storage) { + if storageType == "ent" && effectiveStorage { fmt.Println("🔄 Generating Ent client code...") if err := generateEntCode(debug); err != nil { @@ -770,6 +789,7 @@ type EventsConfig struct { } type StorageConfig struct { + Enabled *bool `+"`yaml:\"enabled\"`"+` Type string `+"`yaml:\"type\"`"+` DBDriver string `+"`yaml:\"db_driver\"`"+` } @@ -822,6 +842,9 @@ func main() { gen.Config.ETagAlgorithm = config.Features.Conditional.ETagAlgorithm gen.Config.EventsEnabled = config.Features.Events.Enabled gen.Config.EventBusType = config.Features.Events.BusType + if config.Features.Storage.Enabled != nil { + gen.Config.StorageEnabled = *config.Features.Storage.Enabled + } // Override storage config from .fabrica.yaml if present if config.Features.Storage.Type != "" { @@ -916,7 +939,7 @@ func discoverVersionedResources(apisConfig *config.APIsConfig) ([]string, error) if _, err := os.Stat(hubDir); os.IsNotExist(err) { // Hub directory doesn't exist yet, return resources listed in apis.yaml - return group.Resources, nil + return group.Resources.Names(), nil } var resources []string @@ -1249,6 +1272,18 @@ func generateVersionedRegistrationCode(modulePath string, apisConfig *config.API fmt.Fprintf(®istrations, "\tif err := gen.RegisterResource(&%s.%s{}); err != nil {\n", pkg, resourceStruct) fmt.Fprintf(®istrations, "\t\treturn fmt.Errorf(\"failed to register %s: %%w\", err)\n", resource) registrations.WriteString("\t}\n") + if resourceSettings, ok := group.Resources.Get(resource); ok && resourceSettings.Configured() { + registrations.WriteString("\tgen.ConfigureResource(" + strconv.Quote(resource) + ", codegen.ResourceConfig{\n") + registrations.WriteString("\t\tURLPath: " + strconv.Quote(resourceSettings.Path) + ",\n") + registrations.WriteString("\t\tOperations: codegen.ResourceOperationsFromNames([]string{") + for i, operation := range resourceSettings.Operations { + if i > 0 { + registrations.WriteString(", ") + } + registrations.WriteString(strconv.Quote(operation)) + } + registrations.WriteString("}),\n\t})\n") + } } return fmt.Sprintf(`%spackage resources diff --git a/cmd/fabrica/generate_runner_test.go b/cmd/fabrica/generate_runner_test.go index f1baaf1..2a14672 100644 --- a/cmd/fabrica/generate_runner_test.go +++ b/cmd/fabrica/generate_runner_test.go @@ -55,6 +55,29 @@ func TestGenerateRunnerCode_SetsAuthForFalseAndTrue(t *testing.T) { } } +func TestGenerateRunnerCode_DeclaresStorageEnabled(t *testing.T) { + runnerCode := generateRunnerCode( + "/tmp/project", + "github.com/example/project", + "cmd/server", + "main", + true, + false, + false, + false, + false, + "file", + ) + + if !strings.Contains(runnerCode, "Enabled *bool `yaml:\"enabled\"`") { + t.Fatalf("runner storage config must distinguish an omitted enabled field:\n%s", runnerCode) + } + if !strings.Contains(runnerCode, "if config.Features.Storage.Enabled != nil {") || + !strings.Contains(runnerCode, "gen.Config.StorageEnabled = *config.Features.Storage.Enabled") { + t.Fatalf("runner must only override the storage default when enabled is present:\n%s", runnerCode) + } +} + func TestGenerateRunnerCode_StorageOnlyDoesNotRegenerateRoutesOrModels(t *testing.T) { runnerCode := generateRunnerCode( "/tmp/project", diff --git a/cmd/fabrica/migrate.go b/cmd/fabrica/migrate.go index e95e6ee..55aeb15 100644 --- a/cmd/fabrica/migrate.go +++ b/cmd/fabrica/migrate.go @@ -140,7 +140,7 @@ func resourceTypeFiles(projectDir string, apisConfig *configpkg.APIsConfig) ([]s for _, version := range group.Versions { versionDir := filepath.Join(projectDir, "apis", group.Name, version) for _, resource := range group.Resources { - addFile(filepath.Join(versionDir, strings.ToLower(resource)+"_types.go")) + addFile(filepath.Join(versionDir, strings.ToLower(resource.Name)+"_types.go")) } entries, err := os.ReadDir(versionDir) diff --git a/cmd/fabrica/registration_template_test.go b/cmd/fabrica/registration_template_test.go index b529c12..338d43c 100644 --- a/cmd/fabrica/registration_template_test.go +++ b/cmd/fabrica/registration_template_test.go @@ -51,3 +51,29 @@ func TestRegistrationGeneratorsProduceGofmtStableOutput(t *testing.T) { } } } + +func TestRegistrationUsesTypedResourceConfiguration(t *testing.T) { + apisConfig := configpkg.DefaultAPIsConfig("example.fabrica.dev", "v1", []string{"v1"}) + apisConfig.Groups[0].Resources = configpkg.APIResources{ + { + Name: "Device", + Path: "/hardware/devices", + Operations: []string{"read", "status"}, + }, + } + + content := generateVersionedRegistrationCode("example.com/test", apisConfig, []string{"Device"}) + + for _, expected := range []string{ + `gen.ConfigureResource("Device", codegen.ResourceConfig{`, + `URLPath: "/hardware/devices",`, + `Operations: codegen.ResourceOperationsFromNames([]string{"read", "status"}),`, + } { + if !strings.Contains(content, expected) { + t.Errorf("versioned registration missing %q", expected) + } + } + if strings.Contains(content, `reflect`) || strings.Contains(content, `func configureResource`) { + t.Error("versioned registration should not contain the compatibility reflection helper") + } +} diff --git a/docs/apis-yaml.md b/docs/apis-yaml.md index df7a9c0..3ddf7ff 100644 --- a/docs/apis-yaml.md +++ b/docs/apis-yaml.md @@ -36,9 +36,72 @@ Fields: - `name`: fully qualified group name. - `storageVersion`: hub version used for storage and conversions. - `versions`: ordered list of all versions (hub + spokes). The hub must be included. -- `resources`: maintained by CLI commands; reflects resources under the hub directory. +- `resources`: maintained by CLI commands; reflects resources under the hub directory. Accepts either list syntax or configured map syntax. - `imports`: optional remote type imports exposed to generated APIs. +## Resource generation control + +Use list syntax when a resource should use the default path and full generated operation set: + +```yaml +groups: + - name: infra.example.io + storageVersion: v1 + versions: + - v1 + resources: + - Device + - Rack +``` + +Use map syntax when a resource needs a custom collection path or a restricted operation set: + +```yaml +groups: + - name: remote-console.openchami.io + storageVersion: v1 + versions: + - v1 + resources: + Console: + path: /remote-console/consoles + operations: + - list + - get +``` + +If `path` is omitted, Fabrica uses the default `/s` path. If `operations` is omitted, Fabrica generates the default CRUD plus status surface. When present, `operations` must contain at least one operation; an empty list is rejected. Resource configuration accepts only `path` and `operations`; unknown fields are rejected. + +Supported operation values are grouped by the API surface they generate. In the routes below, `` is the configured resource path and `{uid}` identifies one resource. + +Read operations: + +- `list` generates `GET ` to return the resource collection. +- `get` generates `GET /{uid}` to return one resource. + +Write operations: + +- `create` generates `POST ` to create a resource. +- `update` or `put` generates `PUT /{uid}` to replace a resource. +- `patch` generates `PATCH /{uid}` to partially update a resource. +- `delete` generates `DELETE /{uid}` to delete a resource. + +Status operations: + +- Each of `update-status`, `status-update`, `put-status`, and `updatestatus` generates `PUT /{uid}/status` to replace the resource status. +- Each of `patch-status`, `status-patch`, and `patchstatus` generates `PATCH /{uid}/status` to partially update the resource status. + +Operation groups: + +- `read` enables `list` and `get`. +- `write` enables `create`, `update`, `patch`, and `delete`. +- `status` enables both status operations. +- `all` or `crud` enables the complete default surface: read, write, and status operations. + +Custom paths must be canonical absolute paths without surrounding whitespace, a trailing slash, empty segments, or `.` and `..` segments. Each segment may contain ASCII letters, digits, `.`, `_`, `~`, and `-`. Paths cannot collide with another resource's collection, item, or status routes, or with built-in endpoints such as `/health`, `/openapi.json`, and `/docs`. + +Storage can be disabled for generated handlers when the project supplies persistence through create-once persistence hooks. Generated handlers delegate resource access to hooks such as `ListResources`, `GetResource`, `SaveResource`, and `DeleteResource`. When storage generation is enabled, Fabrica creates default hook implementations backed by `internal/storage`; when storage generation is disabled, Fabrica creates stubs that compile but must be implemented by the project. + ## Initial workflow 1) `fabrica init [--group ] [--versions v1alpha1,v1]` @@ -56,7 +119,11 @@ Fields: - **Add a new version**: `fabrica add version v1beta2 [--from v1beta1]` copies types from the source spoke into `apis//v1beta2/` and appends the version to `apis.yaml`. - **Promote hub**: change `storageVersion` to the new hub, keep the old hub listed in `versions`, and add conversion logic between hub and spokes. - **Deprecate/remove**: remove the version from `versions` (and associated directories) once clients have migrated. -- **Partial features per version**: encode per-version status subresource or flags in future extensions of `apis.yaml`; avoid putting per-version knobs in `.fabrica.yaml`. +- **Partial generated operations**: use configured `resources` entries to limit generated routes, handlers, OpenAPI operations, client methods, CLI commands, and starter AuthZ policies for a resource. + +## Regeneration behavior + +Most generated files are overwritten by `fabrica generate`. A few starter files are intentionally create-once and safe to edit, including starter AuthZ policy files and per-resource persistence hook files. If you change resource paths or operations later, review those create-once files manually because Fabrica will not overwrite local edits. ## Command expectations diff --git a/internal/config/apis_config.go b/internal/config/apis_config.go index c96d021..0619b76 100644 --- a/internal/config/apis_config.go +++ b/internal/config/apis_config.go @@ -8,7 +8,10 @@ package config import ( "fmt" "os" + pathpkg "path" "path/filepath" + "regexp" + "strings" "gopkg.in/yaml.v3" ) @@ -35,13 +38,207 @@ type APIsConfig struct { // Currently, only a single API group per project is supported. Multiple // groups may be added in future versions. type APIGroup struct { - Name string `yaml:"name"` - StorageVersion string `yaml:"storageVersion"` - Versions []string `yaml:"versions"` - Resources []string `yaml:"resources,omitempty"` - Imports []APIImport `yaml:"imports,omitempty"` + Name string `yaml:"name"` + StorageVersion string `yaml:"storageVersion"` + Versions []string `yaml:"versions"` + Resources APIResources `yaml:"resources,omitempty"` + Imports []APIImport `yaml:"imports,omitempty"` } +// APIResource controls generated output for a resource listed in an API group. +type APIResource struct { + Name string `yaml:"-"` + Path string `yaml:"path,omitempty"` + Operations []string `yaml:"operations,omitempty"` +} + +// Configured reports whether this resource has explicit generation settings. +func (r APIResource) Configured() bool { + return r.Path != "" || len(r.Operations) > 0 +} + +// APIResources is the apis.yaml resource inventory. It accepts both the +// historical list form and the configured map form: +// +// resources: +// - Device +// +// resources: +// Device: +// path: /devices +// operations: [list, get] +type APIResources []APIResource + +// Names returns the configured resource names in declaration order. +func (r APIResources) Names() []string { + names := make([]string, 0, len(r)) + for _, resource := range r { + names = append(names, resource.Name) + } + + return names +} + +// Get returns a resource by name. +func (r APIResources) Get(name string) (APIResource, bool) { + for _, resource := range r { + if resource.Name == name { + return resource, true + } + } + + return APIResource{}, false +} + +// Contains reports whether a resource name is present. +func (r APIResources) Contains(name string) bool { + _, ok := r.Get(name) + return ok +} + +// UnmarshalYAML supports both list and map syntax for resources. +func (r *APIResources) UnmarshalYAML(value *yaml.Node) error { + var resources APIResources + switch value.Kind { + // List syntax: resources: [Device, Network] + case yaml.SequenceNode: + for _, item := range value.Content { + if item.Kind != yaml.ScalarNode { + return fmt.Errorf("resources list entries must be resource name strings; use map syntax for resource configuration") + } + resources = append(resources, APIResource{Name: item.Value}) + } + // Map syntax: resources: {Device: {path: /devices, operations: [list, get]}} + case yaml.MappingNode: + for i := 0; i < len(value.Content); i += 2 { + key := value.Content[i] + val := value.Content[i+1] + if err := validateResourceSettingsNode(key.Value, val); err != nil { + return err + } + var resource APIResource + if val.Kind != yaml.ScalarNode || val.Value != "" { + if err := val.Decode(&resource); err != nil { + return err + } + } + resource.Name = key.Value + resources = append(resources, resource) + } + default: + return fmt.Errorf("resources must be a list or map") + } + *r = resources + + return nil +} + +// validateResourceSettingsNode rejects unknown resource settings and +// distinguishes an omitted operations field, which selects Fabrica's defaults, +// from an explicitly empty operations list. +func validateResourceSettingsNode(resourceName string, value *yaml.Node) error { + if value.Kind != yaml.MappingNode { + return nil + } + + for i := 0; i < len(value.Content); i += 2 { + field := value.Content[i].Value + switch field { + case "path": + continue + case "operations": + var operations []string + if err := value.Content[i+1].Decode(&operations); err != nil { + return err + } + if len(operations) == 0 { + return fmt.Errorf("resources.%s.operations must contain at least one operation", resourceName) + } + continue + } + return fmt.Errorf("resources.%s contains unsupported field %q", resourceName, field) + } + + return nil +} + +// MarshalYAML writes compact list syntax when no resources have explicit +// configuration, and map syntax once path/operation settings are present. +func (r APIResources) MarshalYAML() (interface{}, error) { + hasConfig := false + for _, resource := range r { + if resource.Configured() { + hasConfig = true + break + } + } + if !hasConfig { + return r.Names(), nil + } + + node := &yaml.Node{Kind: yaml.MappingNode} + for _, resource := range r { + key := &yaml.Node{Kind: yaml.ScalarNode, Value: resource.Name} + value := &yaml.Node{Kind: yaml.MappingNode} + if resource.Path != "" { + value.Content = append(value.Content, + &yaml.Node{Kind: yaml.ScalarNode, Value: "path"}, + &yaml.Node{Kind: yaml.ScalarNode, Value: resource.Path}, + ) + } + if len(resource.Operations) > 0 { + operations := &yaml.Node{Kind: yaml.SequenceNode} + for _, operation := range resource.Operations { + operations.Content = append(operations.Content, &yaml.Node{Kind: yaml.ScalarNode, Value: operation}) + } + value.Content = append(value.Content, + &yaml.Node{Kind: yaml.ScalarNode, Value: "operations"}, + operations, + ) + } + node.Content = append(node.Content, key, value) + } + + return node, nil +} + +var validResourceOperations = map[string]struct{}{ + "list": {}, + "get": {}, + "create": {}, + "update": {}, + "put": {}, + "patch": {}, + "delete": {}, + "updatestatus": {}, + "update-status": {}, + "status-update": {}, + "put-status": {}, + "patchstatus": {}, + "patch-status": {}, + "status-patch": {}, + "read": {}, + "write": {}, + "status": {}, + "all": {}, + "crud": {}, +} + +const ( + builtInHealthPath = "/health" + builtInOpenAPIPath = "/openapi.json" + builtInDocsPath = "/docs" +) + +var ( + reservedResourcePaths = map[string]struct{}{ + builtInHealthPath: {}, + builtInOpenAPIPath: {}, + builtInDocsPath: {}, + } + resourcePathSegmentPattern = regexp.MustCompile(`^[A-Za-z0-9._~-]+$`) +) + // APIImport exposes external types for reuse in generated APIs. // This allows projects to import Spec and Status types from other Go modules // instead of defining them locally. Useful for shared type libraries or @@ -188,7 +385,7 @@ func DefaultAPIsConfig(group, storageVersion string, versions []string) *APIsCon Name: resolvedGroup, StorageVersion: resolvedStorage, Versions: resolvedVersions, - Resources: []string{}, + Resources: APIResources{}, }, }, } @@ -233,11 +430,98 @@ func (c *APIsConfig) Validate() error { if !found { return fmt.Errorf("storageVersion %s must appear in versions for group %s", g.StorageVersion, g.Name) } + + resources := make(map[string]struct{}, len(g.Resources)) + paths := make(map[string]string, len(g.Resources)) + for _, resource := range g.Resources { + name := strings.TrimSpace(resource.Name) + if name == "" { + return fmt.Errorf("apis.yaml group %s contains a resource with an empty name", g.Name) + } + if _, ok := resources[name]; ok { + return fmt.Errorf("apis.yaml group %s lists resource %s more than once", g.Name, name) + } + resources[name] = struct{}{} + path := resource.Path + if path == "" { + path = "/" + strings.ToLower(name) + "s" + } + normalizedPath, err := validateResourcePath(name, path) + if err != nil { + return err + } + if existing, ok := paths[normalizedPath]; ok { + return fmt.Errorf("resources.%s.path duplicates path %s already used by resource %s", name, normalizedPath, existing) + } + for existingPath, existingName := range paths { + if resourcePathsOverlap(existingPath, normalizedPath) { + return fmt.Errorf("resources.%s.path %s conflicts with generated routes for resource %s at %s", name, normalizedPath, existingName, existingPath) + } + } + paths[normalizedPath] = name + for _, operation := range resource.Operations { + if _, ok := validResourceOperations[strings.ToLower(strings.TrimSpace(operation))]; !ok { + return fmt.Errorf("resources.%s.operations contains unsupported operation %q", name, operation) + } + } + } } return nil } +func validateResourcePath(resourceName, path string) (string, error) { + if path == "" { + return "", fmt.Errorf("resources.%s.path must not be empty", resourceName) + } + if path != strings.TrimSpace(path) { + return "", fmt.Errorf("resources.%s.path must not contain surrounding whitespace", resourceName) + } + if !strings.HasPrefix(path, "/") { + return "", fmt.Errorf("resources.%s.path must start with /", resourceName) + } + if path == "/" { + return "", fmt.Errorf("resources.%s.path must not be /", resourceName) + } + if strings.HasSuffix(path, "/") { + return "", fmt.Errorf("resources.%s.path must not end with /", resourceName) + } + if strings.Contains(path, "//") { + return "", fmt.Errorf("resources.%s.path must not contain empty path segments", resourceName) + } + if pathpkg.Clean(path) != path { + return "", fmt.Errorf("resources.%s.path must not contain . or .. segments", resourceName) + } + for _, segment := range strings.Split(strings.TrimPrefix(path, "/"), "/") { + if !resourcePathSegmentPattern.MatchString(segment) { + return "", fmt.Errorf("resources.%s.path segment %q contains unsupported characters", resourceName, segment) + } + } + if _, ok := reservedResourcePaths[path]; ok { + return "", fmt.Errorf("resources.%s.path conflicts with built-in endpoint %s", resourceName, path) + } + if strings.HasPrefix(path, builtInDocsPath+"/") { + return "", fmt.Errorf("resources.%s.path conflicts with built-in endpoint %s", resourceName, builtInDocsPath) + } + return path, nil +} + +// resourcePathsOverlap reports whether either collection path can be consumed +// by the other resource's generated /{uid} or /{uid}/status routes. +func resourcePathsOverlap(first, second string) bool { + return matchesGeneratedResourceRoute(first, second) || matchesGeneratedResourceRoute(second, first) +} + +func matchesGeneratedResourceRoute(collectionPath, candidatePath string) bool { + prefix := collectionPath + "/" + if !strings.HasPrefix(candidatePath, prefix) { + return false + } + + segments := strings.Split(strings.TrimPrefix(candidatePath, prefix), "/") + return len(segments) == 1 || (len(segments) == 2 && segments[1] == "status") +} + // PrimaryGroup returns the first (and currently only supported) API group. // // Multiple API groups in a single project are planned for future versions, @@ -268,10 +552,8 @@ func (c *APIsConfig) AddResource(name string) { if err != nil { return } - for _, existing := range group.Resources { - if existing == name { - return - } + if group.Resources.Contains(name) { + return } - group.Resources = append(group.Resources, name) + group.Resources = append(group.Resources, APIResource{Name: name}) } diff --git a/internal/config/apis_config_test.go b/internal/config/apis_config_test.go new file mode 100644 index 0000000..9fc1f77 --- /dev/null +++ b/internal/config/apis_config_test.go @@ -0,0 +1,356 @@ +// Copyright © 2026 OpenCHAMI a Series of LF Projects, LLC +// +// SPDX-License-Identifier: MIT + +package config + +import ( + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +func TestAPIsConfigResourceValidation(t *testing.T) { + cfg := &APIsConfig{ + Groups: []APIGroup{ + { + Name: "example.openchami.io", + StorageVersion: "v1", + Versions: []string{"v1"}, + Resources: APIResources{ + { + Name: "Console", + Path: "/remote-console/consoles", + Operations: []string{"list"}, + }, + }, + }, + }, + } + + if err := cfg.Validate(); err != nil { + t.Fatalf("Validate() failed: %v", err) + } +} + +func TestAPIsConfigResourceRejectsUnknownOperation(t *testing.T) { + cfg := &APIsConfig{ + Groups: []APIGroup{ + { + Name: "example.openchami.io", + StorageVersion: "v1", + Versions: []string{"v1"}, + Resources: APIResources{ + { + Name: "Console", + Operations: []string{"explode"}, + }, + }, + }, + }, + } + + if err := cfg.Validate(); err == nil { + t.Fatalf("expected Validate() to reject unknown operation") + } +} + +func TestAPIsConfigResourceRejectsInvalidPath(t *testing.T) { + cfg := &APIsConfig{ + Groups: []APIGroup{ + { + Name: "example.openchami.io", + StorageVersion: "v1", + Versions: []string{"v1"}, + Resources: APIResources{ + { + Name: "Console", + Path: "remote-console/consoles", + }, + }, + }, + }, + } + + if err := cfg.Validate(); err == nil { + t.Fatalf("expected Validate() to reject path without leading slash") + } +} + +func TestAPIsConfigResourceRejectsNonCanonicalOrUnsafePath(t *testing.T) { + for name, resourcePath := range map[string]string{ + "surrounding whitespace": " /devices", + "trailing slash": "/devices/", + "empty segment": "/hardware//devices", + "current segment": "/hardware/./devices", + "parent segment": "/hardware/../devices", + "query": "/devices?active=true", + "fragment": "/devices#active", + "percent escape": "/hardware%2Fdevices", + "backslash": `/hardware\devices`, + } { + t.Run(name, func(t *testing.T) { + cfg := &APIsConfig{ + Groups: []APIGroup{ + { + Name: "example.openchami.io", + StorageVersion: "v1", + Versions: []string{"v1"}, + Resources: APIResources{ + {Name: "Device", Path: resourcePath}, + }, + }, + }, + } + + if err := cfg.Validate(); err == nil { + t.Fatalf("expected path %q to be rejected", resourcePath) + } + }) + } +} + +func TestAPIsConfigResourceAcceptsCanonicalPath(t *testing.T) { + cfg := &APIsConfig{ + Groups: []APIGroup{ + { + Name: "example.openchami.io", + StorageVersion: "v1", + Versions: []string{"v1"}, + Resources: APIResources{ + {Name: "Device", Path: "/hardware-v2/device_types/~active"}, + }, + }, + }, + } + + if err := cfg.Validate(); err != nil { + t.Fatalf("expected canonical path to be accepted: %v", err) + } +} + +func TestAPIsConfigResourceRejectsReservedPath(t *testing.T) { + cfg := &APIsConfig{ + Groups: []APIGroup{ + { + Name: "example.openchami.io", + StorageVersion: "v1", + Versions: []string{"v1"}, + Resources: APIResources{ + { + Name: "Console", + Path: "/openapi.json", + }, + }, + }, + }, + } + + if err := cfg.Validate(); err == nil { + t.Fatalf("expected Validate() to reject reserved path") + } +} + +func TestAPIsConfigResourceRejectsDuplicatePath(t *testing.T) { + cfg := &APIsConfig{ + Groups: []APIGroup{ + { + Name: "example.openchami.io", + StorageVersion: "v1", + Versions: []string{"v1"}, + Resources: APIResources{ + { + Name: "Console", + Path: "/resources", + }, + { + Name: "Device", + Path: "/resources", + }, + }, + }, + }, + } + + if err := cfg.Validate(); err == nil { + t.Fatalf("expected Validate() to reject duplicate path") + } +} + +func TestAPIsConfigResourceRejectsGeneratedRouteCollision(t *testing.T) { + for name, conflictingPath := range map[string]string{ + "item route": "/devices/metrics", + "status route": "/devices/example/status", + } { + t.Run(name, func(t *testing.T) { + cfg := &APIsConfig{ + Groups: []APIGroup{ + { + Name: "example.openchami.io", + StorageVersion: "v1", + Versions: []string{"v1"}, + Resources: APIResources{ + {Name: "Device", Path: "/devices"}, + {Name: "Metric", Path: conflictingPath}, + }, + }, + }, + } + + if err := cfg.Validate(); err == nil { + t.Fatalf("expected generated route collision for %q", conflictingPath) + } + }) + } +} + +func TestAPIsConfigResourcesAcceptsListSyntax(t *testing.T) { + var cfg APIsConfig + if err := yaml.Unmarshal([]byte(` +groups: + - name: example.openchami.io + storageVersion: v1 + versions: + - v1 + resources: + - Console + - Device +`), &cfg); err != nil { + t.Fatalf("Unmarshal() failed: %v", err) + } + + group := cfg.Groups[0] + if got, want := group.Resources.Names(), []string{"Console", "Device"}; len(got) != len(want) || got[0] != want[0] || got[1] != want[1] { + t.Fatalf("Resources.Names() = %v, want %v", got, want) + } +} + +func TestAPIsConfigResourcesRejectsConfiguredListEntries(t *testing.T) { + for name, input := range map[string]string{ + "single-entry map": ` +groups: + - name: example.openchami.io + storageVersion: v1 + versions: [v1] + resources: + - Console: + operations: [list] +`, + "named object": ` +groups: + - name: example.openchami.io + storageVersion: v1 + versions: [v1] + resources: + - name: Console + operations: [list] +`, + } { + t.Run(name, func(t *testing.T) { + var cfg APIsConfig + err := yaml.Unmarshal([]byte(input), &cfg) + if err == nil { + t.Fatal("expected configured list entry to be rejected") + } + if !strings.Contains(err.Error(), "use map syntax for resource configuration") { + t.Fatalf("unexpected error: %v", err) + } + }) + } +} + +func TestAPIsConfigResourcesAcceptsMapSyntax(t *testing.T) { + var cfg APIsConfig + if err := yaml.Unmarshal([]byte(` +groups: + - name: example.openchami.io + storageVersion: v1 + versions: + - v1 + resources: + Console: + path: /remote-console/consoles + operations: + - list + - get +`), &cfg); err != nil { + t.Fatalf("Unmarshal() failed: %v", err) + } + + resource, ok := cfg.Groups[0].Resources.Get("Console") + if !ok { + t.Fatalf("expected Console resource") + } + if resource.Path != "/remote-console/consoles" { + t.Fatalf("Path = %q, want %q", resource.Path, "/remote-console/consoles") + } + if got, want := resource.Operations, []string{"list", "get"}; len(got) != len(want) || got[0] != want[0] || got[1] != want[1] { + t.Fatalf("Operations = %v, want %v", got, want) + } +} + +func TestAPIsConfigResourcesRejectsEmptyOperations(t *testing.T) { + var cfg APIsConfig + err := yaml.Unmarshal([]byte(` +groups: + - name: example.openchami.io + storageVersion: v1 + versions: + - v1 + resources: + Console: + operations: [] +`), &cfg) + if err == nil { + t.Fatal("expected empty operations to be rejected") + } + if !strings.Contains(err.Error(), "resources.Console.operations must contain at least one operation") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestAPIsConfigResourcesAllowsOmittedOperations(t *testing.T) { + var cfg APIsConfig + if err := yaml.Unmarshal([]byte(` +groups: + - name: example.openchami.io + storageVersion: v1 + versions: + - v1 + resources: + Console: + path: /remote-console/consoles +`), &cfg); err != nil { + t.Fatalf("omitted operations should use defaults: %v", err) + } + + resource, ok := cfg.Groups[0].Resources.Get("Console") + if !ok { + t.Fatal("expected Console resource") + } + if resource.Operations != nil { + t.Fatalf("Operations = %v, want nil defaults", resource.Operations) + } +} + +func TestAPIsConfigResourcesRejectsUnknownField(t *testing.T) { + var cfg APIsConfig + err := yaml.Unmarshal([]byte(` +groups: + - name: example.openchami.io + storageVersion: v1 + versions: + - v1 + resources: + Console: + operatons: + - list +`), &cfg) + if err == nil { + t.Fatal("expected unknown resource field to be rejected") + } + if !strings.Contains(err.Error(), `resources.Console contains unsupported field "operatons"`) { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 2c393a0..7af6a12 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -173,7 +173,13 @@ func LoadConfig(dir string) (*FabricaConfig, error) { return nil, fmt.Errorf("failed to read %s: %w", ConfigFileName, err) } - var config FabricaConfig + config := FabricaConfig{ + Features: FeaturesConfig{ + Storage: StorageConfig{Enabled: true}, + }, + Generation: DefaultGenerationConfig(), + } + if err := yaml.Unmarshal(data, &config); err != nil { return nil, fmt.Errorf("failed to parse %s: %w", ConfigFileName, err) } @@ -263,32 +269,43 @@ func ValidateConfig(config *FabricaConfig) error { if config.Features.Security.AuthZ.Enabled && !config.Features.Security.AuthN.Enabled { return fmt.Errorf("features.security.authz.enabled requires features.security.authn.enabled") } - if !config.Features.Storage.Enabled { - return fmt.Errorf("features.storage.enabled must be true; generated CRUD APIs require storage") - } if config.Features.Reconciliation.Enabled && !config.Features.Events.Enabled { return fmt.Errorf("features.reconciliation.enabled requires features.events.enabled") } // Validate storage type - validTypes := map[string]bool{"file": true, "ent": true} - if !validTypes[config.Features.Storage.Type] { - return fmt.Errorf("invalid storage.type: %s (must be 'file' or 'ent')", - config.Features.Storage.Type) - } + if config.Features.Storage.Enabled { + validTypes := map[string]bool{"file": true, "ent": true} + if !validTypes[config.Features.Storage.Type] { + return fmt.Errorf("invalid storage.type: %s (must be 'file' or 'ent')", + config.Features.Storage.Type) + } - // Validate DB driver if using ent - if config.Features.Storage.Type == "ent" && config.Features.Storage.DBDriver != "" { - validDrivers := map[string]bool{"postgres": true, "mysql": true, "sqlite": true, "sqlite3": true} - if !validDrivers[config.Features.Storage.DBDriver] { - return fmt.Errorf("invalid storage.db_driver: %s (must be 'postgres', 'mysql', 'sqlite', or 'sqlite3')", - config.Features.Storage.DBDriver) + // Validate DB driver if using ent + if config.Features.Storage.Type == "ent" && config.Features.Storage.DBDriver != "" { + validDrivers := map[string]bool{"postgres": true, "mysql": true, "sqlite": true, "sqlite3": true} + if !validDrivers[config.Features.Storage.DBDriver] { + return fmt.Errorf("invalid storage.db_driver: %s (must be 'postgres', 'mysql', 'sqlite', or 'sqlite3')", + config.Features.Storage.DBDriver) + } } } return nil } +// DefaultGenerationConfig returns the default artifact generation settings. +func DefaultGenerationConfig() GenerationConfig { + return GenerationConfig{ + Handlers: true, + Storage: true, + Client: true, + OpenAPI: true, + Events: false, + Middleware: true, + } +} + // NewDefaultConfig creates a new configuration with sensible defaults. // This is used during project initialization to create a scaffold .fabrica.yaml. // @@ -339,14 +356,7 @@ func NewDefaultConfig(name, module string) *FabricaConfig { Enabled: false, }, }, - Generation: GenerationConfig{ - Handlers: true, - Storage: true, - Client: true, - OpenAPI: true, - Events: false, - Middleware: true, - }, + Generation: DefaultGenerationConfig(), } } diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..9aa9afa --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,126 @@ +// Copyright © 2026 OpenCHAMI a Series of LF Projects, LLC +// +// SPDX-License-Identifier: MIT + +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadConfigDefaultsMissingGeneration(t *testing.T) { + dir := t.TempDir() + content := []byte(` +project: + name: test + module: example.com/test +features: + validation: + enabled: true + mode: strict + events: + enabled: false + conditional: + enabled: true + auth: + enabled: false + storage: + enabled: true + type: file +`) + if err := os.WriteFile(filepath.Join(dir, ConfigFileName), content, 0644); err != nil { + t.Fatalf("WriteFile() failed: %v", err) + } + + cfg, err := LoadConfig(dir) + if err != nil { + t.Fatalf("LoadConfig() failed: %v", err) + } + + if !cfg.Generation.Handlers || !cfg.Generation.Storage || !cfg.Generation.Client || !cfg.Generation.OpenAPI || !cfg.Generation.Middleware { + t.Fatalf("Generation defaults not applied: %+v", cfg.Generation) + } + if cfg.Generation.Events || cfg.Generation.Reconciliation { + t.Fatalf("expected events and reconciliation generation to default off: %+v", cfg.Generation) + } +} + +func TestLoadConfigGenerationExplicitFalseOverridesDefault(t *testing.T) { + dir := t.TempDir() + content := []byte(` +project: + name: test + module: example.com/test +features: + storage: + enabled: true + type: file +generation: + handlers: false + storage: false + client: false + openapi: false + middleware: false +`) + if err := os.WriteFile(filepath.Join(dir, ConfigFileName), content, 0644); err != nil { + t.Fatalf("WriteFile() failed: %v", err) + } + + cfg, err := LoadConfig(dir) + if err != nil { + t.Fatalf("LoadConfig() failed: %v", err) + } + + if cfg.Generation.Handlers || cfg.Generation.Storage || cfg.Generation.Client || cfg.Generation.OpenAPI || cfg.Generation.Middleware { + t.Fatalf("explicit false generation settings were not preserved: %+v", cfg.Generation) + } +} + +func TestLoadConfigDefaultsMissingStorageEnabled(t *testing.T) { + dir := t.TempDir() + content := []byte(` +project: + name: test + module: example.com/test +features: + storage: + type: file +`) + if err := os.WriteFile(filepath.Join(dir, ConfigFileName), content, 0644); err != nil { + t.Fatalf("WriteFile() failed: %v", err) + } + + cfg, err := LoadConfig(dir) + if err != nil { + t.Fatalf("LoadConfig() failed: %v", err) + } + if !cfg.Features.Storage.Enabled { + t.Fatal("storage should default to enabled when features.storage.enabled is omitted") + } +} + +func TestLoadConfigStorageExplicitFalseOverridesDefault(t *testing.T) { + dir := t.TempDir() + content := []byte(` +project: + name: test + module: example.com/test +features: + storage: + enabled: false + type: file +`) + if err := os.WriteFile(filepath.Join(dir, ConfigFileName), content, 0644); err != nil { + t.Fatalf("WriteFile() failed: %v", err) + } + + cfg, err := LoadConfig(dir) + if err != nil { + t.Fatalf("LoadConfig() failed: %v", err) + } + if cfg.Features.Storage.Enabled { + t.Fatal("explicit features.storage.enabled=false should override the default") + } +} diff --git a/internal/mcp/cli_bridge.go b/internal/mcp/cli_bridge.go index 1ecfc5b..24bb9f1 100644 --- a/internal/mcp/cli_bridge.go +++ b/internal/mcp/cli_bridge.go @@ -78,7 +78,7 @@ func discoverResources(apisConfig *configpkg.APIsConfig) ([]string, error) { return resources, nil } } - return append([]string(nil), group.Resources...), nil + return group.Resources.Names(), nil } func fabricaBinary() (string, error) { diff --git a/internal/mcp/mcp_helpers.go b/internal/mcp/mcp_helpers.go index 9637d78..397d8ab 100644 --- a/internal/mcp/mcp_helpers.go +++ b/internal/mcp/mcp_helpers.go @@ -418,7 +418,7 @@ func resourceFileMap(projectDir string, group *configpkg.APIGroup) map[string]st return files } for _, resource := range group.Resources { - files[resource] = filepath.ToSlash(filepath.Join(projectDir, "apis", group.Name, group.StorageVersion, strings.ToLower(resource)+"_types.go")) + files[resource.Name] = filepath.ToSlash(filepath.Join(projectDir, "apis", group.Name, group.StorageVersion, strings.ToLower(resource.Name)+"_types.go")) } return files } diff --git a/internal/mcp/mcp_tools.go b/internal/mcp/mcp_tools.go index 99ebef0..bc4e44f 100644 --- a/internal/mcp/mcp_tools.go +++ b/internal/mcp/mcp_tools.go @@ -141,13 +141,14 @@ func (s *mcpServer) toolValidateProject(args map[string]interface{}) (map[string storageResources[name] = struct{}{} } - for _, resource := range group.Resources { + resourceNames := group.Resources.Names() + for _, resource := range resourceNames { if _, ok := storageResources[resource]; !ok { addIssue("warning", "resource_list_drift", fmt.Sprintf("resource %s listed in apis.yaml but not found in storage version dir", resource)) } } for _, resource := range storageResourceFiles { - if !contains(group.Resources, resource) { + if !contains(resourceNames, resource) { addIssue("warning", "resource_list_drift", fmt.Sprintf("resource %s exists in storage version dir but is missing from apis.yaml resources", resource)) } } diff --git a/pkg/codegen/generator.go b/pkg/codegen/generator.go index 4b2fbf4..b9c4c3c 100644 --- a/pkg/codegen/generator.go +++ b/pkg/codegen/generator.go @@ -47,10 +47,10 @@ import ( "text/template" "time" + configpkg "github.com/openchami/fabrica/internal/config" "github.com/openchami/fabrica/internal/constants" "golang.org/x/text/cases" "golang.org/x/text/language" - "gopkg.in/yaml.v3" ) //go:embed templates/** @@ -84,19 +84,32 @@ type SpecField struct { ExampleValue string // Example value for documentation } +// ResourceOperations controls which REST operations are generated for a resource. +type ResourceOperations struct { + List bool + Get bool + Create bool + Update bool + Patch bool + Delete bool + UpdateStatus bool + PatchStatus bool +} + // ResourceMetadata holds metadata about a resource type for code generation type ResourceMetadata struct { - Name string // e.g., "User" - PluralName string // e.g., "users" - Package string // e.g., "github.com/example/app/pkg/resources/user" - PackageAlias string // e.g., "user" - TypeName string // e.g., "*user.User" - SpecType string // e.g., "user.UserSpec" - StatusType string // e.g., "user.UserStatus" - URLPath string // e.g., "/users" - StorageName string // e.g., "User" for storage function names - Tags map[string]string // Additional metadata - SpecFields []SpecField // Fields in the Spec struct + Name string // e.g., "User" + PluralName string // e.g., "users" + Package string // e.g., "github.com/example/app/pkg/resources/user" + PackageAlias string // e.g., "user" + TypeName string // e.g., "*user.User" + SpecType string // e.g., "user.UserSpec" + StatusType string // e.g., "user.UserStatus" + URLPath string // e.g., "/users" + StorageName string // e.g., "User" for storage function names + Tags map[string]string // Additional metadata + Operations ResourceOperations // Which REST operations to generate + SpecFields []SpecField // Fields in the Spec struct // Multi-version support Versions []SchemaVersion // Multiple schema versions @@ -104,6 +117,75 @@ type ResourceMetadata struct { APIGroupVersion string // API group version (e.g., "v2") } +// NeedsPersistenceHooks reports whether generated handlers require a +// user-editable persistence hook file. Hook files are generated only when absent. +func (ops ResourceOperations) NeedsPersistenceHooks() bool { + return ops.List || ops.Get || ops.Create || ops.Update || ops.Patch || ops.Delete || ops.UpdateStatus || ops.PatchStatus +} + +// ResourceConfig controls generation details for a registered resource. +type ResourceConfig struct { + URLPath string + Operations ResourceOperations +} + +// DefaultResourceOperations returns default CRUD plus status surface. +func DefaultResourceOperations() ResourceOperations { + return ResourceOperations{ + List: true, + Get: true, + Create: true, + Update: true, + Patch: true, + Delete: true, + UpdateStatus: true, + PatchStatus: true, + } +} + +// ResourceOperationsFromNames builds operation flags from a list of operation names. +func ResourceOperationsFromNames(names []string) ResourceOperations { + if len(names) == 0 { + return DefaultResourceOperations() + } + + var ops ResourceOperations + for _, name := range names { + switch strings.ToLower(strings.TrimSpace(name)) { + case "list": + ops.List = true + case "get": + ops.Get = true + case "create": + ops.Create = true + case "update", "put": + ops.Update = true + case "patch": + ops.Patch = true + case "delete": + ops.Delete = true + case "updatestatus", "update-status", "status-update", "put-status": + ops.UpdateStatus = true + case "patchstatus", "patch-status", "status-patch": + ops.PatchStatus = true + case "read": + ops.List = true + ops.Get = true + case "write": + ops.Create = true + ops.Update = true + ops.Patch = true + ops.Delete = true + case "status": + ops.UpdateStatus = true + ops.PatchStatus = true + case "all", "crud": + ops = DefaultResourceOperations() + } + } + return ops +} + // GeneratorConfig holds configuration values for code generation // These values are passed to templates and affect what code is generated type GeneratorConfig struct { @@ -124,8 +206,9 @@ type GeneratorConfig struct { EventBusType string // memory, nats, kafka // Storage configuration - StorageType string // file, ent - DBDriver string // postgres, mysql, sqlite + StorageEnabled bool // Whether to generate storage code + StorageType string // file, ent + DBDriver string // postgres, mysql, sqlite // TokenSmith-first Security generation toggles. // @@ -170,6 +253,7 @@ func NewGenerator(outputDir, packageName, modulePath string) *Generator { VersionStrategy: "header", EventsEnabled: false, EventBusType: "memory", + StorageEnabled: true, StorageType: "file", DBDriver: "sqlite", WithAuth: false, @@ -325,6 +409,7 @@ func (g *Generator) templateData(resource ResourceMetadata, templateName string) "URLPath": resource.URLPath, "StorageName": resource.StorageName, "Tags": resource.Tags, + "Operations": resource.Operations, "PerResourceVersioning": perResVersioning, "IsVersioned": isVersioned, "SpecFields": resource.SpecFields, @@ -333,6 +418,7 @@ func (g *Generator) templateData(resource ResourceMetadata, templateName string) "APIGroupVersion": resource.APIGroupVersion, "UniqueImports": uniqueImports, "ModulePath": g.ModulePath, + "StorageEnabled": g.Config.StorageEnabled, }) } @@ -362,6 +448,7 @@ func (g *Generator) globalTemplateData(templateName string) map[string]interface "UniqueImports": uniqueImports, "ProjectName": g.extractProjectName(), "StorageType": g.StorageType, + "StorageEnabled": g.Config.StorageEnabled, "DBDriver": g.DBDriver, "Config": g.Config, "WithAuth": g.Config.WithAuth, @@ -438,6 +525,7 @@ func (g *Generator) RegisterResource(resourceType interface{}) error { URLPath: fmt.Sprintf("/%s", pluralName), StorageName: storageName, Tags: make(map[string]string), + Operations: DefaultResourceOperations(), SpecFields: specFields, Versions: []SchemaVersion{defaultVersion}, DefaultVersion: "v1", @@ -448,6 +536,22 @@ func (g *Generator) RegisterResource(resourceType interface{}) error { return nil } +// ConfigureResource applies generation configuration to a registered resource. +func (g *Generator) ConfigureResource(resourceName string, config ResourceConfig) { + for i := range g.Resources { + if g.Resources[i].Name != resourceName { + continue + } + if config.URLPath != "" { + g.Resources[i].URLPath = config.URLPath + } + if config.Operations != (ResourceOperations{}) { + g.Resources[i].Operations = config.Operations + } + return + } +} + // SetResourceTag sets a tag key/value on a registered resource by name. // If the resource isn't found, this is a no-op. func (g *Generator) SetResourceTag(resourceName, key, value string) { @@ -857,6 +961,7 @@ func (g *Generator) LoadTemplates() error { templateFiles := map[string]string{ // Server templates "handlers": "server/handlers.go.tmpl", + "persistenceHooks": "server/persistence_hooks.go.tmpl", "routes": "server/routes.go.tmpl", "models": "server/models.go.tmpl", "openapi": "server/openapi.go.tmpl", @@ -951,6 +1056,28 @@ func (g *Generator) GenerateHandlers() error { if written { fmt.Printf(" ✓ Generated %s\n", filename) } + + if resource.Operations.NeedsPersistenceHooks() { + var hookBuf bytes.Buffer + hookData := g.templateData(resource, "server/persistence_hooks.go.tmpl") + if err := g.Templates["persistenceHooks"].Execute(&hookBuf, hookData); err != nil { + return fmt.Errorf("failed to execute persistence hook template for %s: %w", resource.Name, err) + } + + hookFormatted, err := format.Source(hookBuf.Bytes()) + if err != nil { + return fmt.Errorf("failed to format generated persistence hook for %s: %w", resource.Name, err) + } + + hookFilename := filepath.Join(g.OutputDir, fmt.Sprintf("%s_persistence_hooks.go", strings.ToLower(resource.Name))) + if _, err := os.Stat(hookFilename); os.IsNotExist(err) { + if _, err := writeGeneratedFile(hookFilename, hookFormatted); err != nil { + return fmt.Errorf("failed to write persistence hook file for %s: %w", resource.Name, err) + } + } else if err != nil { + return fmt.Errorf("failed to inspect persistence hook file for %s: %w", resource.Name, err) + } + } } return nil @@ -1437,26 +1564,9 @@ func (g *Generator) GenerateAPIVersions() error { return nil // Optional feature - skip if not configured } - // Parse apis.yaml for groups and versions - data, err := os.ReadFile("apis.yaml") + cfg, err := configpkg.LoadAPIsConfig("") if err != nil { - return fmt.Errorf("failed to read apis.yaml: %w", err) - } - - // Minimal schema for apis.yaml used by codegen - type apisGroup struct { - Name string `yaml:"name"` - StorageVersion string `yaml:"storageVersion"` - Versions []string `yaml:"versions"` - Resources []string `yaml:"resources"` - } - type apisConfig struct { - Groups []apisGroup `yaml:"groups"` - } - - var cfg apisConfig - if err := yaml.Unmarshal(data, &cfg); err != nil { - return fmt.Errorf("failed to parse apis.yaml: %w", err) + return err } if len(cfg.Groups) == 0 { @@ -1478,7 +1588,7 @@ func (g *Generator) GenerateAPIVersions() error { Name: g.Name, StorageVersion: g.StorageVersion, Spokes: g.Versions, - Resources: g.Resources, + Resources: g.Resources.Names(), }) } @@ -1616,4 +1726,29 @@ var templateFuncs = template.FuncMap{ } return "{\n" + strings.Join(parts, ",\n") + "\n }" }, + "operationList": func(ops ResourceOperations) string { + var names []string + if ops.List { + names = append(names, "list") + } + if ops.Get { + names = append(names, "get") + } + if ops.Create { + names = append(names, "create") + } + if ops.Update { + names = append(names, "update") + } + if ops.Patch { + names = append(names, "patch") + } + if ops.Delete { + names = append(names, "delete") + } + if len(names) == 0 { + return "none" + } + return strings.Join(names, "|") + }, } diff --git a/pkg/codegen/generator_test.go b/pkg/codegen/generator_test.go index 4f2121e..4de23b8 100644 --- a/pkg/codegen/generator_test.go +++ b/pkg/codegen/generator_test.go @@ -51,6 +51,187 @@ func TestGlobalAndMiddlewareTemplateDataIncludeCopyrightYear(t *testing.T) { } } +func TestStorageDisabledHandlersUsePersistenceHooks(t *testing.T) { + gen := NewGenerator(t.TempDir(), "main", "example.com/test") + gen.Version = "test" + gen.Config.StorageEnabled = false + if err := gen.LoadTemplates(); err != nil { + t.Fatalf("LoadTemplates: %v", err) + } + + resource := ResourceMetadata{ + Name: "Node", + PluralName: "nodes", + Package: "example.com/test/pkg/resources/node", + PackageAlias: "node", + TypeName: "*node.Node", + SpecType: "node.NodeSpec", + StatusType: "node.NodeStatus", + URLPath: "/nodes", + StorageName: "Node", + Operations: ResourceOperations{ + Get: true, + Create: true, + Delete: true, + }, + } + + data := gen.templateData(resource, "server/handlers.go.tmpl") + var handlers bytes.Buffer + if err := gen.Templates["handlers"].Execute(&handlers, data); err != nil { + t.Fatalf("execute handlers template: %v", err) + } + gotHandlers := handlers.String() + if strings.Contains(gotHandlers, "internal/storage") { + t.Fatalf("storage-disabled handlers should not import generated storage:\n%s", gotHandlers) + } + wantHandlerSummary := `// Generated handlers provide: +// - GET /nodes/{uid} (get specific Node) +// - POST /nodes (create new Node) +// - DELETE /nodes/{uid} (delete Node)` + if !strings.Contains(gotHandlers, wantHandlerSummary) { + t.Fatalf("generated handler summary is not compact and readable:\n%s", gotHandlers) + } + for _, want := range []string{"GetNodeResource(r.Context(), uid)", "SaveNodeResource(r.Context(), node)", "DeleteNodeResource(r.Context(), uid)"} { + if !strings.Contains(gotHandlers, want) { + t.Fatalf("handlers missing %q:\n%s", want, gotHandlers) + } + } + + gen.Resources = []ResourceMetadata{resource} + var routes bytes.Buffer + if err := gen.Templates["routes"].Execute(&routes, gen.globalTemplateData("server/routes.go.tmpl")); err != nil { + t.Fatalf("execute routes template: %v", err) + } + wantRouteSummary := `// Route patterns: +// - GET /nodes/{uid} -> Get specific Node +// - POST /nodes -> Create new Node +// - DELETE /nodes/{uid} -> Delete Node` + if !strings.Contains(routes.String(), wantRouteSummary) { + t.Fatalf("generated route summary is not compact and readable:\n%s", routes.String()) + } + var openAPI bytes.Buffer + if err := gen.Templates["openapi"].Execute(&openAPI, gen.globalTemplateData("server/openapi.go.tmpl")); err != nil { + t.Fatalf("execute OpenAPI template: %v", err) + } + + var hooks bytes.Buffer + if err := gen.Templates["persistenceHooks"].Execute(&hooks, data); err != nil { + t.Fatalf("execute persistence hooks template: %v", err) + } + gotHooks := hooks.String() + if strings.Contains(gotHooks, "internal/storage") { + t.Fatalf("storage-disabled hooks should not import generated storage:\n%s", gotHooks) + } + for _, want := range []string{ + "GetNodeResource must be implemented when storage generation is disabled", + "SaveNodeResource must be implemented when storage generation is disabled", + "DeleteNodeResource must be implemented when storage generation is disabled", + } { + if !strings.Contains(gotHooks, want) { + t.Fatalf("hooks missing %q:\n%s", want, gotHooks) + } + } +} + +func TestStorageEnabledPersistenceHooksUseGeneratedStorage(t *testing.T) { + gen := NewGenerator(t.TempDir(), "main", "example.com/test") + gen.Version = "test" + if err := gen.LoadTemplates(); err != nil { + t.Fatalf("LoadTemplates: %v", err) + } + + resource := ResourceMetadata{ + Name: "Node", + PluralName: "nodes", + Package: "example.com/test/pkg/resources/node", + PackageAlias: "node", + TypeName: "*node.Node", + SpecType: "node.NodeSpec", + StatusType: "node.NodeStatus", + URLPath: "/nodes", + StorageName: "Node", + Operations: ResourceOperations{ + List: true, + Create: true, + }, + } + + var hooks bytes.Buffer + if err := gen.Templates["persistenceHooks"].Execute( + &hooks, + gen.templateData(resource, "server/persistence_hooks.go.tmpl"), + ); err != nil { + t.Fatalf("execute persistence hooks template: %v", err) + } + + got := hooks.String() + for _, want := range []string{ + `"example.com/test/internal/storage"`, + "return storage.LoadAllNodes(ctx)", + "return storage.SaveNode(ctx, resource)", + } { + if !strings.Contains(got, want) { + t.Fatalf("storage-enabled persistence hooks missing %q:\n%s", want, got) + } + } + if strings.Contains(got, "must be implemented when storage generation is disabled") { + t.Fatalf("storage-enabled persistence hooks contain disabled-storage stubs:\n%s", got) + } +} + +func TestPersistenceHooksOnlyWhenAbsent(t *testing.T) { + outputDir := t.TempDir() + gen := NewGenerator(outputDir, "main", "example.com/test") + gen.Version = "test" + gen.Config.StorageEnabled = false + gen.Resources = []ResourceMetadata{ + { + Name: "Node", + PluralName: "nodes", + Package: "example.com/test/pkg/resources/node", + PackageAlias: "node", + TypeName: "*node.Node", + SpecType: "node.NodeSpec", + StatusType: "node.NodeStatus", + URLPath: "/nodes", + StorageName: "Node", + Operations: ResourceOperations{List: true}, + }, + } + if err := gen.LoadTemplates(); err != nil { + t.Fatalf("LoadTemplates: %v", err) + } + if err := gen.GenerateHandlers(); err != nil { + t.Fatalf("GenerateHandlers: %v", err) + } + + hookPath := filepath.Join(outputDir, "node_persistence_hooks.go") + generated, err := os.ReadFile(hookPath) + if err != nil { + t.Fatalf("read persistence hook file: %v", err) + } + if !strings.Contains(string(generated), "func ListNodeResources") { + t.Fatalf("persistence hook file missing list hook:\n%s", generated) + } + + customized := append(generated, []byte("\n// project customization\n")...) + if err := os.WriteFile(hookPath, customized, 0644); err != nil { + t.Fatalf("customize persistence hook file: %v", err) + } + if err := gen.GenerateHandlers(); err != nil { + t.Fatalf("regenerate handlers: %v", err) + } + + afterRegeneration, err := os.ReadFile(hookPath) + if err != nil { + t.Fatalf("read persistence hook file after regeneration: %v", err) + } + if !bytes.Equal(afterRegeneration, customized) { + t.Fatal("regeneration overwrote the project-owned persistence hook file") + } +} + func TestExecuteTemplateBackfillsCommonMetadata(t *testing.T) { gen := NewGenerator(t.TempDir(), "main", "example.com/test") gen.Version = "test" diff --git a/pkg/codegen/resource_operations_test.go b/pkg/codegen/resource_operations_test.go new file mode 100644 index 0000000..5a30996 --- /dev/null +++ b/pkg/codegen/resource_operations_test.go @@ -0,0 +1,41 @@ +// Copyright © 2026 OpenCHAMI a Series of LF Projects, LLC +// +// SPDX-License-Identifier: MIT + +package codegen + +import "testing" + +func TestResourceOperationsFromNames(t *testing.T) { + ops := ResourceOperationsFromNames([]string{"list", "update-status"}) + if !ops.List || !ops.UpdateStatus { + t.Fatalf("expected list and update-status operations to be enabled: %#v", ops) + } + if ops.Get || ops.Create || ops.Patch || ops.Delete || ops.PatchStatus { + t.Fatalf("unexpected operations enabled: %#v", ops) + } +} + +func TestResourceOperationsFromNamesDefault(t *testing.T) { + ops := ResourceOperationsFromNames(nil) + if !ops.List || !ops.Get || !ops.Create || !ops.Update || !ops.Patch || !ops.Delete || !ops.UpdateStatus || !ops.PatchStatus { + t.Fatalf("expected default operations to enable full CRUD plus status: %#v", ops) + } +} + +func TestOperationListIncludesOnlyGeneratedCLICommands(t *testing.T) { + operationList := templateFuncs["operationList"].(func(ResourceOperations) string) + got := operationList(ResourceOperations{List: true, UpdateStatus: true, PatchStatus: true}) + want := "list" + if got != want { + t.Fatalf("operationList() = %q, want %q", got, want) + } +} + +func TestOperationListReportsNoneForStatusOnlyResource(t *testing.T) { + operationList := templateFuncs["operationList"].(func(ResourceOperations) string) + got := operationList(ResourceOperations{UpdateStatus: true, PatchStatus: true}) + if got != "none" { + t.Fatalf("operationList() = %q, want none", got) + } +} diff --git a/pkg/codegen/templates/authz/policy.csv.tmpl b/pkg/codegen/templates/authz/policy.csv.tmpl index 39d7db6..9b0c923 100644 --- a/pkg/codegen/templates/authz/policy.csv.tmpl +++ b/pkg/codegen/templates/authz/policy.csv.tmpl @@ -6,24 +6,58 @@ # form role:. Start simple, then tailor these tuples for your deployment. {{range .Resources}}# {{.Name}} +{{- if .Operations.List }} p, role:viewer, {{.URLPath}}, GET +{{- end }} +{{- if .Operations.Get }} p, role:viewer, {{.URLPath}}/{uid}, GET +{{- end }} +{{- if .Operations.List }} p, role:editor, {{.URLPath}}, GET +{{- end }} +{{- if .Operations.Get }} p, role:editor, {{.URLPath}}/{uid}, GET +{{- end }} +{{- if .Operations.Create }} p, role:editor, {{.URLPath}}, POST +{{- end }} +{{- if .Operations.Update }} p, role:editor, {{.URLPath}}/{uid}, PUT +{{- end }} +{{- if .Operations.Patch }} p, role:editor, {{.URLPath}}/{uid}, PATCH +{{- end }} +{{- if .Operations.UpdateStatus }} p, role:editor, {{.URLPath}}/{uid}/status, PUT +{{- end }} +{{- if .Operations.PatchStatus }} p, role:editor, {{.URLPath}}/{uid}/status, PATCH +{{- end }} +{{- if .Operations.List }} p, role:admin, {{.URLPath}}, GET +{{- end }} +{{- if .Operations.Get }} p, role:admin, {{.URLPath}}/{uid}, GET +{{- end }} +{{- if .Operations.Create }} p, role:admin, {{.URLPath}}, POST +{{- end }} +{{- if .Operations.Update }} p, role:admin, {{.URLPath}}/{uid}, PUT +{{- end }} +{{- if .Operations.Patch }} p, role:admin, {{.URLPath}}/{uid}, PATCH +{{- end }} +{{- if .Operations.Delete }} p, role:admin, {{.URLPath}}/{uid}, DELETE +{{- end }} +{{- if .Operations.UpdateStatus }} p, role:admin, {{.URLPath}}/{uid}/status, PUT +{{- end }} +{{- if .Operations.PatchStatus }} p, role:admin, {{.URLPath}}/{uid}/status, PATCH +{{- end }} {{end}}# Add deployment-specific tuples below. diff --git a/pkg/codegen/templates/client/client.go.tmpl b/pkg/codegen/templates/client/client.go.tmpl index e75b25a..b53a630 100644 --- a/pkg/codegen/templates/client/client.go.tmpl +++ b/pkg/codegen/templates/client/client.go.tmpl @@ -335,7 +335,7 @@ type {{.Name}}VersionSnapshot struct { } {{- end}}{{- end}} -// Get{{.Name}}s retrieves all {{.PluralName}} +{{if .Operations.List}}// Get{{.Name}}s retrieves all {{.PluralName}} func (c *Client) Get{{.Name}}s(ctx context.Context) ([]{{.PackageAlias}}.{{.Name}}, error) { var response []{{.PackageAlias}}.{{.Name}} if err := c.doRequest(ctx, "GET", "{{.URLPath}}", nil, &response); err != nil { @@ -343,8 +343,9 @@ func (c *Client) Get{{.Name}}s(ctx context.Context) ([]{{.PackageAlias}}.{{.Name } return response, nil } +{{end}} -// Get{{.Name}} retrieves a specific {{.Name}} by UID +{{if .Operations.Get}}// Get{{.Name}} retrieves a specific {{.Name}} by UID func (c *Client) Get{{.Name}}(ctx context.Context, uid string) ({{.TypeName}}, error) { var result {{.PackageAlias}}.{{.Name}} endpoint := fmt.Sprintf("{{.URLPath}}/%s", uid) @@ -353,7 +354,9 @@ func (c *Client) Get{{.Name}}(ctx context.Context, uid string) ({{.TypeName}}, e } return &result, nil } +{{end}} +{{if .Operations.Create}} // Create{{.Name}}Simple creates a new {{.Name}} with minimal boilerplate (Simple API) // // This is the recommended method for most use cases. It only requires a name and spec, @@ -405,7 +408,9 @@ func (c *Client) Create{{.Name}}(ctx context.Context, req Create{{.Name}}Request } return &result, nil } +{{end}} +{{if .Operations.Update}} // Update{{.Name}}Simple updates an existing {{.Name}}'s spec (Simple API) // // This is the recommended method for most update operations. It only requires the UID @@ -454,8 +459,9 @@ func (c *Client) Update{{.Name}}(ctx context.Context, uid string, req Update{{.N } return &result, nil } +{{end}} -// Patch{{.Name}} patches an existing {{.Name}} spec with the specified patch data and content type +{{if .Operations.Patch}}// Patch{{.Name}} patches an existing {{.Name}} spec with the specified patch data and content type func (c *Client) Patch{{.Name}}(ctx context.Context, uid string, patchData []byte, contentType string) ({{.TypeName}}, error) { var result {{.PackageAlias}}.{{.Name}} endpoint := fmt.Sprintf("{{.URLPath}}/%s", uid) @@ -464,8 +470,9 @@ func (c *Client) Patch{{.Name}}(ctx context.Context, uid string, patchData []byt } return &result, nil } +{{end}} -// Update{{.Name}}Status updates only the status of an existing {{.Name}} +{{if .Operations.UpdateStatus}}// Update{{.Name}}Status updates only the status of an existing {{.Name}} // This method is intended for controllers, reconcilers, and monitoring systems. // It preserves the spec and only updates the status portion of the resource. func (c *Client) Update{{.Name}}Status(ctx context.Context, uid string, status {{.PackageAlias}}.{{.Name}}Status) ({{.TypeName}}, error) { @@ -476,8 +483,9 @@ func (c *Client) Update{{.Name}}Status(ctx context.Context, uid string, status { } return &result, nil } +{{end}} -// Patch{{.Name}}Status patches only the status of an existing {{.Name}} +{{if .Operations.PatchStatus}}// Patch{{.Name}}Status patches only the status of an existing {{.Name}} // Supports JSON Merge Patch by default. Use Patch{{.Name}}StatusWithType for other patch formats. func (c *Client) Patch{{.Name}}Status(ctx context.Context, uid string, patchData []byte) ({{.TypeName}}, error) { return c.Patch{{.Name}}StatusWithType(ctx, uid, patchData, "application/merge-patch+json") @@ -493,8 +501,9 @@ func (c *Client) Patch{{.Name}}StatusWithType(ctx context.Context, uid string, p } return &result, nil } +{{end}} -// Delete{{.Name}} deletes a {{.Name}} by UID +{{if .Operations.Delete}}// Delete{{.Name}} deletes a {{.Name}} by UID func (c *Client) Delete{{.Name}}(ctx context.Context, uid string) error { endpoint := fmt.Sprintf("{{.URLPath}}/%s", uid) var response DeleteResponse @@ -503,6 +512,7 @@ func (c *Client) Delete{{.Name}}(ctx context.Context, uid string) error { } return nil } +{{end}} {{end}} diff --git a/pkg/codegen/templates/client/cmd.go.tmpl b/pkg/codegen/templates/client/cmd.go.tmpl index 969d766..18ce2b3 100644 --- a/pkg/codegen/templates/client/cmd.go.tmpl +++ b/pkg/codegen/templates/client/cmd.go.tmpl @@ -13,7 +13,7 @@ // // Generated commands for each resource: // - client health -{{range .Resources}}// - client {{toLower .Name}} [list|get|create|update|patch|delete] +{{range .Resources}}// - client {{toLower .Name}} [{{operationList .Operations}}] {{end}}// // Global flags (available for all commands): // --server Server URL (env: {{toUpper .ProjectName}}_SERVER) @@ -36,15 +36,6 @@ // # List {{(index .Resources 0).PluralName}} with specific version // client {{toLower (index .Resources 0).Name}} list --version v2beta1 // -// # Get {{(index .Resources 0).Name}} as v1 -// client {{toLower (index .Resources 0).Name}} get --version v1 -// -// # Create from JSON file with version -// cat {{toLower (index .Resources 0).Name}}.json | client {{toLower (index .Resources 0).Name}} create --version v2beta1 -// -// # Create from inline JSON -// client {{toLower (index .Resources 0).Name}} create --spec '{"name":"{{toLower (index .Resources 0).Name}}-01","description":"Example {{(index .Resources 0).Name}}"}' -// // # Use environment variables // export {{toUpper .ProjectName}}_SERVER=https://{{.ProjectName}}.example.com // export {{toUpper .ProjectName}}_VERSION=v2beta1 @@ -264,9 +255,10 @@ func setNestedField(target map[string]interface{}, path string, value interface{ var {{toLower .Name}}Cmd = &cobra.Command{ Use: "{{toLower .Name}}", Short: "Manage {{.PluralName}}", - Long: `Create, read, update, patch, and delete {{.PluralName}}.`, + Long: `Manage {{.PluralName}}.`, } +{{if .Operations.List}} var {{toLower .Name}}ListCmd = &cobra.Command{ Use: "list", Short: "List all {{.PluralName}}", @@ -287,7 +279,9 @@ var {{toLower .Name}}ListCmd = &cobra.Command{ return printOutput(items) }, } +{{end}} +{{if .Operations.Get}} var {{toLower .Name}}GetCmd = &cobra.Command{ Use: "get [uid]", Short: "Get a {{.Name}} by UID", @@ -309,7 +303,9 @@ var {{toLower .Name}}GetCmd = &cobra.Command{ return printOutput(item) }, } +{{end}} +{{if .Operations.Create}} var {{toLower .Name}}CreateCmd = &cobra.Command{ Use: "create", Short: "Create a new {{.Name}}", @@ -359,7 +355,9 @@ Spec fields: return printOutput(item) }, } +{{end}} +{{if .Operations.Update}} var {{toLower .Name}}UpdateCmd = &cobra.Command{ Use: "update [uid]", Short: "Update an existing {{.Name}}", @@ -410,7 +408,9 @@ Spec fields: return printOutput(item) }, } +{{end}} +{{if .Operations.Patch}} var {{toLower .Name}}PatchCmd = &cobra.Command{ Use: "patch [uid]", Short: "Patch a {{.Name}}", @@ -550,7 +550,9 @@ Attempts to patch metadata or status fields will be ignored.`, return printOutput(item) }, } +{{end}} +{{if .Operations.Delete}} var {{toLower .Name}}DeleteCmd = &cobra.Command{ Use: "delete [uid]", Short: "Delete a {{.Name}}", @@ -572,6 +574,7 @@ var {{toLower .Name}}DeleteCmd = &cobra.Command{ return nil }, } +{{end}} {{- if .Tags}}{{- if eq (index .Tags "versioning") "enabled"}} // Versions subcommands for {{.Name}} @@ -621,12 +624,24 @@ var {{toLower .Name}}VersionsDeleteCmd = &cobra.Command{ {{- end}}{{- end}} func init() { + {{- if .Operations.List }} {{toLower .Name}}Cmd.AddCommand({{toLower .Name}}ListCmd) + {{- end }} + {{- if .Operations.Get }} {{toLower .Name}}Cmd.AddCommand({{toLower .Name}}GetCmd) + {{- end }} + {{- if .Operations.Create }} {{toLower .Name}}Cmd.AddCommand({{toLower .Name}}CreateCmd) + {{- end }} + {{- if .Operations.Update }} {{toLower .Name}}Cmd.AddCommand({{toLower .Name}}UpdateCmd) + {{- end }} + {{- if .Operations.Patch }} {{toLower .Name}}Cmd.AddCommand({{toLower .Name}}PatchCmd) + {{- end }} + {{- if .Operations.Delete }} {{toLower .Name}}Cmd.AddCommand({{toLower .Name}}DeleteCmd) + {{- end }} {{- if .Tags}}{{- if eq (index .Tags "versioning") "enabled"}} // Versions subcommands @@ -637,9 +652,14 @@ func init() { {{- end}}{{- end}} // Add spec flag for create and update commands + {{- if .Operations.Create }} {{toLower .Name}}CreateCmd.Flags().String("spec", "", "{{.Name}} specification in JSON format") + {{- end }} + {{- if .Operations.Update }} {{toLower .Name}}UpdateCmd.Flags().String("spec", "", "{{.Name}} specification in JSON format") + {{- end }} + {{- if .Operations.Patch }} // Add patch command flags {{toLower .Name}}PatchCmd.Flags().String("spec", "", "JSON Merge Patch specification") {{toLower .Name}}PatchCmd.Flags().String("json-patch", "", "JSON Patch operations (RFC 6902)") @@ -647,6 +667,7 @@ func init() { {{toLower .Name}}PatchCmd.Flags().StringArray("unset", nil, "Unset field using dot notation") {{toLower .Name}}PatchCmd.Flags().StringArray("add", nil, "Add value to array field (field=value)") {{toLower .Name}}PatchCmd.Flags().StringArray("remove", nil, "Remove value from array field (field=value)") + {{- end }} } {{end}} diff --git a/pkg/codegen/templates/server/handlers.go.tmpl b/pkg/codegen/templates/server/handlers.go.tmpl index f24ae6e..ff1aaaf 100644 --- a/pkg/codegen/templates/server/handlers.go.tmpl +++ b/pkg/codegen/templates/server/handlers.go.tmpl @@ -12,6 +12,7 @@ SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT // // This file contains REST API handlers for {{.Name}} resources. +{{- $hasStorageVersioning := and .StorageEnabled .Tags (eq (index .Tags "versioning") "enabled") }} // // To modify this code: // 1. Edit the template file: pkg/codegen/templates/handlers.go.tmpl @@ -19,59 +20,108 @@ SPDX-License-Identifier: MIT // 3. Do NOT edit this file directly - changes will be lost // // Generated handlers provide: +{{- if .Operations.List }} // - GET {{.URLPath}} (list all {{.PluralName}}) +{{- end }} +{{- if .Operations.Get }} // - GET {{.URLPath}}/{uid} (get specific {{.Name}}) +{{- end }} +{{- if .Operations.Create }} // - POST {{.URLPath}} (create new {{.Name}}) +{{- end }} +{{- if .Operations.Update }} // - PUT {{.URLPath}}/{uid} (update {{.Name}} spec) +{{- end }} +{{- if .Operations.Patch }} // - PATCH {{.URLPath}}/{uid} (patch {{.Name}} spec) +{{- end }} +{{- if .Operations.Delete }} // - DELETE {{.URLPath}}/{uid} (delete {{.Name}}) +{{- end }} +{{- if .Operations.UpdateStatus }} // - PUT {{.URLPath}}/{uid}/status (update {{.Name}} status) +{{- end }} +{{- if .Operations.PatchStatus }} // - PATCH {{.URLPath}}/{uid}/status (patch {{.Name}} status) +{{- end }} // // Authorization: Add custom middleware for authentication/authorization -// Storage: Uses storage.Load{{.StorageName}}*/Save{{.StorageName}}*/Delete{{.StorageName}}* +{{if or .Operations.Get .Operations.Create .Operations.Update .Operations.Patch .Operations.Delete .Operations.UpdateStatus .Operations.PatchStatus}}// Persistence: Uses create-once Get{{.Name}}Resource/Save{{.Name}}Resource/Delete{{.Name}}Resource hooks +{{end}} // Version Support: Available (see version context in handlers) // // To enable full version conversion for this resource: // 1. Add new version: fabrica add version // 2. Implement converter: apis///converter.go -// 3. Add version-aware storage: storage.Load{{.StorageName}}WithVersion() +// 3. Add version-aware storage: Get{{.Name}}ResourceWithVersion() // 4. Register versions in cmd/server/main.go // package main import ( + {{- $hasItemOps := or .Operations.Get .Operations.Create .Operations.Update .Operations.Patch .Operations.Delete .Operations.UpdateStatus .Operations.PatchStatus -}} + {{- $hasJSON := or .Operations.Create .Operations.Update .Operations.Patch .Operations.UpdateStatus .Operations.PatchStatus -}} + {{- $hasPatch := or .Operations.Patch .Operations.PatchStatus -}} + {{- $hasWrite := or .Operations.Create .Operations.Update .Operations.Patch .Operations.Delete .Operations.UpdateStatus .Operations.PatchStatus -}} + {{- $hasValidation := or .Operations.Create .Operations.Update .Operations.Patch .Operations.UpdateStatus .Operations.PatchStatus -}} + {{- $hasTime := or .Operations.Create .Operations.Update .Operations.Patch .Operations.Delete .Operations.UpdateStatus .Operations.PatchStatus -}} + {{- $needsResourceTypes := or .Operations.Create .Operations.Update .Operations.Patch .Operations.UpdateStatus .Operations.PatchStatus -}} +{{if $hasJSON}} "encoding/json" +{{end}} "fmt" +{{if $hasPatch}} "io" +{{end}} "net/http" +{{if $hasTime}} "time" +{{end}} +{{if $hasItemOps}} "github.com/go-chi/chi/v5" +{{end}} +{{if $hasWrite}} "github.com/openchami/fabrica/pkg/events" +{{end}} +{{if $hasPatch}} "github.com/openchami/fabrica/pkg/patch" +{{end}} +{{if .Operations.Create}} "github.com/openchami/fabrica/pkg/resource" +{{end}} +{{if $hasValidation}} "github.com/openchami/fabrica/pkg/validation" +{{end}} +{{if .Operations.Create}} "github.com/openchami/fabrica/pkg/versioning" +{{end}} +{{if $needsResourceTypes}} {{range .UniqueImports -}} {{.Alias}} "{{.Path}}" {{end}} +{{end}} +{{if $hasStorageVersioning}} "{{.ModulePath}}/internal/storage" +{{end}} ) +{{if .Operations.List}} // Get{{.Name}}s returns all {{.Name}} resources func Get{{.Name}}s(w http.ResponseWriter, r *http.Request) { // Authorization: Add custom middleware in routes.go or implement checks here // Example: if !authorized(r) { respondError(w, http.StatusUnauthorized, fmt.Errorf("unauthorized")); return } - {{camelCase .PluralName}}, err := storage.LoadAll{{.StorageName}}s(r.Context()) + {{camelCase .PluralName}}, err := List{{.Name}}Resources(r.Context()) if err != nil { respondError(w, http.StatusInternalServerError, fmt.Errorf("failed to load {{.PluralName}}: %w", err)) return } respondJSON(w, http.StatusOK, {{camelCase .PluralName}}) } +{{end}} +{{if .Operations.Get}} // Get{{.Name}} returns a specific {{.Name}} resource by UID func Get{{.Name}}(w http.ResponseWriter, r *http.Request) { uid := chi.URLParam(r, "uid") @@ -83,19 +133,21 @@ func Get{{.Name}}(w http.ResponseWriter, r *http.Request) { // Version context available here for version-aware operations // versionCtx := versioning.GetVersionContext(r.Context()) // Requested version: versionCtx.ServeVersion - // To enable: replace storage.Load{{.StorageName}}() with version-aware function + // To enable: replace Get{{.Name}}Resource() with version-aware function // Authorization: Add custom middleware in routes.go or implement checks here // Example: if !authorized(r) { respondError(w, http.StatusUnauthorized, fmt.Errorf("unauthorized")); return } - {{camelCase .Name}}, err := storage.Load{{.StorageName}}(r.Context(), uid) + {{camelCase .Name}}, err := Get{{.Name}}Resource(r.Context(), uid) if err != nil { respondError(w, http.StatusNotFound, fmt.Errorf("{{.Name}} not found: %w", err)) return } respondJSON(w, http.StatusOK, {{camelCase .Name}}) } +{{end}} +{{if .Operations.Create}} // Create{{.Name}} creates a new {{.Name}} resource func Create{{.Name}}(w http.ResponseWriter, r *http.Request) { var req Create{{.Name}}Request @@ -191,22 +243,22 @@ func Create{{.Name}}(w http.ResponseWriter, r *http.Request) { {{end}} // Save (Layer 1: Ent validation happens automatically if using Ent storage) - if err := storage.Save{{.StorageName}}(r.Context(), {{camelCase .Name}}); err != nil { + if err := Save{{.Name}}Resource(r.Context(), {{camelCase .Name}}); err != nil { respondError(w, http.StatusInternalServerError, fmt.Errorf("failed to save {{.Name}}: %w", err)) return } - {{- if .Tags }}{{- if eq (index .Tags "versioning") "enabled" }} + {{- if $hasStorageVersioning }} // Create initial version snapshot (Spec + metadata only) and persist version into status if verID, err := storage.Create{{.Name}}VersionSnapshot(r.Context(), {{camelCase .Name}}); err != nil { fmt.Printf("Warning: failed to create initial version for {{.Name}} %s: %v\n", {{camelCase .Name}}.GetUID(), err) } else { {{camelCase .Name}}.Status.Version = verID - if err := storage.Save{{.StorageName}}(r.Context(), {{camelCase .Name}}); err != nil { + if err := Save{{.Name}}Resource(r.Context(), {{camelCase .Name}}); err != nil { fmt.Printf("Warning: failed to persist version into status for {{.Name}} %s: %v\n", {{camelCase .Name}}.GetUID(), err) } } - {{- end }}{{- end }} + {{- end }} // Publish resource created event {{if .IsVersioned}} @@ -222,7 +274,9 @@ func Create{{.Name}}(w http.ResponseWriter, r *http.Request) { respondJSON(w, http.StatusCreated, {{camelCase .Name}}) } +{{end}} +{{if .Operations.Update}} // Update{{.Name}} updates the spec of an existing {{.Name}} resource // NOTE: This endpoint ONLY updates the spec. Use PUT /{{.URLPath}}/{uid}/status to update status. func Update{{.Name}}(w http.ResponseWriter, r *http.Request) { @@ -232,7 +286,7 @@ func Update{{.Name}}(w http.ResponseWriter, r *http.Request) { return } - {{camelCase .Name}}, err := storage.Load{{.StorageName}}(r.Context(), uid) + {{camelCase .Name}}, err := Get{{.Name}}Resource(r.Context(), uid) if err != nil { respondError(w, http.StatusNotFound, fmt.Errorf("{{.Name}} not found: %w", err)) return @@ -296,22 +350,22 @@ func Update{{.Name}}(w http.ResponseWriter, r *http.Request) { return } - if err := storage.Save{{.StorageName}}(r.Context(), {{camelCase .Name}}); err != nil { + if err := Save{{.Name}}Resource(r.Context(), {{camelCase .Name}}); err != nil { respondError(w, http.StatusInternalServerError, fmt.Errorf("failed to save {{.Name}}: %w", err)) return } - {{- if .Tags }}{{- if eq (index .Tags "versioning") "enabled" }} + {{- if $hasStorageVersioning }} // Create version snapshot after spec update and persist version into status if verID, err := storage.Create{{.Name}}VersionSnapshot(r.Context(), {{camelCase .Name}}); err != nil { fmt.Printf("Warning: failed to create version for {{.Name}} %s: %v\n", {{camelCase .Name}}.GetUID(), err) } else { {{camelCase .Name}}.Status.Version = verID - if err := storage.Save{{.StorageName}}(r.Context(), {{camelCase .Name}}); err != nil { + if err := Save{{.Name}}Resource(r.Context(), {{camelCase .Name}}); err != nil { fmt.Printf("Warning: failed to persist version into status for {{.Name}} %s: %v\n", {{camelCase .Name}}.GetUID(), err) } } - {{- end }}{{- end }} + {{- end }} // Publish resource updated event updateMetadata := map[string]interface{}{ @@ -330,7 +384,9 @@ func Update{{.Name}}(w http.ResponseWriter, r *http.Request) { respondJSON(w, http.StatusOK, {{camelCase .Name}}) } +{{end}} +{{if .Operations.Patch}} // Patch{{.Name}} patches an existing {{.Name}} resource spec using JSON Merge Patch, JSON Patch, or Shorthand Patch // Only the spec portion of the resource can be patched - metadata and status are API-managed func Patch{{.Name}}(w http.ResponseWriter, r *http.Request) { @@ -340,7 +396,7 @@ func Patch{{.Name}}(w http.ResponseWriter, r *http.Request) { return } - {{camelCase .Name}}, err := storage.Load{{.StorageName}}(r.Context(), uid) + {{camelCase .Name}}, err := Get{{.Name}}Resource(r.Context(), uid) if err != nil { respondError(w, http.StatusNotFound, fmt.Errorf("{{.Name}} not found: %w", err)) return @@ -394,22 +450,22 @@ func Patch{{.Name}}(w http.ResponseWriter, r *http.Request) { } // Save the patched resource - if err := storage.Save{{.StorageName}}(r.Context(), {{camelCase .Name}}); err != nil { + if err := Save{{.Name}}Resource(r.Context(), {{camelCase .Name}}); err != nil { respondError(w, http.StatusInternalServerError, fmt.Errorf("failed to save patched {{.Name}}: %w", err)) return } - {{- if .Tags }}{{- if eq (index .Tags "versioning") "enabled" }} + {{- if $hasStorageVersioning }} // Create version snapshot after spec patch and persist version into status if verID, err := storage.Create{{.Name}}VersionSnapshot(r.Context(), {{camelCase .Name}}); err != nil { fmt.Printf("Warning: failed to create version for {{.Name}} %s: %v\n", {{camelCase .Name}}.GetUID(), err) } else { {{camelCase .Name}}.Status.Version = verID - if err := storage.Save{{.StorageName}}(r.Context(), {{camelCase .Name}}); err != nil { + if err := Save{{.Name}}Resource(r.Context(), {{camelCase .Name}}); err != nil { fmt.Printf("Warning: failed to persist version into status for {{.Name}} %s: %v\n", {{camelCase .Name}}.GetUID(), err) } } - {{- end }}{{- end }} + {{- end }} // Publish resource patched event patchMetadata := map[string]interface{}{ @@ -429,7 +485,9 @@ func Patch{{.Name}}(w http.ResponseWriter, r *http.Request) { respondJSON(w, http.StatusOK, {{camelCase .Name}}) } +{{end}} +{{if .Operations.UpdateStatus}} // Update{{.Name}}Status updates only the status of a {{.Name}} resource // This endpoint is intended for controllers, reconcilers, and monitoring systems. // It does not modify the spec or metadata (except updatedAt timestamp). @@ -446,7 +504,7 @@ func Update{{.Name}}Status(w http.ResponseWriter, r *http.Request) { // Authorization: Add custom middleware for status update authorization // Status updates can have different permissions than spec updates - res, err := storage.Load{{.StorageName}}(r.Context(), uid) + res, err := Get{{.Name}}Resource(r.Context(), uid) if err != nil { respondError(w, http.StatusNotFound, fmt.Errorf("{{.Name}} not found: %w", err)) return @@ -477,7 +535,7 @@ func Update{{.Name}}Status(w http.ResponseWriter, r *http.Request) { return } - if err := storage.Save{{.StorageName}}(r.Context(), res); err != nil { + if err := Save{{.Name}}Resource(r.Context(), res); err != nil { respondError(w, http.StatusInternalServerError, fmt.Errorf("failed to save {{.Name}} status: %w", err)) return } @@ -500,7 +558,9 @@ func Update{{.Name}}Status(w http.ResponseWriter, r *http.Request) { respondJSON(w, http.StatusOK, res) } +{{end}} +{{if .Operations.PatchStatus}} // Patch{{.Name}}Status patches only the status of a {{.Name}} resource // Supports JSON Merge Patch, JSON Patch, and Shorthand Patch formats. // Only modifies status fields - spec and metadata are preserved. @@ -514,7 +574,7 @@ func Patch{{.Name}}Status(w http.ResponseWriter, r *http.Request) { // Authorization: Add custom middleware for status patch authorization // Status patches can have different permissions than spec patches - res, err := storage.Load{{.StorageName}}(r.Context(), uid) + res, err := Get{{.Name}}Resource(r.Context(), uid) if err != nil { respondError(w, http.StatusNotFound, fmt.Errorf("{{.Name}} not found: %w", err)) return @@ -554,7 +614,7 @@ func Patch{{.Name}}Status(w http.ResponseWriter, r *http.Request) { {{- if .Tags }}{{- if eq (index .Tags "versioning") "enabled" }} // Ensure server-managed version field is preserved after patch // Reload current to get authoritative version and copy it back - if current, err := storage.Load{{.StorageName}}(r.Context(), uid); err == nil { + if current, err := Get{{.Name}}Resource(r.Context(), uid); err == nil { res.Status.Version = current.Status.Version } {{- end }}{{- end }} @@ -571,7 +631,7 @@ func Patch{{.Name}}Status(w http.ResponseWriter, r *http.Request) { return } - if err := storage.Save{{.StorageName}}(r.Context(), res); err != nil { + if err := Save{{.Name}}Resource(r.Context(), res); err != nil { respondError(w, http.StatusInternalServerError, fmt.Errorf("failed to save patched {{.Name}} status: %w", err)) return } @@ -593,8 +653,9 @@ func Patch{{.Name}}Status(w http.ResponseWriter, r *http.Request) { respondJSON(w, http.StatusOK, res) } +{{end}} -{{- if .Tags }}{{- if eq (index .Tags "versioning") "enabled" }} +{{- if $hasStorageVersioning }} // List{{.Name}}Versions returns version snapshots for a resource func List{{.Name}}Versions(w http.ResponseWriter, r *http.Request) { uid := chi.URLParam(r, "uid") @@ -643,8 +704,9 @@ func Delete{{.Name}}Version(w http.ResponseWriter, r *http.Request) { } respondJSON(w, http.StatusOK, DeleteResponse{Message: "version deleted", UID: versionID}) } -{{- end }}{{- end }} +{{- end }} +{{if .Operations.Delete}} // Delete{{.Name}} deletes a {{.Name}} resource func Delete{{.Name}}(w http.ResponseWriter, r *http.Request) { uid := chi.URLParam(r, "uid") @@ -654,13 +716,13 @@ func Delete{{.Name}}(w http.ResponseWriter, r *http.Request) { } // Load resource before deletion for event publishing - {{camelCase .Name}}, err := storage.Load{{.StorageName}}(r.Context(), uid) + {{camelCase .Name}}, err := Get{{.Name}}Resource(r.Context(), uid) if err != nil { respondError(w, http.StatusNotFound, fmt.Errorf("{{.Name}} not found: %w", err)) return } - if err := storage.Delete{{.StorageName}}(r.Context(), uid); err != nil { + if err := Delete{{.Name}}Resource(r.Context(), uid); err != nil { respondError(w, http.StatusInternalServerError, fmt.Errorf("failed to delete {{.Name}}: %w", err)) return } @@ -685,3 +747,4 @@ func Delete{{.Name}}(w http.ResponseWriter, r *http.Request) { UID: uid, }) } +{{end}} diff --git a/pkg/codegen/templates/server/openapi.go.tmpl b/pkg/codegen/templates/server/openapi.go.tmpl index 9410429..8e0098c 100644 --- a/pkg/codegen/templates/server/openapi.go.tmpl +++ b/pkg/codegen/templates/server/openapi.go.tmpl @@ -1,3 +1,4 @@ +{{- $storageEnabled := .StorageEnabled -}} // Code generated by Fabrica. DO NOT EDIT. // Copyright © {{.CopyrightYear}} OpenCHAMI a Series of LF Projects, LLC // @@ -197,14 +198,20 @@ func register{{.Name}}Paths(spec *openapi3.T) { resourceSchema, _ := openapi3gen.NewSchemaRefForValue(&{{.PackageAlias}}.{{.Name}}{}, spec.Components.Schemas) spec.Components.Schemas["{{.Name}}"] = resourceSchema + {{- if .Operations.Create }} createReqSchema, _ := openapi3gen.NewSchemaRefForValue(&Create{{.Name}}Request{}, spec.Components.Schemas) spec.Components.Schemas["Create{{.Name}}Request"] = createReqSchema + {{- end }} + {{- if .Operations.Update }} updateReqSchema, _ := openapi3gen.NewSchemaRefForValue(&Update{{.Name}}Request{}, spec.Components.Schemas) spec.Components.Schemas["Update{{.Name}}Request"] = updateReqSchema + {{- end }} + {{- if or .Operations.UpdateStatus .Operations.PatchStatus }} statusSchema, _ := openapi3gen.NewSchemaRefForValue(&{{.PackageAlias}}.{{.Name}}Status{}, spec.Components.Schemas) spec.Components.Schemas["{{.Name}}Status"] = statusSchema + {{- end }} // Error response schema if _, exists := spec.Components.Schemas["ErrorResponse"]; !exists { @@ -214,12 +221,15 @@ func register{{.Name}}Paths(spec *openapi3.T) { spec.Components.Schemas["ErrorResponse"] = &openapi3.SchemaRef{Value: errorSchema} } + {{- if .Operations.Delete }} // DELETE response schema if _, exists := spec.Components.Schemas["DeleteResponse"]; !exists { deleteSchema, _ := openapi3gen.NewSchemaRefForValue(&DeleteResponse{}, spec.Components.Schemas) spec.Components.Schemas["DeleteResponse"] = deleteSchema } + {{- end }} + {{- if .Operations.List }} // List {{.Name}}s operation listOp := openapi3.NewOperation() listOp.OperationID = "list{{.Name}}s" @@ -235,7 +245,9 @@ func register{{.Name}}Paths(spec *openapi3.T) { WithJSONSchemaRef(&openapi3.SchemaRef{Value: arraySchema}), }) listOp.Responses.Set("500", errorResponse()) + {{- end }} + {{- if .Operations.Create }} // Create {{.Name}} operation createOp := openapi3.NewOperation() createOp.OperationID = "create{{.Name}}" @@ -259,7 +271,9 @@ func register{{.Name}}Paths(spec *openapi3.T) { }) createOp.Responses.Set("400", errorResponse()) createOp.Responses.Set("500", errorResponse()) + {{- end }} + {{- if .Operations.Get }} // Get {{.Name}} operation getOp := openapi3.NewOperation() getOp.OperationID = "get{{.Name}}" @@ -276,7 +290,9 @@ func register{{.Name}}Paths(spec *openapi3.T) { }) getOp.Responses.Set("404", errorResponse()) getOp.Responses.Set("500", errorResponse()) + {{- end }} + {{- if .Operations.Update }} // Update {{.Name}} operation updateOp := openapi3.NewOperation() updateOp.OperationID = "update{{.Name}}" @@ -301,7 +317,9 @@ func register{{.Name}}Paths(spec *openapi3.T) { updateOp.Responses.Set("400", errorResponse()) updateOp.Responses.Set("404", errorResponse()) updateOp.Responses.Set("500", errorResponse()) + {{- end }} + {{- if .Operations.Patch }} // Patch {{.Name}} operation patchOp := openapi3.NewOperation() patchOp.OperationID = "patch{{.Name}}" @@ -321,7 +339,9 @@ func register{{.Name}}Paths(spec *openapi3.T) { patchOp.Responses.Set("404", errorResponse()) patchOp.Responses.Set("422", errorResponse()) patchOp.Responses.Set("500", errorResponse()) + {{- end }} + {{- if .Operations.UpdateStatus }} // Update {{.Name}} status operation updateStatusOp := openapi3.NewOperation() updateStatusOp.OperationID = "update{{.Name}}Status" @@ -342,7 +362,9 @@ func register{{.Name}}Paths(spec *openapi3.T) { updateStatusOp.Responses.Set("400", errorResponse()) updateStatusOp.Responses.Set("404", errorResponse()) updateStatusOp.Responses.Set("500", errorResponse()) + {{- end }} + {{- if .Operations.PatchStatus }} // Patch {{.Name}} status operation patchStatusOp := openapi3.NewOperation() patchStatusOp.OperationID = "patch{{.Name}}Status" @@ -360,7 +382,9 @@ func register{{.Name}}Paths(spec *openapi3.T) { patchStatusOp.Responses.Set("404", errorResponse()) patchStatusOp.Responses.Set("422", errorResponse()) patchStatusOp.Responses.Set("500", errorResponse()) + {{- end }} + {{- if .Operations.Delete }} // Delete {{.Name}} operation deleteOp := openapi3.NewOperation() deleteOp.OperationID = "delete{{.Name}}" @@ -378,42 +402,71 @@ func register{{.Name}}Paths(spec *openapi3.T) { deleteOp.Responses.Set("400", errorResponse()) deleteOp.Responses.Set("404", errorResponse()) deleteOp.Responses.Set("500", errorResponse()) + {{- end }} // Create path items collectionPath := &openapi3.PathItem{ + {{- if .Operations.List }} Get: listOp, + {{- end }} + {{- if .Operations.Create }} Post: createOp, + {{- end }} } + {{- if or .Operations.Get .Operations.Update .Operations.Patch .Operations.Delete .Operations.UpdateStatus .Operations.PatchStatus }} uidParam := openapi3.NewPathParameter("uid"). WithDescription("Unique identifier of the {{.Name}} resource"). WithRequired(true). WithSchema(openapi3.NewStringSchema()) + {{- end }} + {{- if or .Operations.Get .Operations.Update .Operations.Patch .Operations.Delete }} itemPath := &openapi3.PathItem{ + {{- if .Operations.Get }} Get: getOp, + {{- end }} + {{- if .Operations.Update }} Put: updateOp, + {{- end }} + {{- if .Operations.Patch }} Patch: patchOp, + {{- end }} + {{- if .Operations.Delete }} Delete: deleteOp, + {{- end }} Parameters: []*openapi3.ParameterRef{ {Value: uidParam}, }, } + {{- end }} + {{- if or .Operations.UpdateStatus .Operations.PatchStatus }} statusPath := &openapi3.PathItem{ + {{- if .Operations.UpdateStatus }} Put: updateStatusOp, + {{- end }} + {{- if .Operations.PatchStatus }} Patch: patchStatusOp, + {{- end }} Parameters: []*openapi3.ParameterRef{ {Value: uidParam}, }, } + {{- end }} // Add paths to spec + {{- if or .Operations.List .Operations.Create }} spec.Paths.Set("{{.URLPath}}", collectionPath) + {{- end }} + {{- if or .Operations.Get .Operations.Update .Operations.Patch .Operations.Delete }} spec.Paths.Set("{{.URLPath}}/{uid}", itemPath) + {{- end }} + {{- if or .Operations.UpdateStatus .Operations.PatchStatus }} spec.Paths.Set("{{.URLPath}}/{uid}/status", statusPath) + {{- end }} - {{- if .Tags}}{{- if eq (index .Tags "versioning") "enabled"}} + {{- if and $storageEnabled .Tags}}{{- if eq (index .Tags "versioning") "enabled"}} // Versions endpoints versionIDParam := openapi3.NewPathParameter("versionID").WithRequired(true).WithSchema(openapi3.NewStringSchema()) diff --git a/pkg/codegen/templates/server/persistence_hooks.go.tmpl b/pkg/codegen/templates/server/persistence_hooks.go.tmpl new file mode 100644 index 0000000..2f1babb --- /dev/null +++ b/pkg/codegen/templates/server/persistence_hooks.go.tmpl @@ -0,0 +1,68 @@ +{{/* +SPDX-FileCopyrightText: 2026 OpenCHAMI a Series of LF Projects, LLC + +SPDX-License-Identifier: MIT +*/}} +// Copyright © {{.CopyrightYear}} OpenCHAMI a Series of LF Projects, LLC +// +// SPDX-License-Identifier: MIT +// +// This file contains create-once persistence hooks for {{.Name}} resources. +// It is generated only if missing and is safe to edit. +package main + +import ( + "context" +{{if not .StorageEnabled}} + "fmt" +{{end}} + + {{.PackageAlias}} "{{.Package}}" +{{if .StorageEnabled}} + "{{.ModulePath}}/internal/storage" +{{end}} +) + +{{if .Operations.List}} +// List{{.Name}}Resources supplies data for GET {{.URLPath}}. +func List{{.Name}}Resources(ctx context.Context) ([]*{{.PackageAlias}}.{{.Name}}, error) { +{{if .StorageEnabled}} + return storage.LoadAll{{.StorageName}}s(ctx) +{{else}} + return nil, fmt.Errorf("List{{.Name}}Resources must be implemented when storage generation is disabled") +{{end}} +} +{{end}} + +{{if or .Operations.Get .Operations.Update .Operations.Patch .Operations.Delete .Operations.UpdateStatus .Operations.PatchStatus}} +// Get{{.Name}}Resource supplies data for operations that load one {{.Name}}. +func Get{{.Name}}Resource(ctx context.Context, uid string) (*{{.PackageAlias}}.{{.Name}}, error) { +{{if .StorageEnabled}} + return storage.Load{{.StorageName}}(ctx, uid) +{{else}} + return nil, fmt.Errorf("Get{{.Name}}Resource must be implemented when storage generation is disabled") +{{end}} +} +{{end}} + +{{if or .Operations.Create .Operations.Update .Operations.Patch .Operations.UpdateStatus .Operations.PatchStatus}} +// Save{{.Name}}Resource persists a {{.Name}}. +func Save{{.Name}}Resource(ctx context.Context, resource *{{.PackageAlias}}.{{.Name}}) error { +{{if .StorageEnabled}} + return storage.Save{{.StorageName}}(ctx, resource) +{{else}} + return fmt.Errorf("Save{{.Name}}Resource must be implemented when storage generation is disabled") +{{end}} +} +{{end}} + +{{if .Operations.Delete}} +// Delete{{.Name}}Resource deletes a {{.Name}} by UID. +func Delete{{.Name}}Resource(ctx context.Context, uid string) error { +{{if .StorageEnabled}} + return storage.Delete{{.StorageName}}(ctx, uid) +{{else}} + return fmt.Errorf("Delete{{.Name}}Resource must be implemented when storage generation is disabled") +{{end}} +} +{{end}} diff --git a/pkg/codegen/templates/server/routes.go.tmpl b/pkg/codegen/templates/server/routes.go.tmpl index fff6cdc..4b243d4 100644 --- a/pkg/codegen/templates/server/routes.go.tmpl +++ b/pkg/codegen/templates/server/routes.go.tmpl @@ -1,3 +1,4 @@ +{{- $storageEnabled := .StorageEnabled -}} // Code generated by Fabrica. DO NOT EDIT. // Copyright © {{.CopyrightYear}} OpenCHAMI a Series of LF Projects, LLC // @@ -15,14 +16,32 @@ {{range .Resources}}// - {{.URLPath}} ({{.Name}} operations) {{end}}// // Route patterns: -// - GET /resource -> List all resources -// - GET /resource/{uid} -> Get specific resource -// - POST /resource -> Create new resource -// - PUT /resource/{uid} -> Update resource spec -// - PATCH /resource/{uid} -> Patch resource spec -// - DELETE /resource/{uid} -> Delete resource -// - PUT /resource/{uid}/status -> Update resource status -// - PATCH /resource/{uid}/status -> Patch resource status +{{- range .Resources }} +{{- if .Operations.List }} +// - GET {{.URLPath}} -> List {{.PluralName}} +{{- end }} +{{- if .Operations.Get }} +// - GET {{.URLPath}}/{uid} -> Get specific {{.Name}} +{{- end }} +{{- if .Operations.Create }} +// - POST {{.URLPath}} -> Create new {{.Name}} +{{- end }} +{{- if .Operations.Update }} +// - PUT {{.URLPath}}/{uid} -> Update {{.Name}} spec +{{- end }} +{{- if .Operations.Patch }} +// - PATCH {{.URLPath}}/{uid} -> Patch {{.Name}} spec +{{- end }} +{{- if .Operations.Delete }} +// - DELETE {{.URLPath}}/{uid} -> Delete {{.Name}} +{{- end }} +{{- if .Operations.UpdateStatus }} +// - PUT {{.URLPath}}/{uid}/status -> Update {{.Name}} status +{{- end }} +{{- if .Operations.PatchStatus }} +// - PATCH {{.URLPath}}/{uid}/status -> Patch {{.Name}} status +{{- end }} +{{- end }} // // To add middleware to routes: // 1. Apply middleware in cmd/server/main.go before calling RegisterGeneratedRoutes @@ -55,31 +74,52 @@ func registerResourcePrefixes() error { func RegisterGeneratedRoutes(r chi.Router) { // Routes inherit middleware from the router passed by main.go {{range .Resources}} + {{- $hasVersions := and $storageEnabled .Tags (eq (index .Tags "versioning") "enabled") }} // {{.Name}} routes r.Route("{{.URLPath}}", func(resource chi.Router) { + {{- if .Operations.List }} resource.Get("/", Get{{.Name}}s) + {{- end }} + {{- if .Operations.Create }} resource.Post("/", Create{{.Name}}) + {{- end }} + {{- if or .Operations.Get .Operations.Update .Operations.Patch .Operations.Delete .Operations.UpdateStatus .Operations.PatchStatus $hasVersions }} resource.Route("/{uid}", func(item chi.Router) { + {{- if .Operations.Get }} item.Get("/", Get{{.Name}}) + {{- end }} + {{- if .Operations.Update }} item.Put("/", Update{{.Name}}) + {{- end }} + {{- if .Operations.Patch }} item.Patch("/", Patch{{.Name}}) + {{- end }} + {{- if .Operations.Delete }} item.Delete("/", Delete{{.Name}}) + {{- end }} + {{- if or .Operations.UpdateStatus .Operations.PatchStatus }} // Status subresource item.Route("/status", func(status chi.Router) { + {{- if .Operations.UpdateStatus }} status.Put("/", Update{{.Name}}Status) + {{- end }} + {{- if .Operations.PatchStatus }} status.Patch("/", Patch{{.Name}}Status) + {{- end }} }) + {{- end }} - {{- if .Tags }}{{- if eq (index .Tags "versioning") "enabled" }} + {{- if $hasVersions }} // Versions subresource item.Route("/versions", func(versions chi.Router) { versions.Get("/", List{{.Name}}Versions) versions.Get("/{versionID}", Get{{.Name}}Version) versions.Delete("/{versionID}", Delete{{.Name}}Version) }) - {{- end }}{{- end }} + {{- end }} }) + {{- end }} }) {{end}} }