Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions cmd/fabrica/config_security_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down
45 changes: 40 additions & 5 deletions cmd/fabrica/generate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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/<group>/<version>/...")
Expand Down Expand Up @@ -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)
Expand All @@ -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 {
Expand Down Expand Up @@ -770,6 +789,7 @@ type EventsConfig struct {
}

type StorageConfig struct {
Enabled *bool `+"`yaml:\"enabled\"`"+`
Type string `+"`yaml:\"type\"`"+`
DBDriver string `+"`yaml:\"db_driver\"`"+`
}
Expand Down Expand Up @@ -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 != "" {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1249,6 +1272,18 @@ func generateVersionedRegistrationCode(modulePath string, apisConfig *config.API
fmt.Fprintf(&registrations, "\tif err := gen.RegisterResource(&%s.%s{}); err != nil {\n", pkg, resourceStruct)
fmt.Fprintf(&registrations, "\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
Expand Down
23 changes: 23 additions & 0 deletions cmd/fabrica/generate_runner_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion cmd/fabrica/migrate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
26 changes: 26 additions & 0 deletions cmd/fabrica/registration_template_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
71 changes: 69 additions & 2 deletions docs/apis-yaml.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `/<lowercase-resource>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, `<path>` is the configured resource path and `{uid}` identifies one resource.

Read operations:

- `list` generates `GET <path>` to return the resource collection.
- `get` generates `GET <path>/{uid}` to return one resource.

Write operations:

- `create` generates `POST <path>` to create a resource.
- `update` or `put` generates `PUT <path>/{uid}` to replace a resource.
- `patch` generates `PATCH <path>/{uid}` to partially update a resource.
- `delete` generates `DELETE <path>/{uid}` to delete a resource.

Status operations:

- Each of `update-status`, `status-update`, `put-status`, and `updatestatus` generates `PUT <path>/{uid}/status` to replace the resource status.
- Each of `patch-status`, `status-patch`, and `patchstatus` generates `PATCH <path>/{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 `List<Resource>Resources`, `Get<Resource>Resource`, `Save<Resource>Resource`, and `Delete<Resource>Resource`. 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 <name> [--group <group>] [--versions v1alpha1,v1]`
Expand All @@ -56,7 +119,11 @@ Fields:
- **Add a new version**: `fabrica add version v1beta2 [--from v1beta1]` copies types from the source spoke into `apis/<group>/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

Expand Down
Loading
Loading