Skip to content
Merged
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
5 changes: 5 additions & 0 deletions changes/unreleased/Added-20260719-001541.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
kind: Added
body: 'Add a generic Smithy-driven CRUD fallback engine (internal/shared/crud) that auto-serves ~2,200 standard CRUD-shaped operations across all 46 JSON-protocol services with plausible, store-backed responses; hand-implemented operations always take precedence and unclassifiable operations still return an honest error. Documented in docs/crud-engine.md'
time: 2026-07-19T00:15:41.279904+09:00
custom:
Issue: "98"
29 changes: 29 additions & 0 deletions cmd/codegen/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"

"github.com/skyoo2003/devcloud/internal/codegen"
Expand Down Expand Up @@ -35,6 +36,8 @@ func main() {

gen := codegen.NewGenerator(*templateDir)

var crudServices []codegen.CRUDServiceData

for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
continue
Expand All @@ -61,6 +64,32 @@ func main() {
fmt.Fprintf(os.Stderr, "Error generating %s: %v\n", model.ServiceID, err)
os.Exit(1)
}

if data, ok := codegen.ServiceCRUDData(model); ok {
crudServices = append(crudServices, data)
}
}

// Write the aggregate CRUD registry only when generating the full fleet
// (a filtered run would otherwise clobber it with a partial registry).
if len(allowedServices) == 0 {
sort.Slice(crudServices, func(i, j int) bool {
return crudServices[i].ServiceID < crudServices[j].ServiceID
})
content, err := gen.GenerateCRUDRegistry(crudServices)
if err != nil {
fmt.Fprintf(os.Stderr, "Error generating CRUD registry: %v\n", err)
os.Exit(1)
}
regDir := filepath.Join(*outputDir, "crudregistry")
if err := os.MkdirAll(regDir, 0755); err != nil {
fmt.Fprintf(os.Stderr, "Error creating crudregistry dir: %v\n", err)
os.Exit(1)
}
if err := os.WriteFile(filepath.Join(regDir, "registry_gen.go"), []byte(content), 0644); err != nil {
fmt.Fprintf(os.Stderr, "Error writing CRUD registry: %v\n", err)
os.Exit(1)
}
}

fmt.Println("Code generation complete.")
Expand Down
8 changes: 8 additions & 0 deletions cmd/devcloud/crudregistry_import.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// SPDX-License-Identifier: Apache-2.0

package main

// Blank import runs the generated CRUD registry's init(), registering every
// service's CRUD-shaped operations with the generic fallback engine
// (internal/shared/crud). Kept separate from the generated imports.go.
import _ "github.com/skyoo2003/devcloud/internal/generated/crudregistry"
63 changes: 63 additions & 0 deletions docs/crud-engine.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# Generic CRUD Fallback Engine

DevCloud auto-serves standard CRUD-shaped operations that a service's hand-written
provider has not implemented, using a generic engine driven by the Smithy models.
This lets the long tail of ~2,200 CRUD-shaped operations respond to SDK calls
without hand-coding each one.

**Fidelity is deliberately "plausible, not faithful."** Responses are store-backed
and echo the caller's input plus synthesized ids/ARNs, so SDKs round-trip
(create → get → list → delete). There is **no** validation, cross-resource
integrity, pagination correctness, or business logic. Treat engine-served
operations as scaffolding for local wiring, not as behavioural parity.

## How it works

- **Engine** — [`internal/shared/crud`](../internal/shared/crud/crud.go): an
in-memory resource store plus verb dispatch (Create/Get/List/Delete/Update/…).
- **Classification** — codegen inspects each operation's name and output shape
(`internal/codegen/gen_crud_meta.go`) and emits one aggregate registry,
`internal/generated/crudregistry/registry_gen.go`, whose `init()` registers
every classifiable operation. Regenerated by `make codegen`.
- **Integration** — a provider opts in by returning `plugin.ErrUnhandledOp` from
its dispatch `default:` case. The gateway
([`internal/gateway/router.go`](../internal/gateway/router.go)) then calls the
engine; if the engine cannot classify the operation, the standard
`InvalidAction` error is returned instead. **Never a fabricated success for an
unclassifiable op.**

## Fidelity tiers

| Tier | Meaning |
|------|---------|
| **hand-verified** | Implemented by the service's provider (explicit dispatch `case`). Highest fidelity. |
| **auto-crud** | Served by the engine with plausible, store-backed responses. Reaches the engine only for engine-wired services (below). |
| **unimplemented** | Not implemented and not CRUD-classifiable — returns an honest `InvalidAction` error. |

Hand-written operations always win: the engine is only reached when a request
falls through to the provider's `default:` case, so it never shadows a real
implementation.

## Scope

- **JSON protocols only** (`json-1.0` / `json-1.1`). These carry the operation
name in `X-Amz-Target`, which the engine needs. Query/REST-XML services (EC2,
S3, Route53, IAM, RDS, CloudFormation) are hand-written and out of scope.
- **Engine-registered services**: 46 (all JSON-protocol services with
classifiable operations), covering ~2,200 operations.
- **Engine-wired services**: all 46 registered JSON services are wired — their
dispatch `default:` returns `plugin.ErrUnhandledOp`, so unimplemented CRUD ops
are engine-served. This **includes core services** (DynamoDB, SQS, KMS, ECS,
…): they hand-implement their common operations, and only their few remaining
unimplemented CRUD ops fall through to the engine. Non-CRUD / unclassifiable
ops still return an honest `InvalidAction` error.

## Known limits

- `List*` responses return stored objects; when the real AWS output member is a
list of *names* (strings) rather than structures, an SDK may not populate it.
- No required-parameter validation, so calls succeed with minimal input.
- The store is in-memory and per-process (not persisted across restarts).

To promote an operation from `auto-crud` to `hand-verified`, implement it as an
explicit `case` in the service provider following existing patterns.
1 change: 1 addition & 0 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ We pursue this vision through a **phased rollout** to manage scope, architectura
- [x] Unimplemented operations return a consistent AWS error (`InvalidAction`, HTTP 400) instead of a false `200` success; dead scaffold code removed
- [x] boto3 compatibility coverage added for previously-untested services (CodeConnections, DMS, Verified Permissions)
- [x] Stable `ServicePlugin` API finalized and documented ([plugin-api.md](plugin-api.md)), enforced by a conformance test over every registered service
- [x] Generic CRUD fallback engine ([crud-engine.md](crud-engine.md)) auto-serves ~2,200 CRUD-shaped operations across all 46 JSON-protocol services with plausible, store-backed responses; every registered JSON service is wired. **Follow-up**: promote high-value auto-crud ops to hand-verified fidelity.
- [ ] v1.0 release (pending maintainer tag)

### Phase 2 — Architectural Preparation (v1.x)
Expand Down
11 changes: 11 additions & 0 deletions docs/services-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,17 @@ KMS, Secrets Manager, EventBridge, CloudFormation) have the deepest coverage.
Services implement their common operations; less-common operations return a clean
AWS error rather than a false success, so an SDK always gets a truthful response.

## Operation coverage & the CRUD fallback engine

Hand-written providers implement each service's common operations. For the long
tail of standard CRUD-shaped operations (~2,200 across 46 JSON-protocol
services), a generic engine can serve plausible, store-backed responses so SDK
calls round-trip. This coverage is **plausible, not faithful** — no validation or
business logic — and is opt-in per service. See
[crud-engine.md](crud-engine.md) for the fidelity tiers, wired services, and
limits. Operations that are neither hand-written nor CRUD-classifiable return an
honest `InvalidAction` error, never a fabricated success.

## Supported protocols

- **JSON 1.0** (`application/x-amz-json-1.0`): DynamoDB, DynamoDB Streams, Kinesis
Expand Down
155 changes: 155 additions & 0 deletions internal/codegen/gen_crud_meta.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
// SPDX-License-Identifier: Apache-2.0

package codegen

import (
"fmt"
"go/format"
"strings"
)

// CRUDRegistryData drives crud_registry.go.tmpl: one file registering every
// service's CRUD-shaped operations with the fallback engine.
type CRUDRegistryData struct {
Services []CRUDServiceData
}

// CRUDServiceData is one service's classified operations.
type CRUDServiceData struct {
ServiceID string
Ops []crudOpData
}

type crudOpData struct {
Op string
Verb string
Resource string
ListKey string
ItemKey string
}

// verbPrefixes maps operation-name prefixes to canonical CRUD verbs, longest and
// most specific first. Only high-confidence CRUD shapes are classified; anything
// else is left unclassified so the engine returns an honest "unknown action".
var verbPrefixes = []struct{ prefix, verb string }{
{"Untag", "Untag"},
{"Tag", "Tag"},
{"Describe", "Get"},
{"BatchGet", "List"},
{"Get", "Get"},
{"List", "List"},
{"Create", "Create"},
{"Register", "Create"},
{"Deregister", "Delete"},
{"Delete", "Delete"},
{"Update", "Update"},
{"Modify", "Update"},
{"Put", "Update"},
}

func canonicalVerb(op string) (verb, resource string) {
for _, vp := range verbPrefixes {
if strings.HasPrefix(op, vp.prefix) && len(op) > len(vp.prefix) {
return vp.verb, singularize(strings.TrimPrefix(op, vp.prefix))
}
}
return "", ""
}

// singularize converts a resource noun to a stable singular key so that, e.g.,
// CreateDatabase and ListDatabases share the same store bucket.
func singularize(s string) string {
switch {
case strings.HasSuffix(s, "ies"):
return s[:len(s)-3] + "y"
case strings.HasSuffix(s, "sses"), strings.HasSuffix(s, "xes"), strings.HasSuffix(s, "zes"):
return s[:len(s)-2]
case strings.HasSuffix(s, "ss"):
return s
case strings.HasSuffix(s, "s"):
return s[:len(s)-1]
default:
return s
}
}

// outputKeys finds the output member holding a collection (list key) and the one
// wrapping a single resource structure (item key).
func outputKeys(model *SmithyModel, op Operation) (listKey, itemKey string) {
out := model.Shapes[op.OutputName]
if out == nil {
return "", ""
}
for _, mem := range out.Members {
target := model.Shapes[mem.TargetName]
if target == nil {
continue
}
switch target.Type {
case ShapeList:
if listKey == "" {
listKey = mem.Name
}
case ShapeStructure:
if itemKey == "" {
itemKey = mem.Name
}
}
}
return listKey, itemKey
}

// classifyOps returns the CRUD metadata for every classifiable operation, in the
// model's (already sorted) order for reproducible output.
func classifyOps(model *SmithyModel) []crudOpData {
var ops []crudOpData
for _, op := range model.Operations {
verb, resource := canonicalVerb(op.Name)
if verb == "" || resource == "" {
continue
}
listKey, itemKey := outputKeys(model, op)
ops = append(ops, crudOpData{
Op: op.Name,
Verb: verb,
Resource: resource,
ListKey: listKey,
ItemKey: itemKey,
})
}
return ops
}

// isJSONProtocol reports whether the engine can serve a service's protocol.
// Only X-Amz-Target JSON protocols carry an operation name at the router.
func isJSONProtocol(protocol string) bool {
return strings.HasPrefix(protocol, "json")
}

// ServiceCRUDData classifies a JSON-protocol model's CRUD operations. It returns
// (data, false) when the service is not engine-servable or has no CRUD ops.
func ServiceCRUDData(model *SmithyModel) (CRUDServiceData, bool) {
if !isJSONProtocol(model.Protocol) {
return CRUDServiceData{}, false
}
ops := classifyOps(model)
if len(ops) == 0 {
return CRUDServiceData{}, false
}
return CRUDServiceData{ServiceID: model.ServiceID, Ops: ops}, true
}

// GenerateCRUDRegistry renders the single aggregate registry file. Services are
// registered from one init() so registration does not depend on any individual
// generated package being imported.
func (g *Generator) GenerateCRUDRegistry(services []CRUDServiceData) (string, error) {
rendered, err := g.renderTemplate("crud_registry.go.tmpl", CRUDRegistryData{Services: services})
if err != nil {
return "", err
}
formatted, err := format.Source([]byte(rendered))
if err != nil {
return "", fmt.Errorf("gofmt crud_registry.go: %w", err)
}
return string(formatted), nil
}
19 changes: 19 additions & 0 deletions internal/codegen/templates/crud_registry.go.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// Code generated by devcloud codegen. DO NOT EDIT.
// SPDX-License-Identifier: Apache-2.0

// Package crudregistry registers every service's CRUD-shaped operations with the
// generic fallback engine. It is blank-imported by the server so registration
// runs regardless of which service packages are linked. See internal/shared/crud.
package crudregistry

import "github.com/skyoo2003/devcloud/internal/shared/crud"

func init() {
{{- range .Services }}
crud.Register("{{ .ServiceID }}", map[string]crud.OpMeta{
{{- range .Ops }}
"{{ .Op }}": {Verb: "{{ .Verb }}", Resource: "{{ .Resource }}", OutputListKey: "{{ .ListKey }}", OutputItemKey: "{{ .ItemKey }}"},
{{- end }}
})
{{- end }}
}
30 changes: 30 additions & 0 deletions internal/gateway/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,17 @@
package gateway

import (
"bytes"
"encoding/json"
"encoding/xml"
"errors"
"io"
"mime"
"net/http"
"strings"

"github.com/skyoo2003/devcloud/internal/plugin"
"github.com/skyoo2003/devcloud/internal/shared/crud"
)

// ServiceRouter dispatches incoming HTTP requests to the appropriate
Expand All @@ -36,7 +40,33 @@ func (sr *ServiceRouter) ServeHTTP(w http.ResponseWriter, r *http.Request) {

op := extractOperationName(r, protocol)

// Buffer the body for JSON protocols so the CRUD fallback engine can re-read
// it if the provider does not handle the operation. Cheap for JSON payloads;
// skipped for REST-XML (S3) where bodies may be large binary uploads.
var body []byte
if crud.JSONProtocol(protocol) {
var rerr error
if body, rerr = io.ReadAll(r.Body); rerr != nil {
writeAWSError(w, protocol, http.StatusBadRequest, "SerializationException", "failed to read request body")
return
}
r.Body = io.NopCloser(bytes.NewReader(body))
}

resp, err := p.HandleRequest(r.Context(), op, r)

// A provider that returns ErrUnhandledOp is opting into the generic CRUD
// fallback for operations it does not implement. If the engine cannot
// classify the operation either, emit the standard "unknown action" error.
if errors.Is(err, plugin.ErrUnhandledOp) {
res, cerr := crud.Handle(serviceID, op, protocol, body)
if cerr != nil {
writeAWSError(w, protocol, http.StatusBadRequest, "InvalidAction", "unknown action: "+op)
return
}
resp, err = &plugin.Response{StatusCode: res.Status, Body: res.Body, ContentType: res.ContentType}, nil
}

if err != nil {
writeAWSError(w, protocol, http.StatusInternalServerError, "InternalError", err.Error())
return
Expand Down
Loading