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
51 changes: 46 additions & 5 deletions cmd/tfpfgen/blueprint_draft.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import (
)

const usageBlueprintDraft = "blueprint draft [-openapi-dir DIR] [-snapshot NAME] [-tag TAG] " +
"[-sdk-dialect restyService|kiotaFluent] [-out DIR] [-dry-run]"
"[-sdk-dialect restyService|kiotaFluent] [-exclusions FILE] [-out DIR] [-dry-run]"

func runBlueprintDraft(args []string) error {
fs, _ := newFlagSet("blueprint draft", usageBlueprintDraft)
Expand All @@ -44,6 +44,8 @@ func runBlueprintDraft(args []string) error {
"binding shape to infer: restyService, or kiotaFluent for a kiota-generated SDK")
sdkModels = fs.String("sdk-models-package", "",
"import path of the kiota SDK's models package (required with -sdk-dialect kiotaFluent)")
exclusions = fs.String("exclusions", "",
"exclusions sidecar; defaults to <openapi-dir>/"+openapi.ExclusionsFileName+" when present")
)

if err := parse(fs, args); err != nil {
Expand Down Expand Up @@ -88,6 +90,15 @@ func runBlueprintDraft(args []string) error {

log.Printf("specification: %s (%s %s)", path, doc.Title, doc.Version)

exclusionsPath := *exclusions
if exclusionsPath == "" {
exclusionsPath = filepath.Join(*openapiDir, openapi.ExclusionsFileName)
}
excluded, err := openapi.LoadExclusions(exclusionsPath)
if err != nil {
return err
}

candidates := filterCandidates(doc.Discover(), *tag, *includeUnusable)
if len(candidates) == 0 {
return fmt.Errorf("%w: no candidates matched", errNothingToDo)
Expand All @@ -111,12 +122,13 @@ func runBlueprintDraft(args []string) error {
SDKModelsImport: *sdkModels,
}

return inferAll(doc, candidates, opts, *out, *scenarioDrafts)
return inferAll(doc, candidates, excluded, opts, *out, *scenarioDrafts)
}

func inferAll(
doc *openapi.Document,
candidates []openapi.Candidate,
excluded openapi.Exclusions,
opts openapi.InferOptions,
out string,
planDrafts string,
Expand All @@ -128,10 +140,39 @@ func inferAll(
)

for _, c := range candidates {
if kind, why := c.Classify(); kind != openapi.CandidateKindResource {
// The sidecar speaks first: a curated exclusion is a decision already
// made, and the run repeats its reason as a named skip.
if reason, is := excluded.Match(c); is {
log.Printf("excluded %s: %s", c.Key, reason)
skipped++
continue
}

kind, why := c.Classify()

if kind == openapi.CandidateKindDataSource {
ds, dsNotes, err := doc.InferDataSource(c, opts)
notes = append(notes, dsNotes...)
if err != nil {
log.Printf("skipped %s: %v", c.Key, err)
skipped++
continue
}
bp := blueprint.Blueprint{FormatVersion: blueprint.FormatVersion, DataSources: []blueprint.DataSource{ds}}
path := filepath.Join(out, "datasources", ds.Key+blueprint.Ext)
if err := blueprint.Save(path, bp); err != nil {
return err
}
log.Printf("wrote %s (dataSource, %d attributes, %d selector(s))",
path, len(ds.Schema.Attributes), len(ds.Binding.Selectors))
written++
continue
}

if kind != openapi.CandidateKindResource {
// Said out loud rather than silently skipped: silence reads as agreement,
// and a data source or action the spec offers deserves at least a line
// saying inference does not reach it yet.
// and an action the spec offers deserves at least a line saying inference
// does not reach it yet.
log.Printf("skipped %s: %s inference is not implemented (%s)", c.Key, kind, why)
skipped++
continue
Expand Down
3 changes: 2 additions & 1 deletion docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ Infers draft blueprints (and optionally scenario worksheets) from a pinned
OpenAPI snapshot.

```
tfpfgen blueprint draft [-openapi-dir DIR] [-snapshot NAME] [-tag TAG] [-sdk-dialect restyService|kiotaFluent] [-out DIR] [-dry-run]
tfpfgen blueprint draft [-openapi-dir DIR] [-snapshot NAME] [-tag TAG] [-sdk-dialect restyService|kiotaFluent] [-exclusions FILE] [-out DIR] [-dry-run]
```

| Flag | Default | Purpose |
Expand All @@ -233,6 +233,7 @@ tfpfgen blueprint draft [-openapi-dir DIR] [-snapshot NAME] [-tag TAG] [-sdk-dia
| `-scenario-drafts` | — | also scaffold a `KEY.scenario.draft.json` scenario worksheet per resource under this directory |
| `-sdk-dialect` | `restyService` | binding shape to infer: `restyService`, or `kiotaFluent` for a kiota-generated SDK |
| `-sdk-models-package` | — | import path of the kiota SDK's models package (required with `-sdk-dialect kiotaFluent`; the resty `-sdk-service-root`/`-sdk-accessor` knobs are refused under it) |
| `-exclusions` | `<openapi-dir>/draft-exclusions.json` | curated sidecar of families drafting must skip, each entry carrying its reason; the run repeats every exclusion as a named skip |

`blueprint draft -dry-run` is the survey: it reports every candidate the
document offers and why the ineligible ones are ineligible. The write path
Expand Down
44 changes: 42 additions & 2 deletions internal/blueprint/binding.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,54 @@ type ResourceBinding struct {
type DataSourceBinding struct {
Service ServiceRef `json:"service"`

// Read is the only operation. It is a pointer for symmetry with ResourceBinding's
// Read is the direct fetch. It is a pointer for symmetry with ResourceBinding's
// operations and so that "not yet authored" is distinguishable from an empty call,
// which is what an imported draft needs; Validate requires it.
// which is what an imported draft needs; Validate requires it unless the binding
// is list-resolved with no direct fetch at all.
Read *Operation `json:"read,omitempty"`

// List is the resolver: when the practitioner selects by anything other than
// the direct identifier, the list is fetched and the selectors narrow it to
// exactly one element -- a lookup must be predictable, so zero matches and
// several matches are both errors, never a guess. With Read present the
// matched element only supplies the identifier and the direct fetch runs as
// usual, so state always maps from one response shape; without Read the
// matched element itself is that shape.
List *Operation `json:"list,omitempty"`
// CollectionField reaches the elements inside the list response, e.g.
// "GetTags()" under method access.
CollectionField string `json:"collectionField,omitempty"`
// ElementType is the list element's Go type, e.g. "models.Tags_API_Tagable".
ElementType string `json:"elementType,omitempty"`

// Selectors are the configuration attributes a practitioner may look up by.
// Exactly one must be set per read, which the generated code enforces.
Selectors []Selector `json:"selectors,omitempty"`

// ElementIDField reads the direct identifier off a matched element -- the
// accessor base, e.g. "Id" -- and ElementIDFlatten converts it into the
// identifier attribute, after which the direct Read runs exactly as if the
// practitioner had supplied the id. Required when both List and Read exist.
ElementIDField string `json:"elementIdField,omitempty"`
ElementIDFlatten *ConvertCall `json:"elementIdFlatten,omitempty"`

Response ResponseModel `json:"response"`
}

// Selector is one attribute a data source can be looked up by.
type Selector struct {
// Attribute is the schema attribute acting as the selector.
Attribute string `json:"attribute"`
// GoField is the model field the attribute lives in.
GoField string `json:"goField"`
// SDKField is the accessor base on the list element the selector matches
// against, e.g. "TestName". Empty when ViaRead.
SDKField string `json:"sdkField,omitempty"`
// ViaRead marks the direct identifier: no list resolution, the value feeds
// the Read chain as-is.
ViaRead bool `json:"viaRead,omitempty"`
}

// EphemeralBinding wires an ephemeral resource to the SDK.
//
// Open is the one operation this toolkit renders: it runs when Terraform needs the value,
Expand Down
64 changes: 62 additions & 2 deletions internal/blueprint/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,25 @@ func (d DataSource) validate(at string, p *problems) {
dup(p, seenNames, a.Name, aat+".name", "attribute name")
dup(p, seenFields, a.GoField, aat+".goField", "model field")
}

// A selector is configuration: it must name a declared attribute the
// practitioner can actually set.
for i, s := range d.Binding.Selectors {
sat := fmt.Sprintf("%s.binding.selectors[%d]", at, i)
found := false
for _, a := range d.Schema.Attributes {
if a.Name != s.Attribute {
continue
}
found = true
if a.ComputedOptionalRequired == Computed {
p.add(sat+".attribute", "%q is computed, which a practitioner cannot set", s.Attribute)
}
}
if !found && s.Attribute != "" {
p.add(sat+".attribute", "names attribute %q, which the data source does not declare", s.Attribute)
}
}
}

func dup(p *problems, seen map[string]bool, value, path, what string) {
Expand Down Expand Up @@ -1548,12 +1567,53 @@ func (b DataSourceBinding) validate(at string, p *problems) {
p.add(at+".response.accessStyle", "%q is not a known access style", b.Response.AccessStyle)
}

if b.Read == nil {
if b.Read == nil && b.List == nil {
p.add(at+".read", "is required: a data source with no read operation has nothing to do")
return
}
if b.Read != nil {
b.Read.validate(at+".read", p)
}

b.Read.validate(at+".read", p)
// The list-resolver contract: selectors narrow the list to exactly one
// element, so a lookup stays predictable. Whatever needs the list must
// bring the list, and the pieces that reach inside it.
needsList := b.Read == nil
for _, s := range b.Selectors {
if !s.ViaRead {
needsList = true
}
}
if needsList && b.List == nil {
p.add(at+".list", "is required: a selector that is not viaRead resolves through the list")
}
if b.List != nil {
b.List.validate(at+".list", p)
required(p, at+".collectionField", b.CollectionField)
required(p, at+".elementType", b.ElementType)
if len(b.Selectors) == 0 && b.Read != nil {
p.add(at+".selectors", "a list with a direct read wants selectors; without any, the list is never consulted")
}
if b.Read != nil {
// A matched element only supplies the identifier; the direct read
// does the fetching. That handoff needs the id spelled out.
required(p, at+".elementIdField", b.ElementIDField)
if b.ElementIDFlatten == nil {
p.add(at+".elementIdFlatten", "is required with both list and read: it converts the matched element's identifier into the id attribute")
}
}
}
for i, s := range b.Selectors {
sat := fmt.Sprintf("%s.selectors[%d]", at, i)
required(p, sat+".attribute", s.Attribute)
required(p, sat+".goField", s.GoField)
if s.ViaRead && b.Read == nil {
p.add(sat+".viaRead", "names the direct read, which this binding does not declare")
}
if !s.ViaRead && s.SDKField == "" {
p.add(sat+".sdkField", "is required: a list-resolved selector names the element field it matches")
}
}
}

func (o Operation) validate(at string, p *problems) {
Expand Down
Loading
Loading