feat: release configurable API response contracts
Define reusable response bodies in .api with typed code mappings, per-route overrides and matching OpenAPI schemas. Add application and callback encoders, early response policy selection, and isolated remote-consumer release verification. Release v0.8.0-preview after full race, vet, build and local MySQL workflow acceptance.
This commit is contained in:
109
response/template.go
Normal file
109
response/template.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package response
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Slot returns the name of a whole-value placeholder, or empty for constants.
|
||||
// Slots preserve the value's JSON type; they are not string interpolation.
|
||||
func Slot(value any) string {
|
||||
s, ok := value.(string)
|
||||
if ok && strings.HasPrefix(s, "${") && strings.HasSuffix(s, "}") {
|
||||
return s[2 : len(s)-1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func validateTemplate(value any, depth int) error {
|
||||
if depth > 24 {
|
||||
return fmt.Errorf("response: body nesting exceeds 24 levels")
|
||||
}
|
||||
switch v := value.(type) {
|
||||
case map[string]any:
|
||||
for key, item := range v {
|
||||
if strings.TrimSpace(key) == "" {
|
||||
return fmt.Errorf("response: body keys must not be empty")
|
||||
}
|
||||
if err := validateTemplate(item, depth+1); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case []any:
|
||||
for _, item := range v {
|
||||
if err := validateTemplate(item, depth+1); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case string:
|
||||
if strings.HasPrefix(v, "${") {
|
||||
switch Slot(v) {
|
||||
case "code", "message", "data", "requestId", "ok", "status":
|
||||
default:
|
||||
return fmt.Errorf("response: unknown body slot %q", v)
|
||||
}
|
||||
}
|
||||
case nil, bool, json.Number, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64:
|
||||
if _, err := json.Marshal(v); err != nil {
|
||||
return fmt.Errorf("response: invalid body constant: %w", err)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("response: body accepts JSON values, got %T", value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cloneTemplate(value any, depth int) any {
|
||||
// Invalid cyclic Go configuration must still reach validation, not overflow.
|
||||
if depth > 24 {
|
||||
return value
|
||||
}
|
||||
switch v := value.(type) {
|
||||
case map[string]any:
|
||||
copy := make(map[string]any, len(v))
|
||||
for key, item := range v {
|
||||
copy[key] = cloneTemplate(item, depth+1)
|
||||
}
|
||||
return copy
|
||||
case []any:
|
||||
copy := make([]any, len(v))
|
||||
for i, item := range v {
|
||||
copy[i] = cloneTemplate(item, depth+1)
|
||||
}
|
||||
return copy
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
func renderTemplate(value any, slots map[string]any, dataMode string) any {
|
||||
if name := Slot(value); name != "" {
|
||||
return slots[name]
|
||||
}
|
||||
switch v := value.(type) {
|
||||
case map[string]any:
|
||||
result := make(map[string]any, len(v))
|
||||
for key, item := range v {
|
||||
slot := Slot(item)
|
||||
if slot == "requestId" && slots[slot] == "" || slot == "data" && slots[slot] == nil && dataMode != "always" {
|
||||
continue
|
||||
}
|
||||
result[key] = renderTemplate(item, slots, dataMode)
|
||||
}
|
||||
return result
|
||||
case []any:
|
||||
result := make([]any, len(v))
|
||||
for i, item := range v {
|
||||
result[i] = renderTemplate(item, slots, dataMode)
|
||||
}
|
||||
return result
|
||||
case string:
|
||||
if strings.HasPrefix(v, "$${") {
|
||||
return v[1:]
|
||||
}
|
||||
return v
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user