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
64 changes: 63 additions & 1 deletion internal/intermediate_representation/attributes.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,20 @@ import (
type flat struct {
empty bool
declaredType string
readOnly bool
// format is the document's declared format, which says what a string
// carries beyond being a string: a password to hide, a timestamp to
// spell as one.
format string
readOnly bool
writeOnly bool
deprecated bool
uniqueItems bool
// The declared constraints, nil when the document states none. They
// become plan-time validators.
pattern string
minimum, maximum *float64
minLength, maxLength *int64
minItems, maxItems *int64
// description is the document's own prose for the schema, folded from
// the first branch that states any. It is the only human-written text in
// the whole derivation; everything else the generated schema says about
Expand Down Expand Up @@ -73,6 +86,30 @@ func flatten(schema *specmodel.Schema) flat {
if schema.ReadOnly {
flattened.readOnly = true
}
if schema.WriteOnly {
flattened.writeOnly = true
}
if schema.Deprecated {
flattened.deprecated = true
}
if schema.UniqueItems {
flattened.uniqueItems = true
}
// The declared facts about the value, folded first-wins like the
// description: a branch that states one is more specific than a
// branch that states nothing.
if flattened.format == "" {
flattened.format = schema.Format
}
if flattened.pattern == "" {
flattened.pattern = schema.Pattern
}
foldBound(&flattened.minimum, schema.Minimum)
foldBound(&flattened.maximum, schema.Maximum)
foldBound(&flattened.minLength, schema.MinLength)
foldBound(&flattened.maxLength, schema.MaxLength)
foldBound(&flattened.minItems, schema.MinItems)
foldBound(&flattened.maxItems, schema.MaxItems)
if flattened.description == "" {
flattened.description = strings.TrimSpace(schema.Description)
}
Expand Down Expand Up @@ -112,6 +149,17 @@ func flatten(schema *specmodel.Schema) flat {
return flattened
}

// foldBound takes a declared bound the fold has not seen yet. First
// declaration wins, matching how the description and the enum fold: a later
// branch that states the same bound states nothing new, and one that states a
// different bound is describing a different use of the same type.
func foldBound[T int64 | float64](dst **T, declared *T) {
if *dst == nil && declared != nil {
value := *declared
*dst = &value
}
}

// scalarTypes are the declared types a single terraform attribute holds
// directly.
var scalarTypes = map[string]bool{"string": true, "integer": true, "number": true, "boolean": true}
Expand Down Expand Up @@ -393,6 +441,20 @@ func buildAttribute(wire string, attributeSite site) (Attribute, attributeEdges)
}
}

// What the document declares about the value itself. These are taken
// from the write side when there is one: a constraint is a rule about
// what may be sent, and a response schema restating it says nothing
// extra. writeOnly is the exception — only a request schema can declare
// it, so a response-only attribute could never carry it anyway.
attribute.Format = flatPrimary.format
attribute.WriteOnly = flatCreate.writeOnly
attribute.Deprecated = flatCreate.deprecated || flatRead.deprecated
attribute.UniqueItems = flatPrimary.uniqueItems
attribute.Pattern = flatPrimary.pattern
attribute.Minimum, attribute.Maximum = flatPrimary.minimum, flatPrimary.maximum
attribute.MinLength, attribute.MaxLength = flatPrimary.minLength, flatPrimary.maxLength
attribute.MinItems, attribute.MaxItems = flatPrimary.minItems, flatPrimary.maxItems

// Every attribute lands in exactly one of five outcomes, and this is where
// four of them are chosen (the fifth, omitted entirely, is decided by
// deriveType marking the attribute unsupported).
Expand Down
127 changes: 127 additions & 0 deletions internal/intermediate_representation/constraints_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
package intermediate_representation

import "testing"

// constrainedSpec is one resource whose create body declares every
// constraint keyword, so one derivation proves the whole set reaches the
// attribute.
const constrainedSpec = `openapi: 3.0.3
info: {title: T, version: "1"}
paths:
/keys:
post:
requestBody:
content:
application/json:
schema: {$ref: '#/components/schemas/Key'}
responses:
"201":
content:
application/json:
schema: {$ref: '#/components/schemas/Key'}
/keys/{keyId}:
get:
responses:
"200":
content:
application/json:
schema: {$ref: '#/components/schemas/Key'}
patch:
requestBody:
content:
application/json:
schema: {$ref: '#/components/schemas/Key'}
responses:
"200":
content:
application/json:
schema: {$ref: '#/components/schemas/Key'}
delete:
responses:
"204": {description: gone}
components:
schemas:
Key:
type: object
properties:
secret:
type: string
format: password
writeOnly: true
minLength: 8
maxLength: 64
pattern: "^[a-z]+$"
legacy:
type: string
deprecated: true
weight:
type: integer
minimum: 1
maximum: 10
tags:
type: array
uniqueItems: true
minItems: 1
maxItems: 5
items: {type: string}
plain:
type: string
`

// TestUnit_Attribute_CarriesTheDeclaredConstraints proves the derivation
// carries every declared constraint onto the attribute. Nothing reads them
// yet; they are what the emitted validators, the sensitivity and the
// deprecation notice are decided from.
func TestUnit_Attribute_CarriesTheDeclaredConstraints(t *testing.T) {
r := resourceByKey(t, mustDerive(t, constrainedSpec, testConfig()), "key")

secret := attribute(t, r.Schema, "secret")
if !secret.WriteOnly {
t.Error("WriteOnly not carried")
}
if secret.Format != "password" {
t.Errorf("Format = %q", secret.Format)
}
if secret.Pattern != "^[a-z]+$" {
t.Errorf("Pattern = %q", secret.Pattern)
}
assertBound(t, "MinLength", secret.MinLength, 8)
assertBound(t, "MaxLength", secret.MaxLength, 64)

if !attribute(t, r.Schema, "legacy").Deprecated {
t.Error("Deprecated not carried")
}

weight := attribute(t, r.Schema, "weight")
if weight.Minimum == nil || *weight.Minimum != 1 {
t.Errorf("Minimum = %v", weight.Minimum)
}
if weight.Maximum == nil || *weight.Maximum != 10 {
t.Errorf("Maximum = %v", weight.Maximum)
}

tags := attribute(t, r.Schema, "tags")
if !tags.UniqueItems {
t.Error("UniqueItems not carried")
}
assertBound(t, "MinItems", tags.MinItems, 1)
assertBound(t, "MaxItems", tags.MaxItems, 5)

plain := attribute(t, r.Schema, "plain")
if plain.WriteOnly || plain.Deprecated || plain.UniqueItems ||
plain.Format != "" || plain.Pattern != "" ||
plain.MinLength != nil || plain.MaxLength != nil {
t.Errorf("a property declaring nothing carries something: %+v", plain)
}
}

func assertBound(t *testing.T, name string, got *int64, want int64) {
t.Helper()
if got == nil {
t.Errorf("%s not carried", name)
return
}
if *got != want {
t.Errorf("%s = %d, want %d", name, *got, want)
}
}
21 changes: 21 additions & 0 deletions internal/intermediate_representation/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,27 @@ type Attribute struct {
// re-creation: x-tfpfgen-create-only, or every writable attribute of
// a resource with no update operation.
RequiresReplace bool `json:"requires_replace,omitempty"`
// Format is the document's declared format, which says what a string
// carries beyond being a string: "password", "date-time", "uuid".
Format string `json:"format,omitempty"`
// WriteOnly marks a property the API accepts on write and never
// returns.
WriteOnly bool `json:"write_only,omitempty"`
// Deprecated marks a property the document declares deprecated.
Deprecated bool `json:"deprecated,omitempty"`
// UniqueItems marks a collection whose members are a set, so the order
// they are returned in carries no meaning.
UniqueItems bool `json:"unique_items,omitempty"`
// The constraints the document declares, nil or empty when it declares
// none. Each becomes a plan-time validator, so a configuration the API
// would refuse or silently clamp fails before it is sent.
Pattern string `json:"pattern,omitempty"`
Minimum *float64 `json:"minimum,omitempty"`
Maximum *float64 `json:"maximum,omitempty"`
MinLength *int64 `json:"min_length,omitempty"`
MaxLength *int64 `json:"max_length,omitempty"`
MinItems *int64 `json:"min_items,omitempty"`
MaxItems *int64 `json:"max_items,omitempty"`
// OneOf lists a closed enum's values for a validator.
OneOf []string `json:"one_of,omitempty"`
// AdvisoryValues lists an open enum's known values
Expand Down
122 changes: 122 additions & 0 deletions internal/specmodel/constraints_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
package specmodel

import "testing"

// constrainedSpec declares every constraint keyword the model reads, so one
// load proves the whole set arrives.
const constrainedSpec = `openapi: 3.0.3
info: {title: T, version: "1"}
components:
schemas:
A:
type: object
properties:
secret:
type: string
format: password
writeOnly: true
minLength: 8
maxLength: 64
pattern: "^[a-z]+$"
legacy:
type: string
deprecated: true
weight:
type: integer
minimum: 1
maximum: 10
tags:
type: array
uniqueItems: true
minItems: 1
maxItems: 5
items: {type: string}
plain:
type: string
`

// property fetches one property of the constrained schema, failing the test
// when the document does not declare it.
func property(t *testing.T, schema *Schema, name string) *Schema {
t.Helper()
got, ok := schema.Property(name)
if !ok {
t.Fatalf("no %s property", name)
}
return got
}

// TestUnit_Specmodel_ReadsTheDeclaredConstraints proves every keyword the
// emitted schema needs survives the load, and that a schema declaring none
// carries none rather than a zero that reads as a bound.
func TestUnit_Specmodel_ReadsTheDeclaredConstraints(t *testing.T) {
doc, err := Load([]byte(constrainedSpec))
if err != nil {
t.Fatalf("Load: %v", err)
}
a := doc.Schemas["A"]
if a == nil {
t.Fatal("no A schema")
}

secret := property(t, a, "secret")
if !secret.WriteOnly {
t.Error("writeOnly not read")
}
if secret.Format != "password" {
t.Errorf("format = %q", secret.Format)
}
if secret.Pattern != "^[a-z]+$" {
t.Errorf("pattern = %q", secret.Pattern)
}
assertInt64(t, "minLength", secret.MinLength, 8)
assertInt64(t, "maxLength", secret.MaxLength, 64)

if !property(t, a, "legacy").Deprecated {
t.Error("deprecated not read")
}

weight := property(t, a, "weight")
assertFloat64(t, "minimum", weight.Minimum, 1)
assertFloat64(t, "maximum", weight.Maximum, 10)

tags := property(t, a, "tags")
if !tags.UniqueItems {
t.Error("uniqueItems not read")
}
assertInt64(t, "minItems", tags.MinItems, 1)
assertInt64(t, "maxItems", tags.MaxItems, 5)

// A property declaring nothing carries nothing: a nil bound and a zero
// bound mean different things, and only nil means the document is silent.
plain := property(t, a, "plain")
if plain.WriteOnly || plain.Deprecated || plain.UniqueItems {
t.Errorf("undeclared flags set: %+v", plain)
}
if plain.MinLength != nil || plain.MaxLength != nil ||
plain.MinItems != nil || plain.MaxItems != nil {
t.Errorf("undeclared bounds set: %+v", plain)
}
}

func assertInt64(t *testing.T, name string, got *int64, want int64) {
t.Helper()
if got == nil {
t.Errorf("%s not read", name)
return
}
if *got != want {
t.Errorf("%s = %d, want %d", name, *got, want)
}
}

func assertFloat64(t *testing.T, name string, got *float64, want float64) {
t.Helper()
if got == nil {
t.Errorf("%s not read", name)
return
}
if *got != want {
t.Errorf("%s = %v, want %v", name, *got, want)
}
}
Loading
Loading