From 7a9cf68fd3447b65ac9b58db72b6efc9130fd83c Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Fri, 16 Oct 2026 13:48:24 +0200 Subject: [PATCH 01/18] refactor(client): keep the URLs of the API docs in client/openapi The generator downloads the same documents as meshstack api-docs. Co-Authored-By: Claude Opus 5.5 --- client/openapi/urls.go | 10 ++++++++++ internal/apidocs/apidocs.go | 9 ++------- 2 files changed, 12 insertions(+), 7 deletions(-) create mode 100644 client/openapi/urls.go diff --git a/client/openapi/urls.go b/client/openapi/urls.go new file mode 100644 index 00000000..68bfa8c5 --- /dev/null +++ b/client/openapi/urls.go @@ -0,0 +1,10 @@ +package openapi + +import "github.com/meshcloud/meshstack-cli/client/types/xurl" + +var ( + // ReleasedUrl serves the API docs of the latest meshStack release. + ReleasedUrl = xurl.MustParsef("https://docs.meshcloud.io/api/meshstack-openapi-docs.json") + // DevUrl serves the API docs of what is merged to meshStack's develop branch. + DevUrl = xurl.MustParsef("https://docs.dev.meshcloud.io/api/meshstack-openapi-docs.json") +) diff --git a/internal/apidocs/apidocs.go b/internal/apidocs/apidocs.go index 45bd30ba..e7b84d80 100644 --- a/internal/apidocs/apidocs.go +++ b/internal/apidocs/apidocs.go @@ -18,11 +18,6 @@ import ( "github.com/meshcloud/meshstack-cli/internal/version" ) -var ( - releasedUrl = xurl.MustParsef("https://docs.meshcloud.io/api/meshstack-openapi-docs.json") - devUrl = xurl.MustParsef("https://docs.dev.meshcloud.io/api/meshstack-openapi-docs.json") -) - // urlEnv names the document of the fake meshStack that the tests and the demo serve. It is no // setting.Setting, and no help names it, so that nobody else comes to rely on it. const urlEnv = "MESHSTACK_API_DOCS_URL" @@ -53,9 +48,9 @@ func Load(ctx context.Context, opts Options) (spec openapi.Spec, wait func(), er ErrNoConfigDir, dir, config.DirectorySetting.EnvKey()) } - url, path, conditionalGet := releasedUrl, dir.Join("api-docs.json"), false + url, path, conditionalGet := openapi.ReleasedUrl, dir.Join("api-docs.json"), false if cliVersion, parseErr := version.Parse(opts.Version); opts.Dev || parseErr != nil || !cliVersion.IsRelease() { - url, path, conditionalGet = devUrl, dir.Join("api-docs-dev.json"), true + url, path, conditionalGet = openapi.DevUrl, dir.Join("api-docs-dev.json"), true } if customEnv := os.Getenv(urlEnv); customEnv != "" { var custom xurl.URL From 6d43c0b9a57465d9a2c998c0bdc3c004d0723c78 Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Fri, 16 Oct 2026 13:48:24 +0200 Subject: [PATCH 02/18] feat(client): read the schema model and referenced responses of the API docs The derived spec refers to its responses and parameters as components and declares parameters on the path, which the reader missed. Schema now carries allOf, oneOf, discriminator, enum, nullable, readOnly, writeOnly and additionalProperties. Co-Authored-By: Claude Opus 5.5 --- client/openapi/doc.go | 129 ++++++++++---------------- client/openapi/operation.go | 175 ++++++++++++++++++++++++++++++++++++ client/openapi/schema.go | 135 ++++++++++++++++++++++++++++ client/openapi/spec.go | 25 +++++- 4 files changed, 381 insertions(+), 83 deletions(-) create mode 100644 client/openapi/operation.go create mode 100644 client/openapi/schema.go diff --git a/client/openapi/doc.go b/client/openapi/doc.go index fc22e067..89ad079d 100644 --- a/client/openapi/doc.go +++ b/client/openapi/doc.go @@ -2,7 +2,6 @@ package openapi import ( "cmp" - "encoding/json/jsontext" "encoding/json/v2" "fmt" "slices" @@ -54,82 +53,61 @@ func (f Field) Depth() int { return strings.Count(f.Path, ".") } -type schema struct { - Ref string `json:"$ref"` - Type string `json:"type"` - Format string `json:"format"` - Description string `json:"description"` - Required []string `json:"required"` - Properties object `json:"properties"` - Items jsontext.Value `json:"items"` - resolvedRef string -} - // Doc reads the operation of a Spec, or of a selection of it, which keeps every component. func (s Spec) Doc(operation Operation) (OperationDoc, error) { var parsed struct { Summary string `json:"summary"` Description string `json:"description"` Parameters []struct { - Name string `json:"name"` - In string `json:"in"` - Required bool `json:"required"` Description string `json:"description"` - Schema schema `json:"schema"` } `json:"parameters"` - RequestBody struct { - Content object `json:"content"` - } `json:"requestBody"` Responses object `json:"responses"` } if err := json.Unmarshal(operation.raw, &parsed); err != nil { return OperationDoc{}, err } + schemas, err := operation.Schemas() + if err != nil { + return OperationDoc{}, err + } doc := OperationDoc{Summary: parsed.Summary, Description: parsed.Description} - for _, parameter := range parsed.Parameters { + for i, parameter := range schemas.Parameters { + schema := cmp.Or(parameter.Schema, &Schema{}) doc.Parameters = append(doc.Parameters, Parameter{ Name: parameter.Name, In: parameter.In, Required: parameter.Required, - Type: parameter.Schema.typeName(nil), - Description: parameter.Description, + Type: schema.typeName(nil, ""), + Description: parsed.Parameters[i].Description, }) } - var err error - if doc.RequestBodies, err = s.bodies(parsed.RequestBody.Content); err != nil { + if doc.RequestBodies, err = s.bodies(schemas.RequestBody); err != nil { return doc, fmt.Errorf("request body: %w", err) } - for _, member := range parsed.Responses { - var response struct { + for i, response := range schemas.Responses { + var described struct { Description string `json:"description"` - Content object `json:"content"` } - if err := json.Unmarshal(member.value, &response); err != nil { - return doc, fmt.Errorf("response %s: %w", member.name, err) + if err := json.Unmarshal(parsed.Responses[i].value, &described); err != nil { + return doc, fmt.Errorf("response %s: %w", response.Status, err) } bodies, err := s.bodies(response.Content) if err != nil { - return doc, fmt.Errorf("response %s: %w", member.name, err) + return doc, fmt.Errorf("response %s: %w", response.Status, err) } - doc.Responses = append(doc.Responses, Response{Status: member.name, Description: response.Description, Bodies: bodies}) + doc.Responses = append(doc.Responses, Response{Status: response.Status, Description: described.Description, Bodies: bodies}) } return doc, nil } -func (s Spec) bodies(c object) ([]Body, error) { +func (s Spec) bodies(contents []Content) ([]Body, error) { var bodies []Body - for _, member := range c { - var mediaType struct { - Schema jsontext.Value `json:"schema"` - } - if err := json.Unmarshal(member.value, &mediaType); err != nil { - return nil, err - } - body := Body{MediaType: member.name} - if mediaType.Schema != nil { + for _, content := range contents { + body := Body{MediaType: content.MediaType} + if content.Schema != nil { var err error - if body.Fields, err = s.fields("", mediaType.Schema, nil); err != nil { - return nil, fmt.Errorf("%s: %w", member.name, err) + if body.Fields, err = s.fields("", content.Schema, nil); err != nil { + return nil, fmt.Errorf("%s: %w", content.MediaType, err) } } bodies = append(bodies, body) @@ -138,27 +116,24 @@ func (s Spec) bodies(c object) ([]Body, error) { } // fields stops at a $ref it is already inside of, because a schema may contain itself. -func (s Spec) fields(path string, raw jsontext.Value, refs []string) ([]Field, error) { - resolved, refs, err := s.resolve(raw, refs) - if err != nil || resolved.Ref != "" { - return nil, err +func (s Spec) fields(path string, schema *Schema, refs []string) ([]Field, error) { + resolved, refs := s.resolve(schema, refs) + if resolved.Ref != "" { + return nil, nil } if resolved.Items != nil { return s.fields(path+"[]", resolved.Items, refs) } var fields []Field for _, property := range resolved.Properties { - child, _, err := s.resolve(property.value, refs) - if err != nil { - return nil, err - } + child, _ := s.resolve(property.Schema, refs) field := Field{ - Path: strings.TrimPrefix(path+"."+property.name, "."), - Type: child.typeName(s.itemsOf(child, refs)), - Required: slices.Contains(resolved.Required, property.name), + Path: strings.TrimPrefix(path+"."+property.Name, "."), + Type: child.typeName(s.itemsOf(child, refs), property.Schema.RefName()), + Required: resolved.IsRequired(property.Name), Description: child.Description, } - below, err := s.fields(field.Path, property.value, refs) + below, err := s.fields(field.Path, property.Schema, refs) if err != nil { return nil, err } @@ -168,50 +143,44 @@ func (s Spec) fields(path string, raw jsontext.Value, refs []string) ([]Field, e } // resolve follows $ref into the components. A schema it cannot follow keeps its Ref. -func (s Spec) resolve(raw jsontext.Value, refs []string) (schema, []string, error) { - var resolved schema - if err := json.Unmarshal(raw, &resolved); err != nil { - return resolved, refs, err - } - resolvedRef := resolved.Ref +func (s Spec) resolve(schema *Schema, refs []string) (*Schema, []string) { + resolved := schema for resolved.Ref != "" && !slices.Contains(refs, resolved.Ref) { - i := slices.IndexFunc(s.components, func(c component) bool { return c.ref() == resolved.Ref }) - if i < 0 { - return resolved, refs, nil + target, err := s.Schema(resolved.RefName()) + if err != nil { + return resolved, refs } refs = append(slices.Clip(refs), resolved.Ref) - description := resolved.Description - resolved = schema{} - if err := json.Unmarshal(s.components[i].value, &resolved); err != nil { - return resolved, refs, err - } - resolved.Description = cmp.Or(description, resolved.Description) + target.Description = cmp.Or(resolved.Description, target.Description) + resolved = target } - resolved.resolvedRef = resolvedRef - return resolved, refs, nil + return resolved, refs } -func (s Spec) itemsOf(array schema, refs []string) *schema { +func (s Spec) itemsOf(array *Schema, refs []string) *Schema { if array.Items == nil { return nil } - items, _, err := s.resolve(array.Items, refs) - if err != nil { - return nil + items, _ := s.resolve(array.Items, refs) + if items.Ref == "" && array.Items.Ref != "" { + named := *items + named.Ref = array.Items.Ref + return &named } - return &items + return items } -func (s schema) typeName(items *schema) string { +// typeName names the component a schema was resolved from, given as resolvedFrom. +func (s *Schema) typeName(items *Schema, resolvedFrom string) string { name := s.Type - if _, component, isRef := strings.CutLast(cmp.Or(s.Ref, s.resolvedRef), "/"); isRef { + if component := cmp.Or(s.RefName(), resolvedFrom); component != "" { name = component } if s.Format != "" { name += " (" + s.Format + ")" } if items != nil { - return strings.TrimSpace(name + " of " + items.typeName(nil)) + return strings.TrimSpace(name + " of " + items.typeName(nil, "")) } return name } diff --git a/client/openapi/operation.go b/client/openapi/operation.go new file mode 100644 index 00000000..135b199b --- /dev/null +++ b/client/openapi/operation.go @@ -0,0 +1,175 @@ +package openapi + +import ( + "encoding/json/jsontext" + "encoding/json/v2" + "fmt" + "slices" +) + +// OperationSchemas are the parameters and bodies of an operation, with their schemas. +type OperationSchemas struct { + Parameters []ParameterSchema + RequestBody []Content + Responses []ResponseContent +} + +type ParameterSchema struct { + Name, In string + Required bool + Schema *Schema +} + +// Content is the body of one media type. +type Content struct { + MediaType string + Schema *Schema +} + +type ResponseContent struct { + Status string + Content []Content +} + +func (o Operation) Schemas() (OperationSchemas, error) { + type content struct { + Content object `json:"content"` + } + var parsed struct { + Parameters []ParameterSchema `json:"parameters"` + RequestBody content `json:"requestBody"` + Responses object `json:"responses"` + } + if err := json.Unmarshal(o.raw, &parsed); err != nil { + return OperationSchemas{}, err + } + schemas := OperationSchemas{Parameters: parsed.Parameters} + var err error + if schemas.RequestBody, err = contents(parsed.RequestBody.Content); err != nil { + return schemas, fmt.Errorf("request body: %w", err) + } + for _, response := range parsed.Responses { + var c content + if err := json.Unmarshal(response.value, &c); err != nil { + return schemas, fmt.Errorf("response %s: %w", response.name, err) + } + bodies, err := contents(c.Content) + if err != nil { + return schemas, fmt.Errorf("response %s: %w", response.name, err) + } + schemas.Responses = append(schemas.Responses, ResponseContent{response.name, bodies}) + } + return schemas, nil +} + +func (p *ParameterSchema) UnmarshalJSONFrom(dec *jsontext.Decoder) error { + var wire struct { + Name string `json:"name"` + In string `json:"in"` + Required bool `json:"required"` + Schema *Schema `json:"schema"` + } + if err := json.UnmarshalDecode(dec, &wire); err != nil { + return err + } + *p = ParameterSchema(wire) + return nil +} + +func contents(members object) ([]Content, error) { + var result []Content + for _, m := range members { + var mediaType struct { + Schema *Schema `json:"schema"` + } + if err := json.Unmarshal(m.value, &mediaType); err != nil { + return nil, fmt.Errorf("%s: %w", m.name, err) + } + result = append(result, Content{m.name, mediaType.Schema}) + } + return result, nil +} + +// inlineRefs replaces a $ref to a response, parameter or request body component by the component, +// and adds the parameters of the path, so that an operation reads as one object. +func inlineRefs(operation, pathParameters jsontext.Value, refs map[string]jsontext.Value) (jsontext.Value, error) { + var members object + if err := json.Unmarshal(operation, &members); err != nil { + return nil, err + } + resolve := func(value jsontext.Value) (jsontext.Value, error) { + var ref struct { + Ref string `json:"$ref"` + } + if value.Kind() != '{' { + return value, nil + } + if err := json.Unmarshal(value, &ref); err != nil || ref.Ref == "" { + return value, err + } + if resolved, ok := refs[ref.Ref]; ok { + return resolved, nil + } + return nil, fmt.Errorf("cannot resolve %s", ref.Ref) + } + resolveAll := func(values []jsontext.Value) error { + for i := range values { + var err error + if values[i], err = resolve(values[i]); err != nil { + return err + } + } + return nil + } + + var parameters []jsontext.Value + if pathParameters != nil { + if err := json.Unmarshal(pathParameters, ¶meters); err != nil { + return nil, err + } + } + hasParameters := false + for i, m := range members { + var err error + switch m.name { + case "parameters": + hasParameters = true + var own []jsontext.Value + if err = json.Unmarshal(m.value, &own); err != nil { + return nil, err + } + combined := append(slices.Clone(parameters), own...) + if err = resolveAll(combined); err != nil { + return nil, err + } + members[i].value, err = json.Marshal(combined) + case "requestBody": + members[i].value, err = resolve(m.value) + case "responses": + var responses object + if err = json.Unmarshal(m.value, &responses); err != nil { + return nil, err + } + for j := range responses { + if responses[j].value, err = resolve(responses[j].value); err != nil { + return nil, err + } + } + members[i].value, err = json.Marshal(responses) + } + if err != nil { + return nil, err + } + } + if !hasParameters && len(parameters) > 0 { + if err := resolveAll(parameters); err != nil { + return nil, err + } + value, err := json.Marshal(parameters) + if err != nil { + return nil, err + } + members = append(members, member{"parameters", value}) + } + return json.Marshal(members) +} diff --git a/client/openapi/schema.go b/client/openapi/schema.go new file mode 100644 index 00000000..965afa1d --- /dev/null +++ b/client/openapi/schema.go @@ -0,0 +1,135 @@ +package openapi + +import ( + "encoding/json/jsontext" + "encoding/json/v2" + "fmt" + "slices" + "strings" +) + +// Schema is an OpenAPI 3.0 schema object, with the members meshStack's API docs use. +type Schema struct { + Ref string `json:"$ref"` + Type string `json:"type"` + Format string `json:"format"` + Description string `json:"description"` + Required []string `json:"required"` + Properties Properties `json:"properties"` + Items *Schema `json:"items"` + // AdditionalProperties is nil for none and for false, and an empty schema for true. + AdditionalProperties *Schema `json:"-"` + AllOf []*Schema `json:"allOf"` + OneOf []*Schema `json:"oneOf"` + Discriminator *Discriminator `json:"discriminator"` + Enum []any `json:"enum"` + Nullable bool `json:"nullable"` + ReadOnly bool `json:"readOnly"` + WriteOnly bool `json:"writeOnly"` +} + +type Discriminator struct { + PropertyName string `json:"propertyName"` + // Mapping is in the order of the document. + Mapping []DiscriminatorMapping `json:"-"` +} + +type DiscriminatorMapping struct { + Value string + Ref string +} + +type Properties []Property + +type Property struct { + Name string + Schema *Schema +} + +func (s *Schema) UnmarshalJSONFrom(dec *jsontext.Decoder) error { + type plain Schema + var wire struct { + plain `json:",embed"` + + AdditionalProperties jsontext.Value `json:"additionalProperties"` + } + if err := json.UnmarshalDecode(dec, &wire); err != nil { + return err + } + *s = Schema(wire.plain) + if kind := wire.AdditionalProperties.Kind(); kind == 't' || kind == '{' { + s.AdditionalProperties = &Schema{} + } + if wire.AdditionalProperties.Kind() == '{' { + return json.Unmarshal(wire.AdditionalProperties, s.AdditionalProperties) + } + return nil +} + +func (d *Discriminator) UnmarshalJSONFrom(dec *jsontext.Decoder) error { + var wire struct { + PropertyName string `json:"propertyName"` + Mapping object `json:"mapping"` + } + if err := json.UnmarshalDecode(dec, &wire); err != nil { + return err + } + d.PropertyName = wire.PropertyName + for _, m := range wire.Mapping { + var ref string + if err := json.Unmarshal(m.value, &ref); err != nil { + return err + } + d.Mapping = append(d.Mapping, DiscriminatorMapping{m.name, ref}) + } + return nil +} + +func (p *Properties) UnmarshalJSONFrom(dec *jsontext.Decoder) error { + var members object + if err := members.UnmarshalJSONFrom(dec); err != nil { + return err + } + *p = (*p)[:0] + for _, m := range members { + var schema Schema + if err := json.Unmarshal(m.value, &schema); err != nil { + return fmt.Errorf("property %s: %w", m.name, err) + } + *p = append(*p, Property{m.name, &schema}) + } + return nil +} + +func (s *Schema) Property(name string) (*Schema, bool) { + for _, property := range s.Properties { + if property.Name == name { + return property.Schema, true + } + } + return nil, false +} + +func (s *Schema) IsRequired(property string) bool { + return slices.Contains(s.Required, property) +} + +// RefName is the name of the component a $ref points to, or empty. +func (s *Schema) RefName() string { + _, name, _ := strings.CutLast(s.Ref, "/") + return name +} + +// Schema reads the schema component of the name. +func (s Spec) Schema(name string) (*Schema, error) { + for _, c := range s.components { + if c.kind == "schemas" && c.name == name { + var schema Schema + if err := json.Unmarshal(c.value, &schema); err != nil { + return nil, fmt.Errorf("schema %s: %w", name, err) + } + return &schema, nil + } + } + return nil, fmt.Errorf("the API docs have no schema %s", name) +} diff --git a/client/openapi/spec.go b/client/openapi/spec.go index 67d2fe62..77fa7ecc 100644 --- a/client/openapi/spec.go +++ b/client/openapi/spec.go @@ -24,6 +24,7 @@ type Spec struct { type Operation struct { Method string + OperationId string PathTemplate PathTemplate // Kind is the meshObject kind below whose path the operation is, such as meshBuildingBlock, or // empty outside the path of a kind. @@ -60,13 +61,27 @@ func Parse(r io.Reader) (Spec, error) { return Spec{}, fmt.Errorf("cannot parse the OpenAPI document: %w", err) } spec := Spec{document: document} + refs := map[string]jsontext.Value{} + for kind, members := range parsed.Components { + for _, c := range members { + refs[component{c, kind}.ref()] = c.value + } + } for _, path := range parsed.Paths { var methods object if err := json.Unmarshal(path.value, &methods); err != nil { return Spec{}, fmt.Errorf("cannot parse path %s: %w", path.name, err) } + pathParameters, _ := methods.get("parameters") for _, method := range methods { - operation, err := parseOperation(strings.ToUpper(method.name), path.name, method.value) + if !slices.Contains(httpMethods, method.name) { + continue + } + raw, err := inlineRefs(method.value, pathParameters, refs) + if err != nil { + return Spec{}, fmt.Errorf("cannot parse %s %s: %w", method.name, path.name, err) + } + operation, err := parseOperation(strings.ToUpper(method.name), path.name, raw) if err != nil { return Spec{}, fmt.Errorf("cannot parse %s %s: %w", method.name, path.name, err) } @@ -82,6 +97,8 @@ func Parse(r io.Reader) (Spec, error) { return spec, nil } +var httpMethods = []string{"get", "put", "post", "delete", "options", "head", "patch", "trace"} + func parseOperation(method, pathTemplate string, raw jsontext.Value) (Operation, error) { type content struct { Content map[string]jsontext.Value `json:"content"` @@ -99,7 +116,7 @@ func parseOperation(method, pathTemplate string, raw jsontext.Value) (Operation, names = slices.AppendSeq(names, maps.Keys(response.Content)) } slices.Sort(names) - operation := Operation{Method: method, PathTemplate: PathTemplate(pathTemplate), raw: raw} + operation := Operation{Method: method, PathTemplate: PathTemplate(pathTemplate), OperationId: parsed.OperationId, raw: raw} for _, name := range slices.Compact(names) { operation.MediaTypes = append(operation.MediaTypes, newMediaType(name, parsed.OperationId)) } @@ -154,7 +171,8 @@ type Selector struct { // Method is empty for every method. Method string // Path is a request path such as /api/meshobjects/meshtenants/, or empty for every path. - Path string + Path string + OperationId string // ApiVersion is zero for the latest version each operation offers. ApiVersion ApiVersion } @@ -166,6 +184,7 @@ func (s Selector) IsZero() bool { func (s Selector) matches(operation Operation) bool { return (s.Method == "" || strings.EqualFold(operation.Method, s.Method)) && (s.Path == "" || operation.PathTemplate.matches(s.Path)) && + (s.OperationId == "" || operation.OperationId == s.OperationId) && (s.Kind == "" || strings.EqualFold(operation.Kind, s.Kind)) && (s.Action == "" || operation.Action == s.Action) } From 8a91f23edbdfa60fbfc81049460f3449d9d6ea79 Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Fri, 16 Oct 2026 13:49:50 +0200 Subject: [PATCH 03/18] feat(client): read a whole listing with pages.ReadAll Co-Authored-By: Claude Opus 5.5 --- client/pages/pages.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 client/pages/pages.go diff --git a/client/pages/pages.go b/client/pages/pages.go new file mode 100644 index 00000000..273a5925 --- /dev/null +++ b/client/pages/pages.go @@ -0,0 +1,16 @@ +// Package pages reads the listings of the generated clients, which page through meshStack lazily. +package pages + +import "iter" + +// ReadAll returns the items read before an error, together with it. +func ReadAll[T any](seq iter.Seq2[T, error]) ([]T, error) { + var items []T + for item, err := range seq { + if err != nil { + return items, err + } + items = append(items, item) + } + return items, nil +} From a0d239a369686786c749b30d44bc36b8a5a7868b Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Fri, 16 Oct 2026 13:49:50 +0200 Subject: [PATCH 04/18] feat(client): read and expand the HAL links of a meshObject Co-Authored-By: Claude Opus 5.5 --- client/meshobject/links.go | 117 ++++++++++++++++++++++++++++++++ client/meshobject/links_test.go | 50 ++++++++++++++ 2 files changed, 167 insertions(+) create mode 100644 client/meshobject/links.go create mode 100644 client/meshobject/links_test.go diff --git a/client/meshobject/links.go b/client/meshobject/links.go new file mode 100644 index 00000000..d42afcf7 --- /dev/null +++ b/client/meshobject/links.go @@ -0,0 +1,117 @@ +// Package meshobject holds the types that several meshObject kinds share. The generator places +// a schema here once more than one kind and version reaches it. +package meshobject + +import ( + "encoding/json/jsontext" + "encoding/json/v2" + "fmt" + "net/url" + "regexp" + "strings" +) + +// Links are the HAL links of a meshObject by relation. HAL writes a relation as one link or as an +// array of them, and Links reads both as a slice. +// +//nolint:recvcheck // MarshalJSONTo takes a value, so that json.Marshal finds it on a field that is not addressable +type Links map[string][]Link + +type Link struct { + Href string `json:"href"` + Templated bool `json:"templated,omitzero"` + Type string `json:"type,omitzero"` + Deprecation string `json:"deprecation,omitzero"` + Profile string `json:"profile,omitzero"` + Name string `json:"name,omitzero"` + Title string `json:"title,omitzero"` + Hreflang string `json:"hreflang,omitzero"` +} + +func (l *Links) UnmarshalJSONFrom(dec *jsontext.Decoder) error { + var relations map[string]jsontext.Value + if err := json.UnmarshalDecode(dec, &relations); err != nil { + return err + } + *l = make(Links, len(relations)) + for rel, value := range relations { + var links []Link + if value.Kind() != '[' { + value = jsontext.Value("[" + string(value) + "]") + } + if err := json.Unmarshal(value, &links); err != nil { + return fmt.Errorf("link %s: %w", rel, err) + } + (*l)[rel] = links + } + return nil +} + +func (l Links) MarshalJSONTo(enc *jsontext.Encoder) error { + relations := make(map[string]any, len(l)) + for rel, links := range l { + if len(links) == 1 { + relations[rel] = links[0] + } else { + relations[rel] = links + } + } + return json.MarshalEncode(enc, relations, json.Deterministic(true)) +} + +// Href is the first link of the relation, with a template left unexpanded. +func (l Links) Href(rel string) (string, bool) { + if len(l[rel]) == 0 { + return "", false + } + return l[rel][0].Href, true +} + +var expressionRe = regexp.MustCompile(`\{([^}]*)\}`) + +// Expand fills the template of the relation's first link with vars, leaving out a variable that +// vars lacks. It knows the expressions meshStack writes, of RFC 6570: {var}, {+var}, {?var,...} +// and {&var,...}. +func (l Links) Expand(rel string, vars map[string]string) (string, error) { + href, ok := l.Href(rel) + if !ok { + return "", fmt.Errorf("no link %s", rel) + } + if !l[rel][0].Templated { + return href, nil + } + var err error + expanded := expressionRe.ReplaceAllStringFunc(href, func(expression string) string { + expression = strings.Trim(expression, "{}") + operator := "" + if expression != "" && strings.ContainsAny(expression[:1], "+?&#./;") { + operator, expression = expression[:1], expression[1:] + } + var parts []string + for name := range strings.SplitSeq(expression, ",") { + value, set := vars[name] + if !set { + continue + } + switch operator { + case "": + parts = append(parts, url.PathEscape(value)) + case "+": + parts = append(parts, value) + case "?", "&": + parts = append(parts, url.QueryEscape(name)+"="+url.QueryEscape(value)) + default: + err = fmt.Errorf("link %s: the expression {%s%s} is not supported", rel, operator, expression) + } + } + switch { + case len(parts) == 0: + return "" + case operator == "?" || operator == "&": + return operator + strings.Join(parts, "&") + default: + return strings.Join(parts, ",") + } + }) + return expanded, err +} diff --git a/client/meshobject/links_test.go b/client/meshobject/links_test.go new file mode 100644 index 00000000..ee32a404 --- /dev/null +++ b/client/meshobject/links_test.go @@ -0,0 +1,50 @@ +package meshobject_test + +import ( + "encoding/json/v2" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/meshcloud/meshstack-cli/client/meshobject" +) + +func TestLinks(t *testing.T) { + var links meshobject.Links + require.NoError(t, json.Unmarshal([]byte(`{ + "self": {"href": "https://meshstack/api/meshobjects/meshbuildingblockruns/r1"}, + "updateSource": {"href": "https://meshstack/api/meshobjects/meshbuildingblockruns/r1/status/source/{sourceId}", "templated": true}, + "runs": {"href": "https://meshstack/api/meshobjects/meshbuildingblockruns{?buildingBlockUuid,sort}", "templated": true}, + "items": [{"href": "https://meshstack/a"}, {"href": "https://meshstack/b"}] + }`), &links)) + + t.Run("reads one link and an array of links", func(t *testing.T) { + href, ok := links.Href("self") + assert.True(t, ok) + assert.Equal(t, "https://meshstack/api/meshobjects/meshbuildingblockruns/r1", href) + assert.Len(t, links["items"], 2) + _, ok = links.Href("missing") + assert.False(t, ok) + }) + + t.Run("expands a template, leaving out what vars lacks", func(t *testing.T) { + expanded, err := links.Expand("updateSource", map[string]string{"sourceId": "a b/c"}) + require.NoError(t, err) + assert.Equal(t, "https://meshstack/api/meshobjects/meshbuildingblockruns/r1/status/source/a%20b%2Fc", expanded) + + expanded, err = links.Expand("runs", map[string]string{"buildingBlockUuid": "b1"}) + require.NoError(t, err) + assert.Equal(t, "https://meshstack/api/meshobjects/meshbuildingblockruns?buildingBlockUuid=b1", expanded) + + expanded, err = links.Expand("self", nil) + require.NoError(t, err) + assert.Equal(t, "https://meshstack/api/meshobjects/meshbuildingblockruns/r1", expanded) + }) + + t.Run("writes one link as an object again", func(t *testing.T) { + out, err := json.Marshal(meshobject.Links{"self": links["self"], "items": links["items"]}) + require.NoError(t, err) + assert.JSONEq(t, `{"self": {"href": "https://meshstack/api/meshobjects/meshbuildingblockruns/r1"}, "items": [{"href": "https://meshstack/a"}, {"href": "https://meshstack/b"}]}`, string(out)) + }) +} From fee1f31605bf737906a4a4c9be687b32af58b7f3 Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Fri, 16 Oct 2026 13:49:50 +0200 Subject: [PATCH 05/18] feat(client): send the requests of a generated client The generated clients leave kind and apiVersion out of their types, so Api adds them to a meshObject body and checks them on the answer. Co-Authored-By: Claude Opus 5.5 --- client/internal/api.go | 159 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 client/internal/api.go diff --git a/client/internal/api.go b/client/internal/api.go new file mode 100644 index 00000000..a10a8f07 --- /dev/null +++ b/client/internal/api.go @@ -0,0 +1,159 @@ +package internal + +import ( + "context" + "encoding/json/jsontext" + "encoding/json/v2" + "errors" + "fmt" + "iter" + "net/url" + "reflect" + "strings" + + "github.com/meshcloud/meshstack-cli/client/openapi" + "github.com/meshcloud/meshstack-cli/internal/http" + internaljson "github.com/meshcloud/meshstack-cli/internal/json" +) + +// Api is one version of a meshObject kind as the generated clients call it. +type Api struct { + HttpClient + + Kind string + ApiVersion string + MediaType string + // Path is that of the kind's collection, such as /api/meshobjects/meshworkspaces. + Path string + // EmbeddedRel names the objects in a page of the listing. + EmbeddedRel string +} + +// Call is one request of a generated client. +type Call struct { + Method string + Path string + Query any + // SendsMeshObject and AnswersMeshObject mark a body and an answer that carry the kind and + // apiVersion, which the generated types leave out. + SendsMeshObject bool + AnswersMeshObject bool +} + +// Path fills the parameters of the template in their order. +func Path(template string, values ...string) string { + var path strings.Builder + rest := template + for _, value := range values { + before, after, ok := strings.Cut(rest, "{") + _, after, closed := strings.Cut(after, "}") + if !ok || !closed { + panic(fmt.Sprintf("%s has fewer parameters than %d", template, len(values))) + } + path.WriteString(before + url.PathEscape(value)) + rest = after + } + return path.String() + rest +} + +// Do answers a zero R for an empty answer, which only an R of type any expects. +func (a Api) Do[R any](ctx context.Context, call Call, body any, options ...http.RequestOption) (R, error) { + var result R + options = append(options, http.WithAccept(a.MediaType)) + if call.Query != nil { + options = append(options, http.WithUrlQuery(call.Query)) + } + if call.Method == http.MethodPut || call.Method == http.MethodDelete { + options = append(options, http.Retryable()) + } + if body != nil { + payload, err := a.payload(body, call.SendsMeshObject) + if err != nil { + return result, err + } + options = append(options, http.WithJsonPayload(payload, a.MediaType)) + } + answer, err := a.DoRequest[[]byte](ctx, call.Method, a.EndpointUrl.JoinPath(call.Path), options...) + if err != nil { + return result, err + } + if len(answer) == 0 { + if t := reflect.TypeFor[R](); t.Kind() == reflect.Interface && t.NumMethod() == 0 { + return result, nil + } + return result, fmt.Errorf("unexpected empty response body from %s %s", call.Method, call.Path) + } + return result, a.decode(answer, call.AnswersMeshObject, &result) +} + +// List pages through the objects that call lists. +func (a Api) List[T any](ctx context.Context, call Call) iter.Seq2[T, error] { + return func(yield func(T, error) bool) { + var noItem T + for pageNumber := 0; ; pageNumber++ { + response, err := a.Do[paginatedResponse[jsontext.Value]](ctx, Call{Method: call.Method, Path: call.Path, Query: call.Query}, nil, + http.WithUrlQuery(map[string]any{"page": pageNumber})) + if err != nil && pageNumber > 0 { + err = fmt.Errorf("cannot read page %d of the list: %w", pageNumber, err) + } + if err != nil { + yield(noItem, err) + return + } + items, ok := response.Embedded[a.EmbeddedRel] + if !ok && response.Page.TotalElements > 0 { + yield(noItem, fmt.Errorf("embedded key %s not found in paginated response", a.EmbeddedRel)) + return + } + for _, raw := range items { + var item T + if err := a.decode(raw, call.AnswersMeshObject, &item); err != nil { + yield(noItem, err) + return + } + if !yield(item, nil) { + return + } + } + if response.Page.Number >= response.Page.TotalPages-1 { + return + } + } + } +} + +// MeshObjectApi lets the raw client reach the kind in the same version. +func (a Api) MeshObjectApi() MeshObjectApi { + return MeshObjectApi{a.AuthorizedClient, a.Kind, a.ApiVersion, a.EndpointUrl.JoinPath(a.Path)} +} + +func (a Api) payload(body any, meshObject bool) (jsontext.Value, error) { + encoded, err := internaljson.Marshal(body) + if err != nil || !meshObject { + return encoded, err + } + version, err := openapi.ParseApiVersion(a.ApiVersion) + if err != nil { + return nil, err + } + return openapi.MediaType{Name: a.MediaType, Kind: a.Kind, ApiVersion: version}.WithKindAndApiVersion(encoded) +} + +func (a Api) decode(answer []byte, meshObject bool, target any) error { + if meshObject { + var header struct { + Kind string `json:"kind"` + ApiVersion string `json:"apiVersion"` + } + if err := json.Unmarshal(answer, &header); err != nil { + return fmt.Errorf("parsing response body as JSON failed: %w", err) + } + if header.Kind != a.Kind || header.ApiVersion != a.ApiVersion { + return errors.New("meshStack answered " + header.Kind + " " + header.ApiVersion + " for " + a.Kind + " " + a.ApiVersion) + } + } + if err := json.Unmarshal(answer, target); err != nil { + return fmt.Errorf("parsing response body as JSON failed: %w", err) + } + return nil +} From e79bc83ae9da2bf7516f4cb2f771d611a380ee23 Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Fri, 16 Oct 2026 14:00:06 +0200 Subject: [PATCH 06/18] feat(client): generate the types and a typed client of meshObject kinds A repository configures the generator in Go, with a TypeMapper for what the spec leaves open, and selects operations by kind, action and version. A schema that only one kind and version reaches lands in that kind's package, one that several reach in a shared package. Co-Authored-By: Claude Opus 5.5 --- .golangci.yml | 5 + client/openapi/generator/generated.go.tmpl | 131 +++++ client/openapi/generator/generator.go | 115 ++++ client/openapi/generator/generator_test.go | 66 +++ client/openapi/generator/main.go | 63 +++ client/openapi/generator/model.go | 520 ++++++++++++++++++ client/openapi/generator/names.go | 47 ++ client/openapi/generator/operations.go | 268 +++++++++ client/openapi/generator/place.go | 190 +++++++ client/openapi/generator/render.go | 262 +++++++++ .../golden/meshobject/generated.go.golden | 84 +++ .../golden/meshother/v1/generated.go.golden | 47 ++ .../golden/meshthing/v2/generated.go.golden | 165 ++++++ client/openapi/generator/testdata/spec.json | 189 +++++++ 14 files changed, 2152 insertions(+) create mode 100644 client/openapi/generator/generated.go.tmpl create mode 100644 client/openapi/generator/generator.go create mode 100644 client/openapi/generator/generator_test.go create mode 100644 client/openapi/generator/main.go create mode 100644 client/openapi/generator/model.go create mode 100644 client/openapi/generator/names.go create mode 100644 client/openapi/generator/operations.go create mode 100644 client/openapi/generator/place.go create mode 100644 client/openapi/generator/render.go create mode 100644 client/openapi/generator/testdata/golden/meshobject/generated.go.golden create mode 100644 client/openapi/generator/testdata/golden/meshother/v1/generated.go.golden create mode 100644 client/openapi/generator/testdata/golden/meshthing/v2/generated.go.golden create mode 100644 client/openapi/generator/testdata/spec.json diff --git a/.golangci.yml b/.golangci.yml index 4ecd93c9..51074da5 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -100,6 +100,11 @@ linters: linters: - forbidigo text: one HTTP client + # The generator downloads the API docs in a go:generate run, where no front end exists. + - path: client/openapi/generator/main\.go + linters: + - forbidigo + text: user agent of the front end # The fake clock is a test's; production code waits on the real one. - path-except: _test\.go linters: diff --git a/client/openapi/generator/generated.go.tmpl b/client/openapi/generator/generated.go.tmpl new file mode 100644 index 00000000..3c3a83b7 --- /dev/null +++ b/client/openapi/generator/generated.go.tmpl @@ -0,0 +1,131 @@ +{{.Header}}{{comment .Doc}}package {{.Package}} + +import ( +{{- range .StdImports}} + {{quote .}} +{{- end}} +{{if .StdImports}}{{end}} +{{- range .Imports}} + {{quote .}} +{{- end}} +) +{{- if .Kind}} + +const ( + Kind = {{quote .Kind}} + ApiVersion = {{quote .ApiVersion}} + MediaType = {{quote .MediaType}} +) +{{- end}} +{{- if .Operations}} + +type Client struct { + api internal.Api +} + +func NewClient(httpClient internal.HttpClient) Client { + return Client{internal.Api{ + HttpClient: httpClient, + Kind: {{if .Kind}}Kind{{else}}""{{end}}, + ApiVersion: {{if .Kind}}ApiVersion{{else}}""{{end}}, + MediaType: {{if .Kind}}MediaType{{else}}{{quote .MediaType}}{{end}}, + Path: {{quote .Collection}}, + EmbeddedRel: {{quote .EmbeddedRel}}, + }} +} + +// Api lets the raw client of the CLI reach the same API version. +func (c Client) Api() internal.Api { + return c.api +} +{{- range .Operations}} + +{{comment .Doc}}func (c Client) {{.Name}}({{.Params}}) {{.Returns}} { +{{- if .List}} + return c.api.List[{{.Result}}](ctx, {{.Call}}) +{{- else if .Result}} + return c.api.Do[{{.Result}}](ctx, {{.Call}}, {{.Body}}) +{{- else}} + _, err := c.api.Do[any](ctx, {{.Call}}, {{.Body}}) + return err +{{- end}} +} +{{- end}} +{{- end}} +{{- range .Queries}} + +type {{.Name}} struct { +{{- range .Fields}} + {{.Name}} {{.Type}} {{.Tag}} +{{- end}} +} +{{- end}} +{{- range .Types}} +{{- $type := .}} + +{{comment .Doc}} +{{- if eq .Kind "enum"}}type {{.Name}} string + +const ( +{{- range .Values}} + {{.Const}} {{$type.Name}} = {{quote .Value}} +{{- end}} +) + +var {{.Plural}} = enum.Of[{{.Name}}]({{range $i, $v := .Values}}{{if $i}}, {{end}}{{quote $v.Value}}{{end}}) +{{- else if eq .Kind "union"}}type {{.Name}} struct { + {{.DiscField}} {{.DiscType}}{{if .Wrap}} `json:{{quote .DiscJSON}}`{{end}} +{{- range .Cases}} + {{.Field}} *{{.Type}}{{if $type.Wrap}} `json:"{{.JSON}},omitzero"`{{end}} +{{- end}} +} +{{- if not .Wrap}} + +func (u {{.Name}}) MarshalJSONTo(enc *jsontext.Encoder) error { + switch u.{{.DiscField}} { +{{- range .Cases}} + case {{quote .Value}}: + if u.{{.Field}} != nil { + return json.MarshalEncode(enc, struct { + Discriminator {{$type.DiscType}} `json:{{quote $type.DiscJSON}}` + *{{.Type}} `json:",embed"` + }{u.{{$type.DiscField}}, u.{{.Field}}}) + } +{{- end}} + } + return fmt.Errorf("{{.Name}} has no value for {{.DiscJSON}} %q", u.{{.DiscField}}) +} + +// UnmarshalJSONFrom leaves every variant nil for a {{.DiscJSON}} this client does not know yet. +func (u *{{.Name}}) UnmarshalJSONFrom(dec *jsontext.Decoder) error { + raw, err := dec.ReadValue() + if err != nil { + return err + } + var discriminator struct { + Value {{.DiscType}} `json:{{quote .DiscJSON}}` + } + if err := json.Unmarshal(raw, &discriminator); err != nil { + return err + } + *u = {{.Name}}{ {{- .DiscField}}: discriminator.Value} + switch discriminator.Value { +{{- range .Cases}} + case {{quote .Value}}: + u.{{.Field}} = new({{.Type}}) + return json.Unmarshal(raw, u.{{.Field}}) +{{- end}} + } + return nil +} +{{- end}} +{{- else}}type {{.Name}} struct { +{{- range .Fields}} +{{- if .Doc}} +{{comment .Doc}} +{{- else}} +{{end}} {{.Name}} {{.Type}} {{.Tag}} +{{- end}} +} +{{- end}} +{{- end}} diff --git a/client/openapi/generator/generator.go b/client/openapi/generator/generator.go new file mode 100644 index 00000000..ecc44e8b --- /dev/null +++ b/client/openapi/generator/generator.go @@ -0,0 +1,115 @@ +// Package generator writes Go types and a thin typed client for meshObject kinds of the meshStack +// API docs. A repository builds its Config in a small main package of its own, so that the config +// can carry functions such as the TypeMapper, and runs it with Main from a go:generate line. +package generator + +import ( + "cmp" + "fmt" + "path" + "slices" + "strings" + + "github.com/meshcloud/meshstack-cli/client/openapi" +) + +type Config struct { + // Module is the import path of the directory the packages are written below, such as + // github.com/meshcloud/meshstack-cli/client. + Module string + // Shared is the path below Module of the package for the schemas that several packages reach. + // It holds a hand-written Links type, which every _links field takes. + Shared string + Packages []Package + // MapType decides the Go type of a schema before the generator does. It must map every oneOf + // without a discriminator. + MapType TypeMapper +} + +// Package selects the operations of one meshObject kind in one API version, or of operation IDs. +type Package struct { + Kind string + // Actions are those of openapi.Operation, such as list, show or trigger-run. + Actions []string + ApiVersion string + // OperationIds select operations outside the path of a kind. + OperationIds []string + // Path below Config.Module defaults to the kind in lower case and the version's number, as + // meshworkspace/v2. + Path string + // Name of the Go package defaults to the kind in lower case. + Name string +} + +type GoType struct { + // Expr is the type as a file writes it, qualified by the last element of Import. + Expr string + Import string + // Container is a type whose zero value already says "absent", such as a slice, a map or an + // any. A field never points to it. + Container bool +} + +// Location names where a schema occurs: the component, and the dotted path of the property in it. +type Location struct { + Schema string + Property string +} + +func (l Location) String() string { + return strings.Trim(l.Schema+"."+l.Property, ".") +} + +func (l Location) child(property string) Location { + return Location{l.Schema, strings.Trim(l.Property+"."+property, ".")} +} + +type TypeMapper func(at Location, schema *openapi.Schema) (GoType, bool) + +const ( + header = "// Code generated by github.com/meshcloud/meshstack-cli/client/openapi/generator. DO NOT EDIT.\n\n" + runtimeImport = "github.com/meshcloud/meshstack-cli/client/internal" + enumImport = "github.com/meshcloud/meshstack-cli/client/types/enum" + fileName = "generated.go" +) + +// Generate returns the content of each generated file by its path below Config.Module. +func Generate(spec openapi.Spec, cfg Config) (map[string][]byte, error) { + g := newGenerator(spec, cfg) + for _, p := range cfg.Packages { + if err := g.addPackage(p); err != nil { + return nil, err + } + } + if err := g.place(); err != nil { + return nil, err + } + files := map[string][]byte{} + for _, p := range append(slices.Clone(g.packages), g.shared) { + if len(p.types) == 0 && len(p.operations) == 0 { + continue + } + source, err := g.render(p) + if err != nil { + return nil, fmt.Errorf("package %s: %w", p.Path, err) + } + files[path.Join(p.Path, fileName)] = source + } + return files, nil +} + +func (p Package) defaults() (Package, error) { + if p.Kind == "" && (p.Path == "" || p.Name == "") { + return p, fmt.Errorf("a package of operation IDs %v needs a Path and a Name", p.OperationIds) + } + if p.Kind != "" { + version, err := openapi.ParseApiVersion(p.ApiVersion) + if err != nil { + return p, fmt.Errorf("package of %s: %w", p.Kind, err) + } + number, _, _ := strings.Cut(strings.TrimPrefix(version.String(), "v"), "-") + p.Path = cmp.Or(p.Path, strings.ToLower(p.Kind)+"/v"+number) + p.Name = cmp.Or(p.Name, strings.ToLower(p.Kind)) + } + return p, nil +} diff --git a/client/openapi/generator/generator_test.go b/client/openapi/generator/generator_test.go new file mode 100644 index 00000000..8f600513 --- /dev/null +++ b/client/openapi/generator/generator_test.go @@ -0,0 +1,66 @@ +package generator_test + +import ( + "flag" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/meshcloud/meshstack-cli/client/openapi" + "github.com/meshcloud/meshstack-cli/client/openapi/generator" +) + +var update = flag.Bool("update", false, "write the golden files anew") + +func TestGenerate(t *testing.T) { + document, err := os.Open("testdata/spec.json") + require.NoError(t, err) + spec, err := openapi.Parse(document) + require.NoError(t, err) + config := generator.Config{ + Module: "example.com/client", + Shared: "meshobject", + Packages: []generator.Package{ + {Kind: "meshThing", ApiVersion: "v2", Actions: []string{"list", "show", "create", "delete", "paint"}}, + {Kind: "meshOther", ApiVersion: "v1", Actions: []string{"show"}}, + }, + } + + t.Run("fails on a oneOf without discriminator that the TypeMapper leaves", func(t *testing.T) { + _, err := generator.Generate(spec, config) + assert.EqualError(t, err, "GET /api/meshobjects/meshthings: response 200: MeshThingSpecV2.value: oneOf without a discriminator has no Go type; the TypeMapper must map it") + }) + + t.Run("writes each package as the golden file has it", func(t *testing.T) { + config.MapType = func(at generator.Location, _ *openapi.Schema) (generator.GoType, bool) { + if at == (generator.Location{Schema: "MeshThingSpecV2", Property: "value"}) { + return generator.GoType{Expr: "types.SecretOrAny", Import: "example.com/client/types", Container: true}, true + } + return generator.GoType{}, false + } + files, err := generator.Generate(spec, config) + require.NoError(t, err) + assert.ElementsMatch(t, []string{"meshthing/v2/generated.go", "meshother/v1/generated.go", "meshobject/generated.go"}, keys(files)) + for name, content := range files { + golden := filepath.Join("testdata", "golden", filepath.FromSlash(name)+".golden") + if *update { + require.NoError(t, os.MkdirAll(filepath.Dir(golden), 0o750)) + require.NoError(t, os.WriteFile(golden, content, 0o600)) + } + expected, err := os.ReadFile(golden) //nolint:gosec // G304: a golden file below testdata + require.NoError(t, err) + assert.Equal(t, string(expected), string(content), name) + } + }) +} + +func keys(files map[string][]byte) []string { + var names []string + for name := range files { + names = append(names, name) + } + return names +} diff --git a/client/openapi/generator/main.go b/client/openapi/generator/main.go new file mode 100644 index 00000000..fbf54450 --- /dev/null +++ b/client/openapi/generator/main.go @@ -0,0 +1,63 @@ +package generator + +import ( + "bytes" + "context" + "flag" + "fmt" + "maps" + "net/url" + "os" + "path/filepath" + "slices" + + "github.com/meshcloud/meshstack-cli/client/openapi" + "github.com/meshcloud/meshstack-cli/internal/http" +) + +// Main generates into the directory of Config.Module, which -out names and which is the working +// directory by default, as go:generate runs a command in the directory of its file. The API docs +// come from -spec, a path or URL, which defaults to the docs of meshStack's develop branch. +func Main(cfg Config) { + spec := flag.String("spec", openapi.DevUrl.String(), "path or URL of the meshStack API docs") + out := flag.String("out", ".", "directory of "+cfg.Module) + flag.Parse() + if err := run(context.Background(), cfg, *spec, *out); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func run(ctx context.Context, cfg Config, source, out string) error { + document, err := load(ctx, source) + if err != nil { + return fmt.Errorf("cannot read the API docs from %s: %w", source, err) + } + spec, err := openapi.Parse(bytes.NewReader(document)) + if err != nil { + return err + } + files, err := Generate(spec, cfg) + if err != nil { + return err + } + for _, name := range slices.Sorted(maps.Keys(files)) { + target := filepath.Join(out, filepath.FromSlash(name)) + if err := os.MkdirAll(filepath.Dir(target), 0o750); err != nil { + return err + } + if err := os.WriteFile(target, files[name], 0o600); err != nil { + return err + } + } + return nil +} + +func load(ctx context.Context, source string) ([]byte, error) { + parsed, err := url.Parse(source) + if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") { + return os.ReadFile(source) //nolint:gosec // G304: the developer names the file to generate from + } + client := http.NewClient(http.UserAgent{GitHubRepo: "meshcloud/meshstack-cli", Version: "generator"}) + return client.DoRequest[[]byte](ctx, http.MethodGet, parsed) +} diff --git a/client/openapi/generator/model.go b/client/openapi/generator/model.go new file mode 100644 index 00000000..79d4b386 --- /dev/null +++ b/client/openapi/generator/model.go @@ -0,0 +1,520 @@ +package generator + +import ( + "cmp" + "encoding/json/v2" + "fmt" + "maps" + "path" + "regexp" + "slices" + "strings" + + "github.com/meshcloud/meshstack-cli/client/openapi" +) + +type direction uint8 + +const ( + request direction = 1 << iota + response +) + +func (d direction) suffix() string { + if d == request { + return "Request" + } + return "Response" +} + +type generator struct { + cfg Config + spec openapi.Spec + schemas map[string]*openapi.Schema + packages []*pkg + shared *pkg + // types holds every schema that becomes a Go type, by component name or inline location. + types map[string]*namedType + links GoType +} + +type pkg struct { + Package + + importPath string + mediaType string + // collection is the path the kind's objects are below, such as /api/meshobjects/meshworkspaces. + collection string + embeddedRel string + operations []*operation + types []*decl + names map[string]bool +} + +type typeKind int + +const ( + objectType typeKind = iota + enumType + unionType +) + +// namedType is a schema that becomes a Go type: an object, an enum or a discriminated union. It +// becomes one decl, or one per direction when its request and response differ. +type namedType struct { + component string + // loc is where an inline schema occurs, and the component itself otherwise. + loc Location + schema *openapi.Schema + kind typeKind + reachers map[direction]map[*pkg]bool + fields map[direction][]field + unions map[direction]*union + values []string + // links marks the X of a HalEntityModel, which gets the _links of the entity model. + links bool + // meshObject marks a body or answer, whose kind and apiVersion the generated client adds. + meshObject bool + // dropProperty is the discriminator of the union this is a variant of. The union writes it. + dropProperty string + reachedDirectly bool + split bool + decls map[direction]*decl +} + +type field struct { + name, json string + ref typeRef + pointer bool + omitZero bool + doc string +} + +type union struct { + property string + field string + discType typeRef + // wrap is the shape {type: "aws", aws: {...}}, whose variant is the property named by the + // discriminator's value. + wrap bool + cases []unionCase +} + +type unionCase struct { + value, field, json string + ref typeRef +} + +type typeRef struct { + goType *GoType + named *namedType + dir direction + slice *typeRef + mapOf *typeRef +} + +func (r typeRef) container() bool { + return r.slice != nil || r.mapOf != nil || (r.goType != nil && r.goType.Container) +} + +type operation struct { + name string + doc string + method string + pathTemplate string + pathParams []param + queryType string + query []param + body *typeRef + sendsMeshObject bool + result *typeRef + answersMeshObject bool + list bool +} + +type param struct { + name, goName, goType string + uuid bool +} + +func newGenerator(spec openapi.Spec, cfg Config) *generator { + shared := &pkg{Path: cfg.Shared, Name: path.Base(cfg.Shared), names: map[string]bool{}} + shared.importPath = path.Join(cfg.Module, cfg.Shared) + return &generator{ + cfg: cfg, + spec: spec, + schemas: map[string]*openapi.Schema{}, + shared: shared, + types: map[string]*namedType{}, + links: GoType{Expr: shared.Name + ".Links", Import: shared.importPath, Container: true}, + } +} + +func (g *generator) schema(name string) (*openapi.Schema, error) { + if s, ok := g.schemas[name]; ok { + return s, nil + } + s, err := g.spec.Schema(name) + if err != nil { + return nil, err + } + g.schemas[name] = s + return s, nil +} + +var duplicateSuffixRe = regexp.MustCompile(`_\d+$`) + +// canonical names a component X_0 as X when both are the same schema: the spec numbers a schema +// it meets again in another context. +func (g *generator) canonical(name string) string { + base := duplicateSuffixRe.ReplaceAllString(name, "") + if base == name { + return name + } + a, errA := g.schema(name) + b, errB := g.schema(base) + if errA != nil || errB != nil { + return name + } + jsonA, _ := json.Marshal(a) + jsonB, _ := json.Marshal(b) + if string(jsonA) == string(jsonB) { + return base + } + return name +} + +func (g *generator) resolve(s *openapi.Schema) (*openapi.Schema, error) { + for { + switch { + case s.Ref != "": + target, err := g.schema(g.canonical(s.RefName())) + if err != nil { + return nil, err + } + s = target + case len(s.AllOf) == 1 && len(s.Properties) == 0: + s = s.AllOf[0] + default: + return s, nil + } + } +} + +func isNamed(s *openapi.Schema) bool { + return len(s.Properties) > 0 || len(s.Enum) > 0 || (len(s.OneOf) > 0 && s.Discriminator != nil) +} + +func (g *generator) componentType(name string, s *openapi.Schema) *namedType { + if t, ok := g.types[name]; ok { + return t + } + t := &namedType{component: name, loc: Location{Schema: name}, schema: s, kind: kindOf(s)} + g.types[name] = t + return t +} + +func (g *generator) inlineType(at Location, s *openapi.Schema) *namedType { + key := "#" + at.String() + if t, ok := g.types[key]; ok { + return t + } + t := &namedType{loc: at, schema: s, kind: kindOf(s)} + g.types[key] = t + return t +} + +func kindOf(s *openapi.Schema) typeKind { + switch { + case len(s.Enum) > 0: + return enumType + case len(s.OneOf) > 0: + return unionType + default: + return objectType + } +} + +// ref reaches the schema from p in the direction, and returns its Go type. +func (g *generator) ref(p *pkg, s *openapi.Schema, dir direction, at Location) (typeRef, error) { + if g.cfg.MapType != nil { + if mapped, ok := g.cfg.MapType(at, s); ok { + return typeRef{goType: &mapped}, nil + } + } + switch { + case s.Ref != "": + name := g.canonical(s.RefName()) + if name == "HalLinks" { + return typeRef{goType: &g.links}, nil + } + target, err := g.schema(name) + if err != nil { + return typeRef{}, err + } + if strings.HasPrefix(name, "HalEntityModel") { + return g.entityModel(p, name, target, dir) + } + if !isNamed(target) { + return g.ref(p, target, dir, at) + } + t := g.componentType(name, target) + t.reachedDirectly = true + return typeRef{named: t, dir: dir}, g.visit(p, t, dir) + case len(s.AllOf) == 1 && len(s.Properties) == 0: + return g.ref(p, s.AllOf[0], dir, at) + case len(s.AllOf) > 1: + return typeRef{}, fmt.Errorf("%s: allOf of %d schemas is only known for a HalEntityModel", at, len(s.AllOf)) + case len(s.OneOf) > 0 && s.Discriminator != nil: + return typeRef{}, fmt.Errorf("%s: a oneOf with a discriminator must be a component, to name its Go type", at) + case len(s.OneOf) > 0: + return typeRef{}, fmt.Errorf("%s: oneOf without a discriminator has no Go type; the TypeMapper must map it", at) + case len(s.Enum) > 0 || len(s.Properties) > 0: + t := g.inlineType(at, s) + return typeRef{named: t, dir: dir}, g.visit(p, t, dir) + case s.Type == "array": + if s.Items == nil { + return typeRef{}, fmt.Errorf("%s: array without items", at) + } + items, err := g.ref(p, s.Items, dir, at) + return typeRef{slice: &items}, err + case s.AdditionalProperties != nil: + values, err := g.ref(p, s.AdditionalProperties, dir, at) + return typeRef{mapOf: &values}, err + } + primitive, err := primitiveOf(s, at) + return typeRef{goType: &primitive}, err +} + +func primitiveOf(s *openapi.Schema, at Location) (GoType, error) { + switch s.Type + " " + s.Format { + case "string uuid": + return GoType{Expr: "uuid.UUID", Import: "uuid"}, nil + case "string date-time": + return GoType{Expr: "time.Time", Import: "time"}, nil + case "string binary": + return GoType{Expr: "[]byte", Container: true}, nil + case "integer int32": + return GoType{Expr: "int32"}, nil + case "number float": + return GoType{Expr: "float32"}, nil + } + switch s.Type { + case "string": + return GoType{Expr: "string"}, nil + case "integer": + return GoType{Expr: "int64"}, nil + case "number": + return GoType{Expr: "float64"}, nil + case "boolean": + return GoType{Expr: "bool"}, nil + case "object": + return GoType{Expr: "map[string]any", Container: true}, nil + case "": + return GoType{Expr: "any", Container: true}, nil + } + return GoType{}, fmt.Errorf("%s: no Go type for a schema of type %q", at, s.Type) +} + +// entityModel unwraps HalEntityModel = allOf [X, {_links}] to X, which gets the links. +func (g *generator) entityModel(p *pkg, name string, s *openapi.Schema, dir direction) (typeRef, error) { + if len(s.AllOf) == 0 || s.AllOf[0].Ref == "" { + return typeRef{}, fmt.Errorf("%s: expected allOf [$ref, {_links}]", name) + } + r, err := g.ref(p, s.AllOf[0], dir, Location{Schema: name}) + if err != nil { + return r, err + } + if r.named == nil || r.named.kind != objectType { + return r, fmt.Errorf("%s: the entity of a HalEntityModel must be an object", name) + } + r.named.links = true + return r, nil +} + +func (g *generator) visit(p *pkg, t *namedType, dir direction) error { + if t.reachers == nil { + t.reachers = map[direction]map[*pkg]bool{} + t.fields = map[direction][]field{} + t.unions = map[direction]*union{} + } + if t.reachers[dir][p] { + return nil + } + if t.reachers[dir] == nil { + t.reachers[dir] = map[*pkg]bool{} + } + t.reachers[dir][p] = true + if t.kind == enumType { + t.values = t.values[:0] + for _, value := range t.schema.Enum { + s, ok := value.(string) + if !ok { + return fmt.Errorf("%s: enum value %v is no string", t.loc, value) + } + t.values = append(t.values, s) + } + return nil + } + if t.kind == unionType { + u, err := g.union(p, t, dir) + t.unions[dir] = u + return err + } + var fields []field + for _, property := range t.schema.Properties { + include, err := g.includes(t, property, dir) + if err != nil { + return err + } + if !include { + continue + } + f, err := g.field(p, t, property, dir) + if err != nil { + return err + } + fields = append(fields, f) + } + t.fields[dir] = fields + return nil +} + +func (g *generator) includes(t *namedType, property openapi.Property, dir direction) (bool, error) { + if property.Name == t.dropProperty || (t.meshObject && (property.Name == "kind" || property.Name == "apiVersion")) { + return false, nil + } + readOnly, writeOnly, err := g.access(property.Schema) + return (!readOnly || dir != request) && (!writeOnly || dir != response), err +} + +// access reads readOnly and writeOnly from the property and the component it refers to. +func (g *generator) access(s *openapi.Schema) (readOnly, writeOnly bool, err error) { + resolved, err := g.resolve(s) + if err != nil { + return false, false, err + } + return s.ReadOnly || resolved.ReadOnly, s.WriteOnly || resolved.WriteOnly, nil +} + +func (g *generator) field(p *pkg, t *namedType, property openapi.Property, dir direction) (field, error) { + r, err := g.ref(p, property.Schema, dir, t.loc.child(property.Name)) + if err != nil { + return field{}, err + } + resolved, err := g.resolve(property.Schema) + if err != nil { + return field{}, err + } + readOnly, _, _ := g.access(property.Schema) + required := t.schema.IsRequired(property.Name) && (!readOnly || dir != request) + optional := !required || property.Schema.Nullable || resolved.Nullable + return field{ + name: goName(property.Name), + json: property.Name, + ref: r, + pointer: optional && !r.container(), + omitZero: optional, + doc: cmp.Or(property.Schema.Description, resolved.Description), + }, nil +} + +func (g *generator) union(p *pkg, t *namedType, dir direction) (*union, error) { + property := t.schema.Discriminator.PropertyName + u := &union{property: property, field: goName(property), discType: typeRef{goType: &GoType{Expr: "string"}}} + if len(t.schema.Discriminator.Mapping) == 0 { + return nil, fmt.Errorf("%s: a discriminator needs its mapping", t.loc) + } + variants := make([]*openapi.Schema, len(t.schema.Discriminator.Mapping)) + u.wrap = true + for i, mapping := range t.schema.Discriminator.Mapping { + var err error + if variants[i], err = g.resolve(&openapi.Schema{Ref: mapping.Ref}); err != nil { + return nil, err + } + _, hasDiscriminator := variants[i].Property(property) + _, hasWrapped := variants[i].Property(mapping.Value) + u.wrap = u.wrap && hasDiscriminator && hasWrapped && len(variants[i].Properties) == 2 + } + if first, _ := variants[0].Property(property); first != nil { + wrapped := first + if len(first.AllOf) == 1 { + wrapped = first.AllOf[0] + } + if wrapped.Ref != "" { + r, err := g.ref(p, wrapped, dir, t.loc.child(property)) + if err != nil { + return nil, err + } + u.discType = r + } + } + for i, mapping := range t.schema.Discriminator.Mapping { + variantName := (&openapi.Schema{Ref: mapping.Ref}).RefName() + c := unionCase{value: mapping.Value, field: goName(mapping.Value)} + if u.wrap { + inner := variants[i].Properties[slices.IndexFunc(variants[i].Properties, func(p openapi.Property) bool { return p.Name != property })] + c.field, c.json = goName(inner.Name), inner.Name + r, err := g.ref(p, inner.Schema, dir, Location{variantName, inner.Name}) + if err != nil { + return nil, err + } + c.ref = r + } else { + vt := g.componentType(variantName, variants[i]) + if vt.kind != objectType { + return nil, fmt.Errorf("%s: variant %s is no object", t.loc, variantName) + } + vt.dropProperty = property + if err := g.visit(p, vt, dir); err != nil { + return nil, err + } + c.ref = typeRef{named: vt, dir: dir} + } + u.cases = append(u.cases, c) + } + return u, nil +} + +// children are the named types a type refers to in the direction. +func (t *namedType) children(dir direction) []*namedType { + var refs []typeRef + for _, f := range t.fields[dir] { + refs = append(refs, f.ref) + } + if u := t.unions[dir]; u != nil { + refs = append(refs, u.discType) + for _, c := range u.cases { + refs = append(refs, c.ref) + } + } + var children []*namedType + for len(refs) > 0 { + r := refs[0] + refs = refs[1:] + switch { + case r.named != nil: + children = append(children, r.named) + case r.slice != nil: + refs = append(refs, *r.slice) + case r.mapOf != nil: + refs = append(refs, *r.mapOf) + } + } + return children +} + +func (t *namedType) dirs() []direction { + return slices.Sorted(maps.Keys(t.reachers)) +} + +// ownSplit is a type reached in both directions whose fields differ between them, as by a +// readOnly or writeOnly property. +func (t *namedType) ownSplit() bool { + if len(t.reachers) < 2 { + return false + } + return !slices.EqualFunc(t.fields[request], t.fields[response], func(a, b field) bool { + return a.json == b.json && a.pointer == b.pointer && a.omitZero == b.omitZero + }) +} diff --git a/client/openapi/generator/names.go b/client/openapi/generator/names.go new file mode 100644 index 00000000..f470dbb1 --- /dev/null +++ b/client/openapi/generator/names.go @@ -0,0 +1,47 @@ +package generator + +import ( + "strings" + "unicode" +) + +// goName writes a JSON name or an enum value as an exported Go name: displayName is DisplayName, +// _links is Links, WAITING_FOR_INPUT is WaitingForInput and v2-preview is V2Preview. +func goName(name string) string { + var b strings.Builder + for word := range strings.FieldsFuncSeq(name, func(r rune) bool { return !unicode.IsLetter(r) && !unicode.IsDigit(r) }) { + if strings.ToUpper(word) == word { + word = strings.ToLower(word) + } + b.WriteString(upperFirst(word)) + } + return sanitize(b.String()) +} + +// sanitize keeps letters and digits, and makes the name exported. +func sanitize(name string) string { + name = strings.Map(func(r rune) rune { + if unicode.IsLetter(r) || unicode.IsDigit(r) { + return r + } + return -1 + }, name) + if name != "" && unicode.IsDigit(rune(name[0])) { + name = "T" + name + } + return upperFirst(name) +} + +func upperFirst(s string) string { + if s == "" { + return s + } + return strings.ToUpper(s[:1]) + s[1:] +} + +func lowerFirst(s string) string { + if s == "" { + return s + } + return strings.ToLower(s[:1]) + s[1:] +} diff --git a/client/openapi/generator/operations.go b/client/openapi/generator/operations.go new file mode 100644 index 00000000..a72848fd --- /dev/null +++ b/client/openapi/generator/operations.go @@ -0,0 +1,268 @@ +package generator + +import ( + "cmp" + "errors" + "fmt" + "path" + "slices" + "strings" + + "github.com/meshcloud/meshstack-cli/client/openapi" +) + +var methodOfAction = map[string]string{ + "list": "List", + "show": "Get", + "create": "Create", + "update": "Update", + "delete": "Delete", +} + +func (g *generator) addPackage(config Package) error { + config, err := config.defaults() + if err != nil { + return err + } + p := &pkg{Package: config, importPath: path.Join(g.cfg.Module, config.Path), names: map[string]bool{}} + g.packages = append(g.packages, p) + selected, err := g.selectOperations(p) + if err != nil { + return err + } + for _, operation := range selected { + if p.Kind != "" { + if mediaType, ok := operation.MediaType(openapi.MustParseApiVersion(p.ApiVersion)); ok { + p.mediaType = mediaType.Name + } + } + if literal := literalStart(string(operation.PathTemplate)); p.collection == "" || len(literal) < len(p.collection) { + p.collection = literal + } + } + if p.Kind != "" && p.mediaType == "" { + return fmt.Errorf("no operation of %s offers %s", p.Kind, p.ApiVersion) + } + for _, operation := range selected { + o, err := g.operation(p, operation) + if err != nil { + return fmt.Errorf("%s %s: %w", operation.Method, operation.PathTemplate, err) + } + p.operations = append(p.operations, o) + } + slices.SortFunc(p.operations, func(a, b *operation) int { return cmp.Compare(a.name, b.name) }) + return nil +} + +func (g *generator) selectOperations(p *pkg) ([]openapi.Operation, error) { + var selectors []openapi.Selector + var version openapi.ApiVersion + if p.ApiVersion != "" { + version = openapi.MustParseApiVersion(p.ApiVersion) + } + for _, action := range p.Actions { + selectors = append(selectors, openapi.Selector{Kind: p.Kind, Action: action, ApiVersion: version}) + } + for _, id := range p.OperationIds { + selectors = append(selectors, openapi.Selector{OperationId: id, ApiVersion: version}) + } + var selected []openapi.Operation + for _, selector := range selectors { + spec, err := g.spec.Select(selector) + if err != nil { + return nil, err + } + if len(spec.Operations) == 0 { + return nil, fmt.Errorf("the API docs have no operation for %+v", selector) + } + selected = append(selected, spec.Operations...) + } + return selected, nil +} + +func literalStart(template string) string { + before, _, _ := strings.Cut(template, "{") + return strings.TrimSuffix(before, "/") +} + +func (g *generator) operation(p *pkg, selected openapi.Operation) (*operation, error) { + schemas, err := selected.Schemas() + if err != nil { + return nil, err + } + o := &operation{ + name: cmp.Or(methodOfAction[selected.Action], goName(selected.Action)), + method: selected.Method, + pathTemplate: string(selected.PathTemplate), + list: selected.Action == "list", + } + if p.Kind == "" || selected.Action == "" { + o.name = goName(strings.TrimPrefix(selected.OperationId, p.Kind)) + } + o.doc, err = g.summary(selected) + if err != nil { + return nil, err + } + for _, parameter := range schemas.Parameters { + if parameterErr := g.addParameter(o, parameter); parameterErr != nil { + return nil, parameterErr + } + } + if len(o.query) > 0 { + o.queryType = o.name + "Query" + if o.list { + o.queryType = "ListFilter" + } + } + if body := p.content(schemas.RequestBody); body != nil { + r, sendsMeshObject, bodyErr := g.root(p, body, request) + if bodyErr != nil { + return nil, fmt.Errorf("request body: %w", bodyErr) + } + o.body, o.sendsMeshObject = &r, sendsMeshObject + } + for _, r := range schemas.Responses { + answer := p.content(r.Content) + if !strings.HasPrefix(r.Status, "2") || answer == nil { + continue + } + if o.list { + if answer, err = g.pageItems(p, answer); err != nil { + return nil, err + } + } + result, answersMeshObject, err := g.root(p, answer, response) + if err != nil { + return nil, fmt.Errorf("response %s: %w", r.Status, err) + } + o.result, o.answersMeshObject = &result, answersMeshObject + break + } + if o.list && o.result == nil { + return nil, fmt.Errorf("a list answers no page of %s", p.mediaType) + } + return o, nil +} + +func (g *generator) summary(operation openapi.Operation) (string, error) { + doc, err := g.spec.Doc(operation) + return doc.Summary, err +} + +func (g *generator) addParameter(o *operation, parameter openapi.ParameterSchema) error { + if parameter.Schema == nil || parameter.In == "header" { + return nil + } + resolved, err := g.resolve(parameter.Schema) + if err != nil { + return err + } + switch parameter.In { + case "path": + p := param{name: parameter.Name, goName: lowerFirst(goName(parameter.Name)), goType: "string"} + if resolved.Format == "uuid" { + p.goType, p.uuid = "uuid.UUID", true + } + o.pathParams = append(o.pathParams, p) + case "query": + if parameter.Name == "page" || parameter.Name == "size" { + return nil + } + goType, err := g.queryType(resolved) + if err != nil { + return fmt.Errorf("query parameter %s: %w", parameter.Name, err) + } + o.query = append(o.query, param{name: parameter.Name, goName: goName(parameter.Name), goType: goType}) + } + return nil +} + +// queryType keeps an enum a string, because a query parameter is no type of the API's objects. +func (g *generator) queryType(s *openapi.Schema) (string, error) { + if s.Type == "array" && s.Items != nil { + items, err := g.resolve(s.Items) + if err != nil { + return "", err + } + element, err := g.queryType(items) + return "[]" + element, err + } + switch s.Type { + case "integer": + return "int64", nil + case "boolean": + return "bool", nil + case "number": + return "float64", nil + } + return "string", nil +} + +// content is the schema of the package's media type, or of the one media type an operation of no +// version has. +func (p *pkg) content(contents []openapi.Content) *openapi.Schema { + for _, c := range contents { + if c.MediaType == p.mediaType { + return c.Schema + } + } + if len(contents) == 1 && openapi.ParseMediaType(contents[0].MediaType).ApiVersion.IsZero() && strings.HasSuffix(contents[0].MediaType, "json") { + return contents[0].Schema + } + return nil +} + +// pageItems reads the schema of the objects of a HalPage, and the name they are embedded by. +func (g *generator) pageItems(p *pkg, page *openapi.Schema) (*openapi.Schema, error) { + resolved, err := g.resolve(page) + if err != nil { + return nil, err + } + embedded, ok := resolved.Property("_embedded") + if !ok || len(embedded.Properties) != 1 || embedded.Properties[0].Schema.Items == nil { + return nil, fmt.Errorf("%s is no HalPage with one embedded array", page.RefName()) + } + p.embeddedRel = embedded.Properties[0].Name + return embedded.Properties[0].Schema.Items, nil +} + +// root marks a meshObject, whose kind and apiVersion the generated client writes and checks, +// before its type is reached. +func (g *generator) root(p *pkg, s *openapi.Schema, dir direction) (typeRef, bool, error) { + entity := s + if strings.HasPrefix(s.RefName(), "HalEntityModel") { + model, err := g.schema(s.RefName()) + if err != nil { + return typeRef{}, false, err + } + if len(model.AllOf) > 0 { + entity = model.AllOf[0] + } + } + if entity.Ref == "" { + if isNamed(entity) || len(entity.AllOf) > 0 { + return typeRef{}, false, errors.New("an inline schema has no component to name its Go type by") + } + r, err := g.ref(p, s, dir, Location{}) + return r, false, err + } + resolved, err := g.resolve(entity) + if err != nil { + return typeRef{}, false, err + } + meshObject := isSingleValue(resolved, "kind") && isSingleValue(resolved, "apiVersion") + if meshObject { + t := g.componentType(g.canonical(entity.RefName()), resolved) + if len(t.reachers) > 0 && !t.meshObject { + return typeRef{}, false, fmt.Errorf("%s is a meshObject, and reached before as a part of another one", t.component) + } + t.meshObject = true + } + r, err := g.ref(p, s, dir, Location{}) + return r, meshObject, err +} + +func isSingleValue(s *openapi.Schema, property string) bool { + value, ok := s.Property(property) + return ok && len(value.Enum) == 1 +} diff --git a/client/openapi/generator/place.go b/client/openapi/generator/place.go new file mode 100644 index 00000000..af972f2b --- /dev/null +++ b/client/openapi/generator/place.go @@ -0,0 +1,190 @@ +package generator + +import ( + "cmp" + "fmt" + "maps" + "slices" + "strconv" + "strings" + "unicode" +) + +// decl is one Go type of a namedType: the only one, or that of one direction. +type decl struct { + t *namedType + // dir is zero for a type that serves both directions. + dir direction + home *pkg + base string + name string +} + +func (g *generator) place() error { + types := slices.SortedFunc(maps.Values(g.types), func(a, b *namedType) int { + return cmp.Compare(a.loc.String(), b.loc.String()) + }) + for _, t := range types { + if t.dropProperty != "" && t.reachedDirectly { + return fmt.Errorf("%s is a variant of a union and is used on its own, so the union cannot write its %s", t.loc, t.dropProperty) + } + t.split = t.ownSplit() + } + for changed := true; changed; { + changed = false + for _, t := range types { + if t.split || len(t.reachers) < 2 { + continue + } + for _, dir := range t.dirs() { + for _, child := range t.children(dir) { + if child.split && !t.split { + t.split, changed = true, true + } + } + } + } + } + + var decls []*decl + for _, t := range types { + t.decls = map[direction]*decl{} + if t.split { + for _, dir := range t.dirs() { + d := &decl{t: t, dir: dir, home: g.home(t.reachers[dir])} + t.decls[dir] = d + decls = append(decls, d) + } + continue + } + reachers := map[*pkg]bool{} + for _, r := range t.reachers { + maps.Copy(reachers, r) + } + d := &decl{t: t, home: g.home(reachers)} + for _, dir := range t.dirs() { + t.decls[dir] = d + } + decls = append(decls, d) + } + + // An inline type takes its name from the type it is in, so components are named first. + depth := func(d *decl) int { + if d.t.component != "" { + return 0 + } + return 1 + strings.Count(d.t.loc.Property, ".") + } + slices.SortStableFunc(decls, func(a, b *decl) int { return cmp.Compare(depth(a), depth(b)) }) + for _, p := range append(slices.Clone(g.packages), g.shared) { + for _, o := range p.operations { + p.names[o.queryType] = true + } + for _, reserved := range []string{"Client", "NewClient", "Kind", "ApiVersion", "MediaType"} { + p.names[reserved] = true + } + } + for _, d := range decls { + if err := g.name(d); err != nil { + return err + } + d.home.types = append(d.home.types, d) + } + for _, p := range append(slices.Clone(g.packages), g.shared) { + slices.SortFunc(p.types, func(a, b *decl) int { return cmp.Compare(a.name, b.name) }) + } + return nil +} + +func (g *generator) home(reachers map[*pkg]bool) *pkg { + if len(reachers) == 1 { + for p := range reachers { + return p + } + } + return g.shared +} + +func (g *generator) name(d *decl) error { + var candidates []string + switch { + case d.t.component == "": + prefix := sanitize(strings.ReplaceAll(d.t.loc.Schema, ".", "")) + if parent, ok := g.types[d.t.loc.Schema]; ok && parent.decls != nil { + prefix = cmp.Or(parent.decls[d.dir], parent.decls[response], parent.decls[request]).base + } + var suffix strings.Builder + for property := range strings.SplitSeq(d.t.loc.Property, ".") { + suffix.WriteString(goName(property)) + } + candidates = []string{prefix + suffix.String()} + case d.home == g.shared: + candidates = []string{sanitize(strings.ReplaceAll(d.t.component, ".", ""))} + default: + candidates = d.home.candidates(d.t.component, d.t.reachers) + } + for i := range candidates { + d.base = candidates[i] + d.name = d.base + if d.dir != 0 && !strings.HasSuffix(d.name, d.dir.suffix()) { + d.name += d.dir.suffix() + } + if !d.home.names[d.name] { + d.home.names[d.name] = true + return nil + } + } + for i := 2; ; i++ { + if name := d.name + strconv.Itoa(i); !d.home.names[name] { + d.name = name + d.home.names[name] = true + return nil + } + } +} + +// candidates name a component in a kind's package without the kind and version, shortest first: +// MeshBuildingBlockRun.MeshBuildingBlockRunSpec.Behavior is Behavior, then SpecBehavior. +func (p *pkg) candidates(component string, reachers map[direction]map[*pkg]bool) []string { + var segments []string + for segment := range strings.SplitSeq(duplicateSuffixRe.ReplaceAllString(component, ""), ".") { + segment = strings.TrimPrefix(segment, upperFirst(p.Kind)) + segments = append(segments, sanitize(p.withoutVersion(segment))) + } + var candidates []string + for i := range slices.Backward(segments) { + if name := strings.Join(segments[i:], ""); name != "" { + candidates = append(candidates, name) + } + } + if len(candidates) == 0 { + if _, isResponse := reachers[response]; isResponse { + return []string{"Response"} + } + return []string{"Request"} + } + return append(candidates, sanitize(strings.ReplaceAll(component, ".", ""))) +} + +// withoutVersion drops the version of the package where it stands as a word of its own, as the V2 +// of SpecV2Response. +func (p *pkg) withoutVersion(name string) string { + if p.ApiVersion == "" { + return name + } + number, _, _ := strings.Cut(strings.TrimPrefix(p.ApiVersion, "v"), "-") + token := "V" + number + for i := 0; i+len(token) <= len(name); { + j := strings.Index(name[i:], token) + if j < 0 { + break + } + end := i + j + len(token) + if end == len(name) || unicode.IsUpper(rune(name[end])) { + name = name[:i+j] + name[end:] + continue + } + i = end + } + return name +} diff --git a/client/openapi/generator/render.go b/client/openapi/generator/render.go new file mode 100644 index 00000000..4ffc4840 --- /dev/null +++ b/client/openapi/generator/render.go @@ -0,0 +1,262 @@ +package generator + +import ( + "bytes" + _ "embed" + "fmt" + "go/format" + "maps" + "slices" + "strconv" + "strings" + "text/template" +) + +//go:embed generated.go.tmpl +var fileTemplate string + +var tmpl = template.Must(template.New("file").Funcs(template.FuncMap{ + "comment": comment, + "quote": strconv.Quote, +}).Parse(fileTemplate)) + +type fileData struct { + Header string + Doc string + Package string + StdImports []string + Imports []string + Kind string + ApiVersion string + MediaType string + Collection string + EmbeddedRel string + Operations []operationData + Queries []typeData + Types []typeData +} + +type operationData struct { + Name, Doc, Params, Returns, Result, Call, Body string + List bool +} + +type typeData struct { + Name, Doc, Kind string + Fields []fieldData + Values []enumValue + Plural string + DiscField string + DiscType string + DiscJSON string + Wrap bool + Cases []caseData +} + +type fieldData struct { + Name, Type, Tag, Doc string +} + +type enumValue struct { + Const, Value string +} + +type caseData struct { + Value, Field, Type, JSON string +} + +type renderer struct { + g *generator + p *pkg + imports map[string]bool +} + +func (g *generator) render(p *pkg) ([]byte, error) { + r := renderer{g: g, p: p, imports: map[string]bool{}} + data := fileData{ + Header: header, + Package: p.Name, + Kind: p.Kind, + ApiVersion: p.ApiVersion, + MediaType: p.mediaType, + Collection: p.collection, + EmbeddedRel: p.embeddedRel, + } + if p.Kind != "" { + data.Doc = fmt.Sprintf("Package %s is the meshObject kind %s in API version %s.", p.Name, p.Kind, p.ApiVersion) + } + for _, o := range p.operations { + data.Operations = append(data.Operations, r.operation(o)) + if o.queryType != "" { + query := typeData{Name: o.queryType, Kind: "object"} + for _, q := range o.query { + query.Fields = append(query.Fields, fieldData{Name: q.goName, Type: q.goType, Tag: fmt.Sprintf("`json:%q`", q.name)}) + } + data.Queries = append(data.Queries, query) + } + } + if len(p.operations) > 0 { + r.imports["context"] = true + r.imports[runtimeImport] = true + } + for _, d := range p.types { + data.Types = append(data.Types, r.typeData(d)) + } + for _, imp := range slices.Sorted(maps.Keys(r.imports)) { + if first, _, _ := strings.Cut(imp, "/"); strings.Contains(first, ".") { + data.Imports = append(data.Imports, imp) + } else { + data.StdImports = append(data.StdImports, imp) + } + } + var source bytes.Buffer + if err := tmpl.Execute(&source, data); err != nil { + return nil, err + } + formatted, err := format.Source(source.Bytes()) + if err != nil { + return nil, fmt.Errorf("%w in\n%s", err, source.String()) + } + return formatted, nil +} + +func (r renderer) expr(t typeRef) string { + switch { + case t.slice != nil: + return "[]" + r.expr(*t.slice) + case t.mapOf != nil: + return "map[string]" + r.expr(*t.mapOf) + case t.goType != nil: + if t.goType.Import != "" { + r.imports[t.goType.Import] = true + } + return t.goType.Expr + } + d := t.named.decls[t.dir] + if d.home == r.p { + return d.name + } + r.imports[d.home.importPath] = true + return d.home.Name + "." + d.name +} + +func (r renderer) operation(o *operation) operationData { + params := []string{"ctx context.Context"} + pathArgs := []string{strconv.Quote(o.pathTemplate)} + for _, p := range o.pathParams { + params = append(params, p.goName+" "+p.goType) + if p.uuid { + r.imports["uuid"] = true + pathArgs = append(pathArgs, p.goName+".String()") + } else { + pathArgs = append(pathArgs, p.goName) + } + } + call := []string{"Method: " + strconv.Quote(o.method)} + if len(o.pathParams) > 0 { + call = append(call, "Path: internal.Path("+strings.Join(pathArgs, ", ")+")") + } else { + call = append(call, "Path: "+pathArgs[0]) + } + if o.queryType != "" { + name := "query" + if o.list { + name = "filter" + } + params = append(params, name+" "+o.queryType) + call = append(call, "Query: "+name) + } + data := operationData{Name: o.name, Doc: o.doc, List: o.list, Body: "nil", Returns: "error"} + if o.body != nil { + params = append(params, "body "+r.expr(*o.body)) + data.Body = "body" + } + if o.sendsMeshObject { + call = append(call, "SendsMeshObject: true") + } + if o.answersMeshObject { + call = append(call, "AnswersMeshObject: true") + } + if o.result != nil { + data.Result = r.expr(*o.result) + data.Returns = "(" + data.Result + ", error)" + } + if o.list { + r.imports["iter"] = true + data.Returns = "iter.Seq2[" + data.Result + ", error]" + } + data.Params = strings.Join(params, ", ") + data.Call = "internal.Call{" + strings.Join(call, ", ") + "}" + return data +} + +func (r renderer) typeData(d *decl) typeData { + t := d.t + data := typeData{Name: d.name, Doc: t.schema.Description} + dir := d.dir + if dir == 0 { + dir = t.dirs()[len(t.dirs())-1] + } + switch t.kind { + case enumType: + r.imports[enumImport] = true + data.Kind, data.Plural = "enum", plural(d.name) + for _, value := range t.values { + data.Values = append(data.Values, enumValue{d.name + goName(value), value}) + } + case unionType: + u := t.unions[dir] + data.Kind, data.Wrap = "union", u.wrap + data.DiscField, data.DiscType, data.DiscJSON = u.field, r.expr(u.discType), u.property + if !u.wrap { + for _, imp := range []string{"encoding/json/jsontext", "encoding/json/v2", "fmt"} { + r.imports[imp] = true + } + } + for _, c := range u.cases { + data.Cases = append(data.Cases, caseData{c.value, c.field, r.expr(c.ref), c.json}) + } + default: + data.Kind = "object" + for _, f := range t.fields[dir] { + data.Fields = append(data.Fields, r.field(f)) + } + if t.links && d.dir != request { + data.Fields = append(data.Fields, fieldData{Name: "Links", Type: r.expr(typeRef{goType: &r.g.links}), Tag: "`json:\"_links,omitzero\"`"}) + } + } + return data +} + +func (r renderer) field(f field) fieldData { + typ := r.expr(f.ref) + if f.pointer { + typ = "*" + typ + } + tag := f.json + if f.omitZero { + tag += ",omitzero" + } + return fieldData{Name: f.name, Type: typ, Tag: fmt.Sprintf("`json:%q`", tag), Doc: f.doc} +} + +func plural(name string) string { + switch { + case strings.HasSuffix(name, "s"), strings.HasSuffix(name, "x"): + return name + "es" + case strings.HasSuffix(name, "y") && !strings.ContainsAny(name[len(name)-2:len(name)-1], "aeiou"): + return name[:len(name)-1] + "ies" + } + return name + "s" +} + +func comment(text string) string { + if text == "" { + return "" + } + var b strings.Builder + for line := range strings.SplitSeq(strings.TrimSpace(text), "\n") { + b.WriteString(strings.TrimRight("// "+line, " ") + "\n") + } + return b.String() +} diff --git a/client/openapi/generator/testdata/golden/meshobject/generated.go.golden b/client/openapi/generator/testdata/golden/meshobject/generated.go.golden new file mode 100644 index 00000000..33eb23ca --- /dev/null +++ b/client/openapi/generator/testdata/golden/meshobject/generated.go.golden @@ -0,0 +1,84 @@ +// Code generated by github.com/meshcloud/meshstack-cli/client/openapi/generator. DO NOT EDIT. + +package meshobject + +import ( + "encoding/json/jsontext" + "encoding/json/v2" + "fmt" + "uuid" + + "github.com/meshcloud/meshstack-cli/client/types/enum" +) + +type Owner struct { + Name string `json:"name"` + Target *TargetRef `json:"target,omitzero"` +} + +type TargetKind string + +const ( + TargetKindMeshTenant TargetKind = "meshTenant" + TargetKindMeshWorkspace TargetKind = "meshWorkspace" +) + +var TargetKinds = enum.Of[TargetKind]("meshTenant", "meshWorkspace") + +type TargetRef struct { + Kind TargetKind + MeshTenant *TenantRef + MeshWorkspace *WorkspaceRef +} + +func (u TargetRef) MarshalJSONTo(enc *jsontext.Encoder) error { + switch u.Kind { + case "meshTenant": + if u.MeshTenant != nil { + return json.MarshalEncode(enc, struct { + Discriminator TargetKind `json:"kind"` + *TenantRef `json:",embed"` + }{u.Kind, u.MeshTenant}) + } + case "meshWorkspace": + if u.MeshWorkspace != nil { + return json.MarshalEncode(enc, struct { + Discriminator TargetKind `json:"kind"` + *WorkspaceRef `json:",embed"` + }{u.Kind, u.MeshWorkspace}) + } + } + return fmt.Errorf("TargetRef has no value for kind %q", u.Kind) +} + +// UnmarshalJSONFrom leaves every variant nil for a kind this client does not know yet. +func (u *TargetRef) UnmarshalJSONFrom(dec *jsontext.Decoder) error { + raw, err := dec.ReadValue() + if err != nil { + return err + } + var discriminator struct { + Value TargetKind `json:"kind"` + } + if err := json.Unmarshal(raw, &discriminator); err != nil { + return err + } + *u = TargetRef{Kind: discriminator.Value} + switch discriminator.Value { + case "meshTenant": + u.MeshTenant = new(TenantRef) + return json.Unmarshal(raw, u.MeshTenant) + case "meshWorkspace": + u.MeshWorkspace = new(WorkspaceRef) + return json.Unmarshal(raw, u.MeshWorkspace) + } + return nil +} + +type TenantRef struct { + Uuid uuid.UUID `json:"uuid"` +} + +type WorkspaceRef struct { + Name string `json:"name"` +} diff --git a/client/openapi/generator/testdata/golden/meshother/v1/generated.go.golden b/client/openapi/generator/testdata/golden/meshother/v1/generated.go.golden new file mode 100644 index 00000000..bfb05a42 --- /dev/null +++ b/client/openapi/generator/testdata/golden/meshother/v1/generated.go.golden @@ -0,0 +1,47 @@ +// Code generated by github.com/meshcloud/meshstack-cli/client/openapi/generator. DO NOT EDIT. + +// Package meshother is the meshObject kind meshOther in API version v1. +package meshother + +import ( + "context" + + "example.com/client/meshobject" + "github.com/meshcloud/meshstack-cli/client/internal" +) + +const ( + Kind = "meshOther" + ApiVersion = "v1" + MediaType = "application/vnd.meshcloud.api.meshother.v1.hal+json" +) + +type Client struct { + api internal.Api +} + +func NewClient(httpClient internal.HttpClient) Client { + return Client{internal.Api{ + HttpClient: httpClient, + Kind: Kind, + ApiVersion: ApiVersion, + MediaType: MediaType, + Path: "/api/meshobjects/meshothers", + EmbeddedRel: "", + }} +} + +// Api lets the raw client of the CLI reach the same API version. +func (c Client) Api() internal.Api { + return c.api +} + +// Get other +func (c Client) Get(ctx context.Context, other string) (Response, error) { + return c.api.Do[Response](ctx, internal.Call{Method: "GET", Path: internal.Path("/api/meshobjects/meshothers/{other}", other), AnswersMeshObject: true}, nil) +} + +type Response struct { + Owner meshobject.Owner `json:"owner"` + Links meshobject.Links `json:"_links,omitzero"` +} diff --git a/client/openapi/generator/testdata/golden/meshthing/v2/generated.go.golden b/client/openapi/generator/testdata/golden/meshthing/v2/generated.go.golden new file mode 100644 index 00000000..7da4f81d --- /dev/null +++ b/client/openapi/generator/testdata/golden/meshthing/v2/generated.go.golden @@ -0,0 +1,165 @@ +// Code generated by github.com/meshcloud/meshstack-cli/client/openapi/generator. DO NOT EDIT. + +// Package meshthing is the meshObject kind meshThing in API version v2. +package meshthing + +import ( + "context" + "iter" + "time" + "uuid" + + "example.com/client/meshobject" + "example.com/client/types" + "github.com/meshcloud/meshstack-cli/client/internal" + "github.com/meshcloud/meshstack-cli/client/types/enum" +) + +const ( + Kind = "meshThing" + ApiVersion = "v2" + MediaType = "application/vnd.meshcloud.api.meshthing.v2.hal+json" +) + +type Client struct { + api internal.Api +} + +func NewClient(httpClient internal.HttpClient) Client { + return Client{internal.Api{ + HttpClient: httpClient, + Kind: Kind, + ApiVersion: ApiVersion, + MediaType: MediaType, + Path: "/api/meshobjects/meshthings", + EmbeddedRel: "meshThings", + }} +} + +// Api lets the raw client of the CLI reach the same API version. +func (c Client) Api() internal.Api { + return c.api +} + +// Create thing +func (c Client) Create(ctx context.Context, body Request) (Response, error) { + return c.api.Do[Response](ctx, internal.Call{Method: "POST", Path: "/api/meshobjects/meshthings", SendsMeshObject: true, AnswersMeshObject: true}, body) +} + +// Delete thing +func (c Client) Delete(ctx context.Context, thing uuid.UUID) error { + _, err := c.api.Do[any](ctx, internal.Call{Method: "DELETE", Path: internal.Path("/api/meshobjects/meshthings/{thing}", thing.String())}, nil) + return err +} + +// Get thing +func (c Client) Get(ctx context.Context, thing uuid.UUID) (Response, error) { + return c.api.Do[Response](ctx, internal.Call{Method: "GET", Path: internal.Path("/api/meshobjects/meshthings/{thing}", thing.String()), AnswersMeshObject: true}, nil) +} + +// List things +func (c Client) List(ctx context.Context, filter ListFilter) iter.Seq2[Response, error] { + return c.api.List[Response](ctx, internal.Call{Method: "GET", Path: "/api/meshobjects/meshthings", Query: filter, AnswersMeshObject: true}) +} + +// Paint thing +func (c Client) Paint(ctx context.Context, thing uuid.UUID, body PaintRequest) (Response, error) { + return c.api.Do[Response](ctx, internal.Call{Method: "POST", Path: internal.Path("/api/meshobjects/meshthings/{thing}/paint", thing.String()), AnswersMeshObject: true}, body) +} + +type ListFilter struct { + Sort []string `json:"sort"` + OwnerUuid string `json:"ownerUuid"` +} + +type AwsConfig struct { + Region string `json:"region"` +} + +type Config struct { + Type string `json:"type"` + Aws *AwsConfig `json:"aws,omitzero"` + Gcp *GcpConfig `json:"gcp,omitzero"` +} + +type GcpConfig struct { + Project string `json:"project"` +} + +type Metadata struct { + Uuid uuid.UUID `json:"uuid"` + CreatedOn time.Time `json:"createdOn"` + DeletedOn *time.Time `json:"deletedOn,omitzero"` + Owner *meshobject.Owner `json:"owner,omitzero"` +} + +type PaintRequest struct { + DryRun *bool `json:"dryRun,omitzero"` +} + +type Part struct { + Size float64 `json:"size"` +} + +type Request struct { + // What the thing is. + Spec SpecRequest `json:"spec"` +} + +type Response struct { + Metadata Metadata `json:"metadata"` + Spec SpecResponse `json:"spec"` + Status Status `json:"status"` + Links meshobject.Links `json:"_links,omitzero"` +} + +type SecretBoxRequest struct { + Plaintext *string `json:"plaintext,omitzero"` +} + +type SecretBoxResponse struct { + Hash *string `json:"hash,omitzero"` +} + +type SpecColor string + +const ( + SpecColorDeepRed SpecColor = "DEEP_RED" + SpecColorGreen SpecColor = "green" +) + +var SpecColors = enum.Of[SpecColor]("DEEP_RED", "green") + +type SpecRequest struct { + // The name. + // It spans two lines. + Name string `json:"name"` + Count *int64 `json:"count,omitzero"` + Color *SpecColor `json:"color,omitzero"` + Value types.SecretOrAny `json:"value,omitzero"` + Tags map[string][]string `json:"tags,omitzero"` + Parts []Part `json:"parts,omitzero"` + Spare *Part `json:"spare,omitzero"` + Target *meshobject.TargetRef `json:"target,omitzero"` + Secret SecretBoxRequest `json:"secret"` + Extra map[string]any `json:"extra,omitzero"` +} + +type SpecResponse struct { + // The name. + // It spans two lines. + Name string `json:"name"` + Count *int64 `json:"count,omitzero"` + Color *SpecColor `json:"color,omitzero"` + Value types.SecretOrAny `json:"value,omitzero"` + Tags map[string][]string `json:"tags,omitzero"` + Parts []Part `json:"parts,omitzero"` + Spare *Part `json:"spare,omitzero"` + Target *meshobject.TargetRef `json:"target,omitzero"` + Secret SecretBoxResponse `json:"secret"` + Extra map[string]any `json:"extra,omitzero"` +} + +type Status struct { + Config Config `json:"config"` +} diff --git a/client/openapi/generator/testdata/spec.json b/client/openapi/generator/testdata/spec.json new file mode 100644 index 00000000..11b97264 --- /dev/null +++ b/client/openapi/generator/testdata/spec.json @@ -0,0 +1,189 @@ +{ + "openapi": "3.0.1", + "paths": { + "/api/meshobjects/meshthings": { + "get": { + "summary": "List things", + "operationId": "meshThingList", + "parameters": [ + {"$ref": "#/components/parameters/page"}, + {"name": "sort", "in": "query", "schema": {"$ref": "#/components/schemas/array_string_0"}}, + {"name": "ownerUuid", "in": "query", "schema": {"type": "string", "format": "uuid"}} + ], + "responses": { + "200": {"description": "OK", "content": {"application/vnd.meshcloud.api.meshthing.v2.hal+json": {"schema": {"$ref": "#/components/schemas/HalPageMeshThingV2"}}}} + } + }, + "post": { + "summary": "Create thing", + "operationId": "meshThingPost", + "requestBody": {"content": {"application/vnd.meshcloud.api.meshthing.v2.hal+json": {"schema": {"$ref": "#/components/schemas/MeshThingV2Request"}}}}, + "responses": { + "201": {"$ref": "#/components/responses/Thing"}, + "400": {"$ref": "#/components/responses/BadRequest"} + } + } + }, + "/api/meshobjects/meshthings/{thing}": { + "parameters": [{"$ref": "#/components/parameters/thing"}], + "get": { + "summary": "Get thing", + "operationId": "meshThingGet", + "responses": {"200": {"$ref": "#/components/responses/Thing"}} + }, + "delete": { + "summary": "Delete thing", + "operationId": "meshThingDelete", + "responses": {"204": {"description": "Deleted"}} + } + }, + "/api/meshobjects/meshthings/{thing}/paint": { + "post": { + "summary": "Paint thing", + "operationId": "meshThingPaint", + "parameters": [{"$ref": "#/components/parameters/thing"}], + "requestBody": {"content": {"application/vnd.meshcloud.api.meshthing.v2.hal+json": {"schema": {"$ref": "#/components/schemas/MeshThingPaintRequest"}}}}, + "responses": {"200": {"$ref": "#/components/responses/Thing"}} + } + }, + "/api/meshobjects/meshothers/{other}": { + "get": { + "summary": "Get other", + "operationId": "meshOtherGet", + "parameters": [{"name": "other", "in": "path", "required": true, "schema": {"type": "string"}}], + "responses": { + "200": {"description": "OK", "content": {"application/vnd.meshcloud.api.meshother.v1.hal+json": {"schema": {"$ref": "#/components/schemas/HalEntityModelMeshOther"}}}} + } + } + } + }, + "components": { + "parameters": { + "page": {"name": "page", "in": "query", "schema": {"type": "integer", "format": "int32"}}, + "thing": {"name": "thing", "in": "path", "required": true, "schema": {"$ref": "#/components/schemas/string_uuid"}} + }, + "responses": { + "Thing": {"description": "The thing", "content": {"application/vnd.meshcloud.api.meshthing.v2.hal+json": {"schema": {"$ref": "#/components/schemas/HalEntityModelMeshThingV2"}}}}, + "BadRequest": {"description": "Bad request", "content": {"application/json": {"schema": {"type": "object"}}}} + }, + "schemas": { + "array_string_0": {"type": "array", "items": {"type": "string"}}, + "string_uuid": {"type": "string", "format": "uuid"}, + "string_date-time": {"type": "string", "format": "date-time"}, + "MapOfList": {"type": "object", "additionalProperties": {"$ref": "#/components/schemas/array_string_0"}}, + "HalLinks": {"type": "object", "readOnly": true, "additionalProperties": {"oneOf": [{"$ref": "#/components/schemas/HalLink"}, {"type": "array", "items": {"$ref": "#/components/schemas/HalLink"}}]}}, + "HalLink": {"type": "object", "required": ["href"], "properties": {"href": {"type": "string"}}}, + "HalPageMeshThingV2": { + "type": "object", + "properties": { + "_embedded": {"type": "object", "properties": {"meshThings": {"type": "array", "items": {"$ref": "#/components/schemas/HalEntityModelMeshThingV2"}}}}, + "_links": {"allOf": [{"$ref": "#/components/schemas/HalLinks"}]}, + "page": {"type": "object", "properties": {"number": {"type": "integer"}}} + } + }, + "HalEntityModelMeshThingV2": { + "allOf": [ + {"$ref": "#/components/schemas/MeshThingV2Response"}, + {"type": "object", "required": ["_links"], "properties": {"_links": {"allOf": [{"$ref": "#/components/schemas/HalLinks"}]}}} + ] + }, + "HalEntityModelMeshOther": { + "allOf": [ + {"$ref": "#/components/schemas/MeshOther"}, + {"type": "object", "properties": {"_links": {"allOf": [{"$ref": "#/components/schemas/HalLinks"}]}}} + ] + }, + "MeshThingV2Request": { + "type": "object", + "required": ["kind", "apiVersion", "spec"], + "properties": { + "kind": {"type": "string", "enum": ["meshThing"]}, + "apiVersion": {"type": "string", "enum": ["v2"]}, + "spec": {"description": "What the thing is.", "allOf": [{"$ref": "#/components/schemas/MeshThingSpecV2"}]} + } + }, + "MeshThingV2Response": { + "type": "object", + "required": ["kind", "apiVersion", "metadata", "spec", "status"], + "properties": { + "kind": {"type": "string", "enum": ["meshThing"]}, + "apiVersion": {"type": "string", "enum": ["v2"]}, + "metadata": {"$ref": "#/components/schemas/MeshThingMetadataV2"}, + "spec": {"allOf": [{"$ref": "#/components/schemas/MeshThingSpecV2"}]}, + "status": {"readOnly": true, "allOf": [{"$ref": "#/components/schemas/MeshThingStatusV2"}]} + } + }, + "MeshThingMetadataV2": { + "type": "object", + "required": ["uuid", "createdOn"], + "properties": { + "uuid": {"$ref": "#/components/schemas/string_uuid"}, + "createdOn": {"$ref": "#/components/schemas/string_date-time"}, + "deletedOn": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/string_date-time"}]}, + "owner": {"$ref": "#/components/schemas/Owner"} + } + }, + "MeshThingSpecV2": { + "type": "object", + "required": ["name", "count", "secret"], + "properties": { + "name": {"type": "string", "description": "The name.\nIt spans two lines."}, + "count": {"type": "integer", "format": "int64", "nullable": true}, + "color": {"type": "string", "enum": ["DEEP_RED", "green"]}, + "value": {"nullable": true, "oneOf": [{"type": "string"}, {"type": "boolean"}]}, + "tags": {"allOf": [{"$ref": "#/components/schemas/MapOfList"}]}, + "parts": {"type": "array", "items": {"$ref": "#/components/schemas/Part_0"}}, + "spare": {"$ref": "#/components/schemas/Part"}, + "target": {"$ref": "#/components/schemas/TargetRef"}, + "secret": {"$ref": "#/components/schemas/SecretBox"}, + "extra": {"type": "object", "additionalProperties": true} + } + }, + "MeshThingStatusV2": { + "type": "object", + "required": ["config"], + "properties": { + "config": {"$ref": "#/components/schemas/MeshThingConfigV2"} + } + }, + "MeshThingPaintRequest": { + "type": "object", + "properties": {"dryRun": {"type": "boolean"}} + }, + "Part": {"type": "object", "required": ["size"], "properties": {"size": {"type": "number"}}}, + "Part_0": {"type": "object", "required": ["size"], "properties": {"size": {"type": "number"}}}, + "SecretBox": { + "type": "object", + "properties": { + "hash": {"type": "string", "readOnly": true}, + "plaintext": {"type": "string", "writeOnly": true} + } + }, + "TargetRef": { + "discriminator": {"propertyName": "kind", "mapping": {"meshTenant": "#/components/schemas/TenantRef", "meshWorkspace": "#/components/schemas/WorkspaceRef"}}, + "oneOf": [{"$ref": "#/components/schemas/TenantRef"}, {"$ref": "#/components/schemas/WorkspaceRef"}] + }, + "TargetKind": {"type": "string", "enum": ["meshTenant", "meshWorkspace"]}, + "TenantRef": {"type": "object", "required": ["kind", "uuid"], "properties": {"kind": {"$ref": "#/components/schemas/TargetKind"}, "uuid": {"$ref": "#/components/schemas/string_uuid"}}}, + "WorkspaceRef": {"type": "object", "required": ["kind", "name"], "properties": {"kind": {"$ref": "#/components/schemas/TargetKind"}, "name": {"type": "string"}}}, + "MeshThingConfigV2": { + "discriminator": {"propertyName": "type", "mapping": {"aws": "#/components/schemas/AwsWrap", "gcp": "#/components/schemas/GcpWrap"}}, + "oneOf": [{"$ref": "#/components/schemas/AwsWrap"}, {"$ref": "#/components/schemas/GcpWrap"}] + }, + "AwsWrap": {"type": "object", "required": ["type", "aws"], "properties": {"type": {"type": "string", "enum": ["aws"]}, "aws": {"$ref": "#/components/schemas/AwsConfig"}}}, + "GcpWrap": {"type": "object", "required": ["type", "gcp"], "properties": {"type": {"type": "string", "enum": ["gcp"]}, "gcp": {"$ref": "#/components/schemas/GcpConfig"}}}, + "AwsConfig": {"type": "object", "required": ["region"], "properties": {"region": {"type": "string"}}}, + "GcpConfig": {"type": "object", "required": ["project"], "properties": {"project": {"type": "string"}}}, + "Owner": {"type": "object", "required": ["name"], "properties": {"name": {"type": "string"}, "target": {"$ref": "#/components/schemas/TargetRef"}}}, + "MeshOther": { + "type": "object", + "required": ["kind", "apiVersion", "owner"], + "properties": { + "kind": {"type": "string", "enum": ["meshOther"]}, + "apiVersion": {"type": "string", "enum": ["v1"]}, + "owner": {"$ref": "#/components/schemas/Owner"} + } + } + } + } +} From 22a97bd19c50bb5cbb7e79dc11121562bdef2289 Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Fri, 16 Oct 2026 14:01:15 +0200 Subject: [PATCH 07/18] feat(client): generate meshWorkspace v2, meshBuildingBlock v2 and meshBuildingBlockRun v1 go generate ./client downloads the API docs of develop; -spec names another document. The TypeMapper gives a building block input value the SecretOrAny the Terraform provider converts. Co-Authored-By: Claude Opus 5.5 --- client/generate.go | 3 + client/internal/generate/main.go | 33 ++ client/meshbuildingblock/v2/generated.go | 521 ++++++++++++++++++++ client/meshbuildingblockrun/v1/generated.go | 417 ++++++++++++++++ client/meshobject/generated.go | 116 +++++ client/meshworkspace/v2/generated.go | 125 +++++ 6 files changed, 1215 insertions(+) create mode 100644 client/generate.go create mode 100644 client/internal/generate/main.go create mode 100644 client/meshbuildingblock/v2/generated.go create mode 100644 client/meshbuildingblockrun/v1/generated.go create mode 100644 client/meshobject/generated.go create mode 100644 client/meshworkspace/v2/generated.go diff --git a/client/generate.go b/client/generate.go new file mode 100644 index 00000000..2b2c0cfd --- /dev/null +++ b/client/generate.go @@ -0,0 +1,3 @@ +package client + +//go:generate go run ./internal/generate diff --git a/client/internal/generate/main.go b/client/internal/generate/main.go new file mode 100644 index 00000000..1d7497cf --- /dev/null +++ b/client/internal/generate/main.go @@ -0,0 +1,33 @@ +// Command generate writes the packages of the meshObject kinds below client/. +package main + +import ( + "github.com/meshcloud/meshstack-cli/client/openapi" + "github.com/meshcloud/meshstack-cli/client/openapi/generator" +) + +func main() { + generator.Main(generator.Config{ + Module: "github.com/meshcloud/meshstack-cli/client", + Shared: "meshobject", + Packages: []generator.Package{ + {Kind: "meshWorkspace", ApiVersion: "v2", Actions: []string{"list", "show", "create", "update", "delete"}}, + {Kind: "meshBuildingBlock", ApiVersion: "v2-preview", Actions: []string{"list", "show", "create", "update", "delete", "purge", "trigger-run"}}, + {Kind: "meshBuildingBlockRun", ApiVersion: "v1", Actions: []string{"list", "show", "logs"}}, + }, + MapType: mapType, + }) +} + +const typesImport = "github.com/meshcloud/meshstack-cli/client/types" + +func mapType(at generator.Location, _ *openapi.Schema) (generator.GoType, bool) { + switch at { + case generator.Location{Schema: "MeshBuildingBlockInput", Property: "value"}: + return generator.GoType{Expr: "types.SecretOrAny", Import: typesImport, Container: true}, true + case generator.Location{Schema: "MeshBuildingBlockOutput", Property: "value"}, + generator.Location{Schema: "MeshBuildingBlockRun.MeshBuildingBlockRunSpec.MeshBuildingBlockInputsForRun", Property: "value"}: + return generator.GoType{Expr: "any", Container: true}, true + } + return generator.GoType{}, false +} diff --git a/client/meshbuildingblock/v2/generated.go b/client/meshbuildingblock/v2/generated.go new file mode 100644 index 00000000..a3cc8744 --- /dev/null +++ b/client/meshbuildingblock/v2/generated.go @@ -0,0 +1,521 @@ +// Code generated by github.com/meshcloud/meshstack-cli/client/openapi/generator. DO NOT EDIT. + +// Package meshbuildingblock is the meshObject kind meshBuildingBlock in API version v2-preview. +package meshbuildingblock + +import ( + "context" + "encoding/json/jsontext" + "encoding/json/v2" + "fmt" + "iter" + "time" + "uuid" + + "github.com/meshcloud/meshstack-cli/client/internal" + "github.com/meshcloud/meshstack-cli/client/meshobject" + "github.com/meshcloud/meshstack-cli/client/types" + "github.com/meshcloud/meshstack-cli/client/types/enum" +) + +const ( + Kind = "meshBuildingBlock" + ApiVersion = "v2-preview" + MediaType = "application/vnd.meshcloud.api.meshbuildingblock.v2-preview.hal+json" +) + +type Client struct { + api internal.Api +} + +func NewClient(httpClient internal.HttpClient) Client { + return Client{internal.Api{ + HttpClient: httpClient, + Kind: Kind, + ApiVersion: ApiVersion, + MediaType: MediaType, + Path: "/api/meshobjects/meshbuildingblocks", + EmbeddedRel: "meshBuildingBlocks", + }} +} + +// Api lets the raw client of the CLI reach the same API version. +func (c Client) Api() internal.Api { + return c.api +} + +// Create building block +func (c Client) Create(ctx context.Context, body Request) (Response, error) { + return c.api.Do[Response](ctx, internal.Call{Method: "POST", Path: "/api/meshobjects/meshbuildingblocks", SendsMeshObject: true, AnswersMeshObject: true}, body) +} + +// Delete building block +func (c Client) Delete(ctx context.Context, buildingBlock uuid.UUID) error { + _, err := c.api.Do[any](ctx, internal.Call{Method: "DELETE", Path: internal.Path("/api/meshobjects/meshbuildingblocks/{buildingBlock}", buildingBlock.String())}, nil) + return err +} + +// Get building block +func (c Client) Get(ctx context.Context, buildingBlock uuid.UUID) (Response, error) { + return c.api.Do[Response](ctx, internal.Call{Method: "GET", Path: internal.Path("/api/meshobjects/meshbuildingblocks/{buildingBlock}", buildingBlock.String()), AnswersMeshObject: true}, nil) +} + +// List building blocks +func (c Client) List(ctx context.Context, filter ListFilter) iter.Seq2[Response, error] { + return c.api.List[Response](ctx, internal.Call{Method: "GET", Path: "/api/meshobjects/meshbuildingblocks", Query: filter, AnswersMeshObject: true}) +} + +// Purge building block +func (c Client) Purge(ctx context.Context, buildingBlock uuid.UUID) error { + _, err := c.api.Do[any](ctx, internal.Call{Method: "DELETE", Path: internal.Path("/api/meshobjects/meshbuildingblocks/{buildingBlock}/purge", buildingBlock.String())}, nil) + return err +} + +// Trigger run of building block +func (c Client) TriggerRun(ctx context.Context, buildingBlock uuid.UUID, body TriggerRunRequest) (Response, error) { + return c.api.Do[Response](ctx, internal.Call{Method: "POST", Path: internal.Path("/api/meshobjects/meshbuildingblocks/{buildingBlock}/trigger-run", buildingBlock.String()), AnswersMeshObject: true}, body) +} + +// Update building block +func (c Client) Update(ctx context.Context, buildingBlock uuid.UUID, body Request) (Response, error) { + return c.api.Do[Response](ctx, internal.Call{Method: "PUT", Path: internal.Path("/api/meshobjects/meshbuildingblocks/{buildingBlock}", buildingBlock.String()), SendsMeshObject: true, AnswersMeshObject: true}, body) +} + +type ListFilter struct { + WorkspaceIdentifier string `json:"workspaceIdentifier"` + CustomerIdentifier string `json:"customerIdentifier"` + ProjectIdentifier string `json:"projectIdentifier"` + PlatformIdentifier string `json:"platformIdentifier"` + Name string `json:"name"` + DefinitionName string `json:"definitionName"` + DefinitionUuid string `json:"definitionUuid"` + VersionNumber string `json:"versionNumber"` + Status string `json:"status"` + Sort []string `json:"sort"` + VersionUuid string `json:"versionUuid"` + TenantUuid string `json:"tenantUuid"` + TargetRefKind string `json:"targetRefKind"` + HealthStatus []string `json:"healthStatus"` + Action []string `json:"action"` + ManagedByWorkspaceIdentifier string `json:"managedByWorkspaceIdentifier"` + ManagedByDefinitionUuid string `json:"managedByDefinitionUuid"` + LifecycleState []string `json:"lifecycleState"` +} + +type ActionValue string + +const ( + ActionValueProvideInput ActionValue = "PROVIDE_INPUT" + ActionValueWaitingForDependency ActionValue = "WAITING_FOR_DEPENDENCY" + ActionValueApproveRun ActionValue = "APPROVE_RUN" + ActionValueReviewDrift ActionValue = "REVIEW_DRIFT" + ActionValuePending ActionValue = "PENDING" + ActionValueInProgress ActionValue = "IN_PROGRESS" + ActionValueUpgradeAvailable ActionValue = "UPGRADE_AVAILABLE" + ActionValueReviewErrors ActionValue = "REVIEW_ERRORS" + ActionValueNoAction ActionValue = "NO_ACTION" +) + +var ActionValues = enum.Of[ActionValue]("PROVIDE_INPUT", "WAITING_FOR_DEPENDENCY", "APPROVE_RUN", "REVIEW_DRIFT", "PENDING", "IN_PROGRESS", "UPGRADE_AVAILABLE", "REVIEW_ERRORS", "NO_ACTION") + +type DefinitionOutputAssignmentType string + +const ( + DefinitionOutputAssignmentTypeNone DefinitionOutputAssignmentType = "NONE" + DefinitionOutputAssignmentTypePlatformTenantId DefinitionOutputAssignmentType = "PLATFORM_TENANT_ID" + DefinitionOutputAssignmentTypeSignInUrl DefinitionOutputAssignmentType = "SIGN_IN_URL" + DefinitionOutputAssignmentTypeResourceUrl DefinitionOutputAssignmentType = "RESOURCE_URL" + DefinitionOutputAssignmentTypeSummary DefinitionOutputAssignmentType = "SUMMARY" +) + +var DefinitionOutputAssignmentTypes = enum.Of[DefinitionOutputAssignmentType]("NONE", "PLATFORM_TENANT_ID", "SIGN_IN_URL", "RESOURCE_URL", "SUMMARY") + +type DefinitionVersionRefRequest struct { + // The uuid of the meshBuildingBlockDefinitionVersion for this Building Block + Uuid uuid.UUID `json:"uuid"` + // This is a fixed value "meshBuildingBlockDefinitionVersion" + Kind *DefinitionVersionRefRequestKind `json:"kind,omitzero"` + Name *string `json:"name,omitzero"` +} + +// This is a fixed value "meshBuildingBlockDefinitionVersion" +type DefinitionVersionRefRequestKind string + +const ( + DefinitionVersionRefRequestKindMeshBuildingBlockDefinitionVersion DefinitionVersionRefRequestKind = "meshBuildingBlockDefinitionVersion" +) + +var DefinitionVersionRefRequestKinds = enum.Of[DefinitionVersionRefRequestKind]("meshBuildingBlockDefinitionVersion") + +type DefinitionVersionRefResponse struct { + // The uuid of the meshBuildingBlockDefinitionVersion for this Building Block + Uuid uuid.UUID `json:"uuid"` + // This is a fixed value "meshBuildingBlockDefinitionVersion" + Kind DefinitionVersionRefResponseKind `json:"kind"` + Name *string `json:"name,omitzero"` +} + +// This is a fixed value "meshBuildingBlockDefinitionVersion" +type DefinitionVersionRefResponseKind string + +const ( + DefinitionVersionRefResponseKindMeshBuildingBlockDefinitionVersion DefinitionVersionRefResponseKind = "meshBuildingBlockDefinitionVersion" +) + +var DefinitionVersionRefResponseKinds = enum.Of[DefinitionVersionRefResponseKind]("meshBuildingBlockDefinitionVersion") + +type HealthStatusValue string + +const ( + HealthStatusValueNone HealthStatusValue = "NONE" + HealthStatusValueSucceeded HealthStatusValue = "SUCCEEDED" + HealthStatusValueFailed HealthStatusValue = "FAILED" + HealthStatusValueDeleted HealthStatusValue = "DELETED" +) + +var HealthStatusValues = enum.Of[HealthStatusValue]("NONE", "SUCCEEDED", "FAILED", "DELETED") + +type IOType string + +const ( + IOTypeString IOType = "STRING" + IOTypeCode IOType = "CODE" + IOTypeInteger IOType = "INTEGER" + IOTypeBoolean IOType = "BOOLEAN" + IOTypeFile IOType = "FILE" + IOTypeSingleSelect IOType = "SINGLE_SELECT" + IOTypeMultiSelect IOType = "MULTI_SELECT" + IOTypeJson IOType = "JSON" +) + +var IOTypes = enum.Of[IOType]("STRING", "CODE", "INTEGER", "BOOLEAN", "FILE", "SINGLE_SELECT", "MULTI_SELECT", "JSON") + +type InputAssignmentType string + +const ( + InputAssignmentTypeAuthor InputAssignmentType = "AUTHOR" + InputAssignmentTypeUserInput InputAssignmentType = "USER_INPUT" + InputAssignmentTypePlatformOperatorManualInput InputAssignmentType = "PLATFORM_OPERATOR_MANUAL_INPUT" + InputAssignmentTypeBuildingBlockOutput InputAssignmentType = "BUILDING_BLOCK_OUTPUT" + InputAssignmentTypePlatformTenantId InputAssignmentType = "PLATFORM_TENANT_ID" + InputAssignmentTypeMeshstackTenantUuid InputAssignmentType = "MESHSTACK_TENANT_UUID" + InputAssignmentTypeWorkspaceIdentifier InputAssignmentType = "WORKSPACE_IDENTIFIER" + InputAssignmentTypeProjectIdentifier InputAssignmentType = "PROJECT_IDENTIFIER" + InputAssignmentTypeFullPlatformIdentifier InputAssignmentType = "FULL_PLATFORM_IDENTIFIER" + InputAssignmentTypeTenantBuildingBlockUuid InputAssignmentType = "TENANT_BUILDING_BLOCK_UUID" + InputAssignmentTypeStatic InputAssignmentType = "STATIC" + InputAssignmentTypeUserPermissions InputAssignmentType = "USER_PERMISSIONS" + InputAssignmentTypeTag InputAssignmentType = "TAG" + InputAssignmentTypePaymentMethod InputAssignmentType = "PAYMENT_METHOD" +) + +var InputAssignmentTypes = enum.Of[InputAssignmentType]("AUTHOR", "USER_INPUT", "PLATFORM_OPERATOR_MANUAL_INPUT", "BUILDING_BLOCK_OUTPUT", "PLATFORM_TENANT_ID", "MESHSTACK_TENANT_UUID", "WORKSPACE_IDENTIFIER", "PROJECT_IDENTIFIER", "FULL_PLATFORM_IDENTIFIER", "TENANT_BUILDING_BLOCK_UUID", "STATIC", "USER_PERMISSIONS", "TAG", "PAYMENT_METHOD") + +type InputRequest struct { + // The value of this input. For non-sensitive inputs, the plain value in the shape its type takes: + // + // - `STRING`, `CODE`, `FILE` and `SINGLE_SELECT`: a string. + // - `MULTI_SELECT`: a list of strings. + // - `BOOLEAN`: `true` or `false`. + // - `INTEGER`: a whole number from -2147483648 to 2147483647. + // - `JSON`: the JSON text, as a string, that matches the input's JSON schema. + // + // A value of another shape is rejected with 400 Bad Request. A 'PAYMENT_METHOD' input takes and reports the Payment Method's ref, e.g. `{ "kind": "meshPaymentMethod", "name": "" }`. For sensitive inputs, a request provides an object with a `plaintext` field containing the secret value, e.g. `{ "plaintext": "my-secret" }`, and a response contains a SecretEmbedded object with only a `hash` field (plaintext is never returned in responses). Null fields are normally omitted from meshObject API responses; this field is the exception when `isOptional` is true, where an explicit `null` reports that no value is supplied. + Value types.SecretOrAny `json:"value,omitzero"` + // The data type of the value. One of STRING, CODE, INTEGER, BOOLEAN, FILE, SINGLE_SELECT, MULTI_SELECT, JSON. + ValueType *IOType `json:"valueType,omitzero"` +} + +type InputResponse struct { + // The value of this input. For non-sensitive inputs, the plain value in the shape its type takes: + // + // - `STRING`, `CODE`, `FILE` and `SINGLE_SELECT`: a string. + // - `MULTI_SELECT`: a list of strings. + // - `BOOLEAN`: `true` or `false`. + // - `INTEGER`: a whole number from -2147483648 to 2147483647. + // - `JSON`: the JSON text, as a string, that matches the input's JSON schema. + // + // A value of another shape is rejected with 400 Bad Request. A 'PAYMENT_METHOD' input takes and reports the Payment Method's ref, e.g. `{ "kind": "meshPaymentMethod", "name": "" }`. For sensitive inputs, a request provides an object with a `plaintext` field containing the secret value, e.g. `{ "plaintext": "my-secret" }`, and a response contains a SecretEmbedded object with only a `hash` field (plaintext is never returned in responses). Null fields are normally omitted from meshObject API responses; this field is the exception when `isOptional` is true, where an explicit `null` reports that no value is supplied. + Value types.SecretOrAny `json:"value,omitzero"` + // The data type of the value. One of STRING, CODE, INTEGER, BOOLEAN, FILE, SINGLE_SELECT, MULTI_SELECT, JSON. + ValueType *IOType `json:"valueType,omitzero"` + // Whether this input contains sensitive data. + IsSensitive *bool `json:"isSensitive,omitzero"` + // The assignment type of the input as defined on the Building Block Definition. + AssignmentType *InputAssignmentType `json:"assignmentType,omitzero"` + // Whether this input can be updated by the consumer. + UpdateableByConsumer *bool `json:"updateableByConsumer,omitzero"` + // Whether the Building Block Definition allows this input to be left unset. meshStack then sends no value and the + // implementation falls back to the default declared in its own code. It is defined on the Building Block Definition + // Version. + IsOptional bool `json:"isOptional"` +} + +type MeshLifecycle struct { + // The lifecycle state of this meshObject. One of ACTIVE, DEACTIVATED, MARKED_FOR_DELETION, DELETED. + State MeshLifecycleState `json:"state"` + // When this meshObject was created, and by whom if an author is known. + Created Transition `json:"created"` + // Present when this meshObject has been deactivated: when that happened, and by whom if known. + Deactivated *Transition `json:"deactivated,omitzero"` + // Present when this meshObject has been marked for deletion: when the deletion was requested, and by whom if known. + MarkedForDeletion *Transition `json:"markedForDeletion,omitzero"` + // When this meshObject was deleted in the platform (if it has been), and by whom if known. + Deleted *Transition `json:"deleted,omitzero"` +} + +type MeshLifecycleState string + +const ( + MeshLifecycleStateActive MeshLifecycleState = "ACTIVE" + MeshLifecycleStateDeactivated MeshLifecycleState = "DEACTIVATED" + MeshLifecycleStateMarkedForDeletion MeshLifecycleState = "MARKED_FOR_DELETION" + MeshLifecycleStateDeleted MeshLifecycleState = "DELETED" +) + +var MeshLifecycleStates = enum.Of[MeshLifecycleState]("ACTIVE", "DEACTIVATED", "MARKED_FOR_DELETION", "DELETED") + +type MeshParentBuildingBlock struct { + // (Deprecated: Use parentBuildingBlockRefs instead) Reference to the parent Building Block, identified by its uuid. + BuildingBlockRef *Ref `json:"buildingBlockRef,omitzero"` + // (Deprecated: Use parentBuildingBlockRefs instead) The UUID of the parent Building Block. + BuildingBlockUuid *string `json:"buildingBlockUuid,omitzero"` + // (Deprecated: Use parentBuildingBlockRefs instead) The UUID of the Building Block Definition of the parent Building Block. It is + // derived from the referenced Building Block, and ignored when supplied in a request. + DefinitionUuid *string `json:"definitionUuid,omitzero"` +} + +type Metadata struct { + // The UUID to uniquely identify the Building Block. + Uuid *string `json:"uuid,omitzero"` + // The identifier of the meshWorkspace the Building Blocks belongs to. + OwnedByWorkspace string `json:"ownedByWorkspace"` +} + +type Output struct { + // The value of this output. + Value any `json:"value,omitzero"` + // The data type of the value. One of STRING, CODE, INTEGER, BOOLEAN, FILE. + ValueType OutputType `json:"valueType"` + // The assignment type of the output as defined on the Building Block Definition. One of NONE, PLATFORM_TENANT_ID, + // SIGN_IN_URL, RESOURCE_URL, SUMMARY. + AssignmentType DefinitionOutputAssignmentType `json:"assignmentType"` +} + +type OutputType string + +const ( + OutputTypeString OutputType = "STRING" + OutputTypeCode OutputType = "CODE" + OutputTypeInteger OutputType = "INTEGER" + OutputTypeBoolean OutputType = "BOOLEAN" + OutputTypeFile OutputType = "FILE" +) + +var OutputTypes = enum.Of[OutputType]("STRING", "CODE", "INTEGER", "BOOLEAN", "FILE") + +type Ref struct { + // The UUID identifying the referenced meshObject. + Uuid uuid.UUID `json:"uuid"` + // The kind of the referenced meshObject. Always "meshBuildingBlock". + Kind RefKind `json:"kind"` + Name *string `json:"name,omitzero"` +} + +// The kind of the referenced meshObject. Always "meshBuildingBlock". +type RefKind string + +const ( + RefKindMeshBuildingBlock RefKind = "meshBuildingBlock" +) + +var RefKinds = enum.Of[RefKind]("meshBuildingBlock") + +type Request struct { + // Always contains the 'uuid' to uniquely identify the Building Block and the workspace it belongs to. + Metadata *Metadata `json:"metadata,omitzero"` + // All fields in this section describe the Building Block. + Spec SpecRequest `json:"spec"` +} + +type Response struct { + // Always contains the 'uuid' to uniquely identify the Building Block and the workspace it belongs to. + Metadata Metadata `json:"metadata"` + // All fields in this section describe the Building Block. + Spec SpecResponse `json:"spec"` + // All fields in this section describe the current status of the Building Block. + Status StatusResponse `json:"status"` + Links meshobject.Links `json:"_links,omitzero"` +} + +type SpecRequest struct { + // The reference to the meshBuildingBlockDefinitionVersion for this Building Block + BuildingBlockDefinitionVersionRef DefinitionVersionRefRequest `json:"buildingBlockDefinitionVersionRef"` + // The reference to either a tenant or workspace the Building Block is located on. A tenant is referenced by `uuid`, + // a workspace by its identifier in `name`. + TargetRef TargetRefRequest `json:"targetRef"` + // The display name of the Building Block as it is shown in meshPanel. + DisplayName string `json:"displayName"` + // Map of input keys to their input data. A request contains only the inputs it may update, given sufficient + // permission. A response contains all inputs the Building Block Definition defines, with their values; a sensitive + // value is returned as its hash only, because meshStack does not store the plaintext. + Inputs map[string]InputRequest `json:"inputs"` + // (Deprecated: Use parentBuildingBlockRefs instead) List of parent Building Blocks, which are required for the Building Block. + ParentBuildingBlocks []MeshParentBuildingBlock `json:"parentBuildingBlocks,omitzero"` + // List of references to the parent Building Blocks of this Building Block. Each entry contains a uuid and kind. + ParentBuildingBlockRefs []Ref `json:"parentBuildingBlockRefs,omitzero"` +} + +type SpecResponse struct { + // The reference to the meshBuildingBlockDefinitionVersion for this Building Block + BuildingBlockDefinitionVersionRef DefinitionVersionRefResponse `json:"buildingBlockDefinitionVersionRef"` + // The reference to either a tenant or workspace the Building Block is located on. A tenant is referenced by `uuid`, + // a workspace by its identifier in `name`. + TargetRef meshobject.MeshBuildingBlockTargetRefResponse `json:"targetRef"` + // The display name of the Building Block as it is shown in meshPanel. + DisplayName string `json:"displayName"` + // Map of input keys to their input data. A request contains only the inputs it may update, given sufficient + // permission. A response contains all inputs the Building Block Definition defines, with their values; a sensitive + // value is returned as its hash only, because meshStack does not store the plaintext. + Inputs map[string]InputResponse `json:"inputs"` + // (Deprecated: Use parentBuildingBlockRefs instead) List of parent Building Blocks, which are required for the Building Block. + ParentBuildingBlocks []MeshParentBuildingBlock `json:"parentBuildingBlocks"` + // List of references to the parent Building Blocks of this Building Block. Each entry contains a uuid and kind. + ParentBuildingBlockRefs []Ref `json:"parentBuildingBlockRefs,omitzero"` +} + +type StatusResponse struct { + // Deprecated: rely on `status.healthStatus` and `status.action` instead, which separate the two questions this field + // conflates — whether the Building Block is healthy, and what it needs from you next. The status of the Building + // Block. One of WAITING_FOR_DEPENDENT_INPUT, WAITING_FOR_OPERATOR_INPUT, WAITING_FOR_USER_INPUT, + // WAITING_FOR_APPROVAL, PENDING, IN_PROGRESS, SUCCEEDED, FAILED, ABORTED. A request that triggers a run reports + // `PENDING` right away, before the run itself exists, so you can poll this field for that run's outcome without + // mistaking the previous run's status for it. + Status StatusValue `json:"status"` + // The outcome of the latest run that speaks for the Building Block: an apply or destroy run, or a preflight that + // failed or confirmed there is nothing to change. A preflight that planned a change leaves this field to the run + // that last changed something. One of NONE, SUCCEEDED, FAILED, DELETED. Unlike `status.status` only a terminal + // outcome moves it, so it keeps reporting `FAILED` until a run succeeds again, and `NONE` until the first such run + // has finished. Use the `healthStatus` query parameter on the list endpoint to filter on this value. + HealthStatus HealthStatusValue `json:"healthStatus"` + // What the Building Block needs from you next, as shown in the Action column in meshPanel. One of PROVIDE_INPUT, + // WAITING_FOR_DEPENDENCY, APPROVE_RUN, REVIEW_DRIFT, PENDING, IN_PROGRESS, UPGRADE_AVAILABLE, REVIEW_ERRORS, + // NO_ACTION. `PROVIDE_INPUT` covers both input-waiting statuses, `REVIEW_DRIFT` reports a dry run that detected + // changes, and `UPGRADE_AVAILABLE` reports a Building Block with no pending action whose definition version has been + // superseded. A deleted Building Block always reports `NO_ACTION`. Use the `action` query parameter on the list + // endpoint to filter on this value. + Action ActionValue `json:"action"` + // Map of output keys to their output data. Outputs are only available once the building block has been successfully + // executed, so this is an empty map right after creation. Poll the GET endpoint to retrieve outputs once the + // building block has reached status `SUCCEEDED`. + Outputs map[string]Output `json:"outputs"` + // The UUID of the latest modifying (apply/destroy) meshBuildingBlockRun for this Building Block. Dry runs are + // excluded — see `status.latestDryRunUuid`. Null when there is no modifying run yet. Use the meshBuildingBlockRun + // API to read the run and its logs; access to that content is governed there (run transparency does not gate this + // identifier). + LatestRunUuid *string `json:"latestRunUuid,omitzero"` + // The UUID of the latest dry run for this Building Block, but only when it is the newest run — i.e. no modifying + // (apply/destroy) run is newer; null otherwise. This guarantees the field never points to a stale dry run that a + // later modifying run has superseded. + LatestDryRunUuid *string `json:"latestDryRunUuid,omitzero"` + // Why meshStack could not start the run this Building Block was last asked to perform, phrased for the user of the + // Building Block and saying what they can do about it. Null while nothing stands in the way of a run. + RunStartFailure *string `json:"runStartFailure,omitzero"` + // The date `status.runStartFailure` was last recorded (e.g. `2020-12-22T09:37:43Z`). Null while nothing stands in + // the way of a run. + RunStartFailedOn *time.Time `json:"runStartFailedOn,omitzero"` + // Indicates whether this building block was marked to be purged. + ForcePurge bool `json:"forcePurge"` + // Lifecycle information of this meshObject: its state and the transitions into it. + Lifecycle MeshLifecycle `json:"lifecycle"` +} + +type StatusValue string + +const ( + StatusValueWaitingForDependentInput StatusValue = "WAITING_FOR_DEPENDENT_INPUT" + StatusValueWaitingForOperatorInput StatusValue = "WAITING_FOR_OPERATOR_INPUT" + StatusValueWaitingForUserInput StatusValue = "WAITING_FOR_USER_INPUT" + StatusValueWaitingForApproval StatusValue = "WAITING_FOR_APPROVAL" + StatusValuePending StatusValue = "PENDING" + StatusValueInProgress StatusValue = "IN_PROGRESS" + StatusValueSucceeded StatusValue = "SUCCEEDED" + StatusValueFailed StatusValue = "FAILED" + StatusValueAborted StatusValue = "ABORTED" +) + +var StatusValues = enum.Of[StatusValue]("WAITING_FOR_DEPENDENT_INPUT", "WAITING_FOR_OPERATOR_INPUT", "WAITING_FOR_USER_INPUT", "WAITING_FOR_APPROVAL", "PENDING", "IN_PROGRESS", "SUCCEEDED", "FAILED", "ABORTED") + +type TargetRefRequest struct { + Kind meshobject.MeshBuildingBlockTargetRefDiscriminator + MeshTenant *TargetTenantRefRequest + MeshWorkspace *TargetWorkspaceRefRequest +} + +func (u TargetRefRequest) MarshalJSONTo(enc *jsontext.Encoder) error { + switch u.Kind { + case "meshTenant": + if u.MeshTenant != nil { + return json.MarshalEncode(enc, struct { + Discriminator meshobject.MeshBuildingBlockTargetRefDiscriminator `json:"kind"` + *TargetTenantRefRequest `json:",embed"` + }{u.Kind, u.MeshTenant}) + } + case "meshWorkspace": + if u.MeshWorkspace != nil { + return json.MarshalEncode(enc, struct { + Discriminator meshobject.MeshBuildingBlockTargetRefDiscriminator `json:"kind"` + *TargetWorkspaceRefRequest `json:",embed"` + }{u.Kind, u.MeshWorkspace}) + } + } + return fmt.Errorf("TargetRefRequest has no value for kind %q", u.Kind) +} + +// UnmarshalJSONFrom leaves every variant nil for a kind this client does not know yet. +func (u *TargetRefRequest) UnmarshalJSONFrom(dec *jsontext.Decoder) error { + raw, err := dec.ReadValue() + if err != nil { + return err + } + var discriminator struct { + Value meshobject.MeshBuildingBlockTargetRefDiscriminator `json:"kind"` + } + if err := json.Unmarshal(raw, &discriminator); err != nil { + return err + } + *u = TargetRefRequest{Kind: discriminator.Value} + switch discriminator.Value { + case "meshTenant": + u.MeshTenant = new(TargetTenantRefRequest) + return json.Unmarshal(raw, u.MeshTenant) + case "meshWorkspace": + u.MeshWorkspace = new(TargetWorkspaceRefRequest) + return json.Unmarshal(raw, u.MeshWorkspace) + } + return nil +} + +type TargetTenantRefRequest struct { + // The uuid of the tenant for this Building Block. Only for kind "meshTenant"; a workspace has no uuid, use `name`. + Uuid *uuid.UUID `json:"uuid,omitzero"` + // The identifier of the workspace for this Building Block. Only for kind "meshWorkspace", where it replaces `uuid`. + Name *string `json:"name,omitzero"` +} + +type TargetWorkspaceRefRequest struct { + Name *string `json:"name,omitzero"` + Uuid *string `json:"uuid,omitzero"` +} + +type Transition struct { + // The date this happened (e.g. `2020-12-22T09:37:43Z`). + Timestamp time.Time `json:"timestamp"` + // The author of this action; the timestamp and displayName may accompany it. Absent when not known. + Author *meshobject.MeshObjectAuthor `json:"author,omitzero"` +} + +type TriggerRunRequest struct { + // When `true`, performs a dry run instead of a regular run. Only supported for OpenTofu building blocks. + DryRun *bool `json:"dryRun,omitzero"` +} diff --git a/client/meshbuildingblockrun/v1/generated.go b/client/meshbuildingblockrun/v1/generated.go new file mode 100644 index 00000000..5947611b --- /dev/null +++ b/client/meshbuildingblockrun/v1/generated.go @@ -0,0 +1,417 @@ +// Code generated by github.com/meshcloud/meshstack-cli/client/openapi/generator. DO NOT EDIT. + +// Package meshbuildingblockrun is the meshObject kind meshBuildingBlockRun in API version v1. +package meshbuildingblockrun + +import ( + "context" + "encoding/json/jsontext" + "encoding/json/v2" + "fmt" + "iter" + "time" + "uuid" + + "github.com/meshcloud/meshstack-cli/client/internal" + "github.com/meshcloud/meshstack-cli/client/meshobject" + "github.com/meshcloud/meshstack-cli/client/types/enum" +) + +const ( + Kind = "meshBuildingBlockRun" + ApiVersion = "v1" + MediaType = "application/vnd.meshcloud.api.meshbuildingblockrun.v1.hal+json" +) + +type Client struct { + api internal.Api +} + +func NewClient(httpClient internal.HttpClient) Client { + return Client{internal.Api{ + HttpClient: httpClient, + Kind: Kind, + ApiVersion: ApiVersion, + MediaType: MediaType, + Path: "/api/meshobjects/meshbuildingblockruns", + EmbeddedRel: "meshBuildingBlockRuns", + }} +} + +// Api lets the raw client of the CLI reach the same API version. +func (c Client) Api() internal.Api { + return c.api +} + +// Get building block run +func (c Client) Get(ctx context.Context, buildingBlockRun uuid.UUID) (Response, error) { + return c.api.Do[Response](ctx, internal.Call{Method: "GET", Path: internal.Path("/api/meshobjects/meshbuildingblockruns/{buildingBlockRun}", buildingBlockRun.String()), AnswersMeshObject: true}, nil) +} + +// List building block runs +func (c Client) List(ctx context.Context, filter ListFilter) iter.Seq2[Response, error] { + return c.api.List[Response](ctx, internal.Call{Method: "GET", Path: "/api/meshobjects/meshbuildingblockruns", Query: filter, AnswersMeshObject: true}) +} + +// Download logs of building block run +func (c Client) Logs(ctx context.Context, buildingBlockRun uuid.UUID) (LogsDTO, error) { + return c.api.Do[LogsDTO](ctx, internal.Call{Method: "GET", Path: internal.Path("/api/meshobjects/meshbuildingblockruns/{buildingBlockRun}/logs", buildingBlockRun.String())}, nil) +} + +type ListFilter struct { + BuildingBlockUuid string `json:"buildingBlockUuid"` + Sort []string `json:"sort"` +} + +type Behavior string + +const ( + BehaviorApply Behavior = "APPLY" + BehaviorDetect Behavior = "DETECT" + BehaviorDestroy Behavior = "DESTROY" +) + +var Behaviors = enum.Of[Behavior]("APPLY", "DETECT", "DESTROY") + +type BuildingBlock struct { + // UUID of the referenced building block. + Uuid string `json:"uuid"` + Spec MeshBuildingBlockSpecForRun `json:"spec"` +} + +type BuildingBlockDefinition struct { + // UUID of the building block definition. + Uuid string `json:"uuid"` + Spec MeshBuildingBlockMinimalDefinitionSpec `json:"spec"` +} + +type BuildingBlockImplementation struct { + Type BuildingBlockImplementationDiscriminator + Manual *MeshManualBuildingBlockImplementation + Terraform *MeshBuildingBlockTerraformImplementation + GithubWorkflow *MeshBuildingBlockGithubImplementation + GitlabCicd *MeshBuildingBlockGitlabImplementation + AzureDevops *MeshBuildingBlockAzureDevOpsImplementation +} + +func (u BuildingBlockImplementation) MarshalJSONTo(enc *jsontext.Encoder) error { + switch u.Type { + case "MANUAL": + if u.Manual != nil { + return json.MarshalEncode(enc, struct { + Discriminator BuildingBlockImplementationDiscriminator `json:"type"` + *MeshManualBuildingBlockImplementation `json:",embed"` + }{u.Type, u.Manual}) + } + case "TERRAFORM": + if u.Terraform != nil { + return json.MarshalEncode(enc, struct { + Discriminator BuildingBlockImplementationDiscriminator `json:"type"` + *MeshBuildingBlockTerraformImplementation `json:",embed"` + }{u.Type, u.Terraform}) + } + case "GITHUB_WORKFLOW": + if u.GithubWorkflow != nil { + return json.MarshalEncode(enc, struct { + Discriminator BuildingBlockImplementationDiscriminator `json:"type"` + *MeshBuildingBlockGithubImplementation `json:",embed"` + }{u.Type, u.GithubWorkflow}) + } + case "GITLAB_CICD": + if u.GitlabCicd != nil { + return json.MarshalEncode(enc, struct { + Discriminator BuildingBlockImplementationDiscriminator `json:"type"` + *MeshBuildingBlockGitlabImplementation `json:",embed"` + }{u.Type, u.GitlabCicd}) + } + case "AZURE_DEVOPS": + if u.AzureDevops != nil { + return json.MarshalEncode(enc, struct { + Discriminator BuildingBlockImplementationDiscriminator `json:"type"` + *MeshBuildingBlockAzureDevOpsImplementation `json:",embed"` + }{u.Type, u.AzureDevops}) + } + } + return fmt.Errorf("BuildingBlockImplementation has no value for type %q", u.Type) +} + +// UnmarshalJSONFrom leaves every variant nil for a type this client does not know yet. +func (u *BuildingBlockImplementation) UnmarshalJSONFrom(dec *jsontext.Decoder) error { + raw, err := dec.ReadValue() + if err != nil { + return err + } + var discriminator struct { + Value BuildingBlockImplementationDiscriminator `json:"type"` + } + if err := json.Unmarshal(raw, &discriminator); err != nil { + return err + } + *u = BuildingBlockImplementation{Type: discriminator.Value} + switch discriminator.Value { + case "MANUAL": + u.Manual = new(MeshManualBuildingBlockImplementation) + return json.Unmarshal(raw, u.Manual) + case "TERRAFORM": + u.Terraform = new(MeshBuildingBlockTerraformImplementation) + return json.Unmarshal(raw, u.Terraform) + case "GITHUB_WORKFLOW": + u.GithubWorkflow = new(MeshBuildingBlockGithubImplementation) + return json.Unmarshal(raw, u.GithubWorkflow) + case "GITLAB_CICD": + u.GitlabCicd = new(MeshBuildingBlockGitlabImplementation) + return json.Unmarshal(raw, u.GitlabCicd) + case "AZURE_DEVOPS": + u.AzureDevops = new(MeshBuildingBlockAzureDevOpsImplementation) + return json.Unmarshal(raw, u.AzureDevops) + } + return nil +} + +type BuildingBlockImplementationDiscriminator string + +const ( + BuildingBlockImplementationDiscriminatorManual BuildingBlockImplementationDiscriminator = "MANUAL" + BuildingBlockImplementationDiscriminatorTerraform BuildingBlockImplementationDiscriminator = "TERRAFORM" + BuildingBlockImplementationDiscriminatorGithubWorkflow BuildingBlockImplementationDiscriminator = "GITHUB_WORKFLOW" + BuildingBlockImplementationDiscriminatorGitlabCicd BuildingBlockImplementationDiscriminator = "GITLAB_CICD" + BuildingBlockImplementationDiscriminatorAzureDevops BuildingBlockImplementationDiscriminator = "AZURE_DEVOPS" +) + +var BuildingBlockImplementationDiscriminators = enum.Of[BuildingBlockImplementationDiscriminator]("MANUAL", "TERRAFORM", "GITHUB_WORKFLOW", "GITLAB_CICD", "AZURE_DEVOPS") + +type BuildingBlockRunStepStatus string + +const ( + BuildingBlockRunStepStatusPending BuildingBlockRunStepStatus = "PENDING" + BuildingBlockRunStepStatusInProgress BuildingBlockRunStepStatus = "IN_PROGRESS" + BuildingBlockRunStepStatusSucceeded BuildingBlockRunStepStatus = "SUCCEEDED" + BuildingBlockRunStepStatusFailed BuildingBlockRunStepStatus = "FAILED" + BuildingBlockRunStepStatusAborted BuildingBlockRunStepStatus = "ABORTED" + BuildingBlockRunStepStatusWarning BuildingBlockRunStepStatus = "WARNING" +) + +var BuildingBlockRunStepStatuses = enum.Of[BuildingBlockRunStepStatus]("PENDING", "IN_PROGRESS", "SUCCEEDED", "FAILED", "ABORTED", "WARNING") + +type KnownHostEntry struct { + Host string `json:"host"` + KeyType string `json:"keyType"` + KeyValue string `json:"keyValue"` +} + +type LogsDTO struct { + // List of steps in the building block run. + Steps []StepLogDTO `json:"steps"` +} + +type MeshBuildingBlockAzureDevOpsImplementation struct { + AzureDevOpsBaseUrl string `json:"azureDevOpsBaseUrl"` + Organization string `json:"organization"` + Project string `json:"project"` + PipelineId string `json:"pipelineId"` + PersonalAccessToken string `json:"personalAccessToken"` + Async bool `json:"async"` + RefName *string `json:"refName,omitzero"` +} + +type MeshBuildingBlockGithubImplementation struct { + GithubBaseUrl string `json:"githubBaseUrl"` + Owner string `json:"owner"` + AppId string `json:"appId"` + AppPem string `json:"appPem"` + Repository string `json:"repository"` + Branch string `json:"branch"` + ApplyWorkflow string `json:"applyWorkflow"` + DestroyWorkflow *string `json:"destroyWorkflow,omitzero"` + Async bool `json:"async"` + // When true, the GitHub workflow receives only the `buildingBlockRunUrl` input and no full building block run JSON. + // This avoids GitHub's limit of 65,535 characters for all `workflow_dispatch` inputs. + OmitRunObjectInput bool `json:"omitRunObjectInput"` +} + +type MeshBuildingBlockGitlabImplementation struct { + GitlabBaseUrl string `json:"gitlabBaseUrl"` + ProjectId string `json:"projectId"` + RefName string `json:"refName"` + PipelineTriggerToken string `json:"pipelineTriggerToken"` +} + +type MeshBuildingBlockIOType string + +const ( + MeshBuildingBlockIOTypeString MeshBuildingBlockIOType = "STRING" + MeshBuildingBlockIOTypeCode MeshBuildingBlockIOType = "CODE" + MeshBuildingBlockIOTypeInteger MeshBuildingBlockIOType = "INTEGER" + MeshBuildingBlockIOTypeBoolean MeshBuildingBlockIOType = "BOOLEAN" + MeshBuildingBlockIOTypeFile MeshBuildingBlockIOType = "FILE" + MeshBuildingBlockIOTypeList MeshBuildingBlockIOType = "LIST" + MeshBuildingBlockIOTypeSingleSelect MeshBuildingBlockIOType = "SINGLE_SELECT" + MeshBuildingBlockIOTypeMultiSelect MeshBuildingBlockIOType = "MULTI_SELECT" +) + +var MeshBuildingBlockIOTypes = enum.Of[MeshBuildingBlockIOType]("STRING", "CODE", "INTEGER", "BOOLEAN", "FILE", "LIST", "SINGLE_SELECT", "MULTI_SELECT") + +type MeshBuildingBlockInputsForRun struct { + // The name of the input variable. + Key string `json:"key"` + // The value of this variable. + Value any `json:"value"` + // The type of the variable. One of STRING, CODE, INTEGER, BOOLEAN, FILE, LIST, SINGLE_SELECT, MULTI_SELECT. + Type MeshBuildingBlockIOType `json:"type"` + // Flag if the variable content is sensitive and encrypted. The runner must decrypt this variable content first + // before it can be used. + IsSensitive bool `json:"isSensitive"` + // Flag if this variable should be provided as environment variable instead of getting handled as a 'native' + // variable for the chosen runner technology (for example a Terraform input). + IsEnvironment bool `json:"isEnvironment"` +} + +type MeshBuildingBlockMinimalDefinitionSpec struct { + // The identifier of the workspace that owns the building block definition. + WorkspaceIdentifier string `json:"workspaceIdentifier"` + // Version of the building block definition. + Version int64 `json:"version"` + // Contains information about the used implementation e.g. Terraform specific details of the building block. + Implementation BuildingBlockImplementation `json:"implementation"` +} + +type MeshBuildingBlockSpecForRun struct { + // Display name of the building block. + DisplayName string `json:"displayName"` + // The reference to either the tenant or workspace the building block is located on. Contains a `kind` + // (`meshTenant` or `meshWorkspace`). For `meshTenant`, the tenant is identified by its `uuid`; for + // `meshWorkspace`, the workspace is identified by its `name` field, which holds the workspace identifier. + TargetRef meshobject.MeshBuildingBlockTargetRefResponse `json:"targetRef"` + // The identifier of the workspace of the building block. + WorkspaceIdentifier string `json:"workspaceIdentifier"` + // The identifier of the project of the building block. + ProjectIdentifier *string `json:"projectIdentifier,omitzero"` + // The full identifier of the platform of the building block. + FullPlatformIdentifier *string `json:"fullPlatformIdentifier,omitzero"` + // Contains the filled in input objects for this building block run. + Inputs []MeshBuildingBlockInputsForRun `json:"inputs"` + // A list of parent building blocks UUIDs for this building block. + ParentBuildingBlocks []MeshParentBuildingBlock `json:"parentBuildingBlocks"` +} + +type MeshBuildingBlockTerraformImplementation struct { + TerraformVersion string `json:"terraformVersion"` + RepositoryUrl string `json:"repositoryUrl"` + RepositoryPath *string `json:"repositoryPath,omitzero"` + // The Git ref to check out. When null, the latest one is used. + RefName *string `json:"refName,omitzero"` + // Null for an HTTPS repository URL. + SshPrivateKey *string `json:"sshPrivateKey,omitzero"` + KnownHost *KnownHostEntry `json:"knownHost,omitzero"` + Async bool `json:"async"` + UseMeshHttpBackendFallback bool `json:"useMeshHttpBackendFallback"` + PreRunScript *string `json:"preRunScript,omitzero"` +} + +type MeshManualBuildingBlockImplementation struct { +} + +type MeshParentBuildingBlock struct { + // The UUID of the parent Building Block. + BuildingBlockUuid string `json:"buildingBlockUuid"` + // The UUID of the Building Block Definition of the parent Building Block. + DefinitionUuid string `json:"definitionUuid"` +} + +type Metadata struct { + // The UUID of this building block run. + Uuid string `json:"uuid"` + // When meshStack recorded this run, as an ISO-8601 instant (e.g. `2020-12-22T09:37:43Z`). A run is recorded when + // it enters the queue, which is not necessarily when a runner picked it up: a run that waits for an input, a + // parent Building Block or an approval is recorded long before it executes. The list endpoint sorts on this + // timestamp under the same key. + CreatedAt time.Time `json:"createdAt"` +} + +type Response struct { + Metadata Metadata `json:"metadata"` + // All fields in this section describe the meshBuildingBlockRun. + Spec Spec `json:"spec"` + // The current execution status of this building block run. + Status RunStatus `json:"status"` + Links meshobject.Links `json:"_links,omitzero"` +} + +type RunStatus string + +const ( + RunStatusInProgress RunStatus = "IN_PROGRESS" + RunStatusSucceeded RunStatus = "SUCCEEDED" + RunStatusFailed RunStatus = "FAILED" +) + +var RunStatuses = enum.Of[RunStatus]("IN_PROGRESS", "SUCCEEDED", "FAILED") + +type RunTrigger struct { + // The reason this run was triggered. One of BUILDING_BLOCK_CREATION, MANUAL_EXECUTION, USER_STATUS_CHANGE, + // GROUP_MEMBERSHIP_CHANGE, PLATFORM_TENANT_ID_UPDATE, PROJECT_PERMISSIONS_CHANGE, WORKSPACE_PERMISSION_CHANGE, + // BUILDING_BLOCK_UPGRADE, BUILDING_BLOCK_INPUTS_UPDATE, BUILDING_BLOCK_DEFINITION_DELETION, + // BUILDING_BLOCK_DELETION, BUILDING_BLOCK_PURGE, DEPENDENCY_OUTPUT_CHANGED, PARENT_RUN_SUCCEEDED, + // PREFLIGHT_SUCCEEDED, SCHEDULED_DRIFT_RECONCILIATION, SCHEDULED_DRIFT_DETECTION, TAG_VALUE_CHANGE, + // PROJECT_ROLE_EXPIRY, WORKSPACE_ROLE_EXPIRY. + Reason TriggerReason `json:"reason"` + Author meshobject.MeshObjectAuthor `json:"author"` +} + +type Spec struct { + // Every run has a monotonically increasing number. + RunNumber int64 `json:"runNumber"` + BuildingBlock BuildingBlock `json:"buildingBlock"` + BuildingBlockDefinition BuildingBlockDefinition `json:"buildingBlockDefinition"` + // The behavior the runner should execute. One of APPLY, DETECT, DESTROY. + Behavior Behavior `json:"behavior"` + // JWT token that the runner uses to authenticate when calling back to meshStack during this run. It acts with the + // managed rights of the workspace that owns the building block definition: it can update the run status, steps and + // sources, list runs, read and write the run artifact and read, write and delete the Terraform state. It is only + // returned to the runner when it checks out the run, and to readers that manage the run (the workspace owning the + // building block definition or its runner, or an admin). All other readers get the placeholder + // `` instead. + RunToken string `json:"runToken"` + // Information about what triggered this run. Omitted for runs created before trigger tracking was introduced. + Trigger *RunTrigger `json:"trigger,omitzero"` +} + +type StepLogDTO struct { + // The display name of the step. + DisplayName string `json:"displayName"` + // The execution status of the step. One of PENDING, IN_PROGRESS, SUCCEEDED, FAILED, ABORTED, WARNING. + Status BuildingBlockRunStepStatus `json:"status"` + // A user-facing message for the step. Always included regardless of caller permissions. + UserMessage *string `json:"userMessage,omitzero"` + // A system/operator-facing message for the step. Included for admin or platform operator (managed) API keys, for + // workspace-scoped callers when runTransparency is enabled, and for a composition's ephemeral run key over runs + // whose definition its own workspace owns. Null otherwise. + SystemMessage *string `json:"systemMessage,omitzero"` +} + +type TriggerReason string + +const ( + TriggerReasonBuildingBlockCreation TriggerReason = "BUILDING_BLOCK_CREATION" + TriggerReasonManualExecution TriggerReason = "MANUAL_EXECUTION" + TriggerReasonUserStatusChange TriggerReason = "USER_STATUS_CHANGE" + TriggerReasonGroupMembershipChange TriggerReason = "GROUP_MEMBERSHIP_CHANGE" + TriggerReasonPlatformTenantIdUpdate TriggerReason = "PLATFORM_TENANT_ID_UPDATE" + TriggerReasonProjectPermissionsChange TriggerReason = "PROJECT_PERMISSIONS_CHANGE" + TriggerReasonWorkspacePermissionChange TriggerReason = "WORKSPACE_PERMISSION_CHANGE" + TriggerReasonBuildingBlockUpgrade TriggerReason = "BUILDING_BLOCK_UPGRADE" + TriggerReasonBuildingBlockInputsUpdate TriggerReason = "BUILDING_BLOCK_INPUTS_UPDATE" + TriggerReasonBuildingBlockDefinitionDeletion TriggerReason = "BUILDING_BLOCK_DEFINITION_DELETION" + TriggerReasonBuildingBlockDeletion TriggerReason = "BUILDING_BLOCK_DELETION" + TriggerReasonBuildingBlockPurge TriggerReason = "BUILDING_BLOCK_PURGE" + TriggerReasonDependencyOutputChanged TriggerReason = "DEPENDENCY_OUTPUT_CHANGED" + TriggerReasonParentRunSucceeded TriggerReason = "PARENT_RUN_SUCCEEDED" + TriggerReasonPreflightSucceeded TriggerReason = "PREFLIGHT_SUCCEEDED" + TriggerReasonScheduledDriftReconciliation TriggerReason = "SCHEDULED_DRIFT_RECONCILIATION" + TriggerReasonScheduledDriftDetection TriggerReason = "SCHEDULED_DRIFT_DETECTION" + TriggerReasonTagValueChange TriggerReason = "TAG_VALUE_CHANGE" + TriggerReasonProjectRoleExpiry TriggerReason = "PROJECT_ROLE_EXPIRY" + TriggerReasonWorkspaceRoleExpiry TriggerReason = "WORKSPACE_ROLE_EXPIRY" +) + +var TriggerReasons = enum.Of[TriggerReason]("BUILDING_BLOCK_CREATION", "MANUAL_EXECUTION", "USER_STATUS_CHANGE", "GROUP_MEMBERSHIP_CHANGE", "PLATFORM_TENANT_ID_UPDATE", "PROJECT_PERMISSIONS_CHANGE", "WORKSPACE_PERMISSION_CHANGE", "BUILDING_BLOCK_UPGRADE", "BUILDING_BLOCK_INPUTS_UPDATE", "BUILDING_BLOCK_DEFINITION_DELETION", "BUILDING_BLOCK_DELETION", "BUILDING_BLOCK_PURGE", "DEPENDENCY_OUTPUT_CHANGED", "PARENT_RUN_SUCCEEDED", "PREFLIGHT_SUCCEEDED", "SCHEDULED_DRIFT_RECONCILIATION", "SCHEDULED_DRIFT_DETECTION", "TAG_VALUE_CHANGE", "PROJECT_ROLE_EXPIRY", "WORKSPACE_ROLE_EXPIRY") diff --git a/client/meshobject/generated.go b/client/meshobject/generated.go new file mode 100644 index 00000000..02998aa9 --- /dev/null +++ b/client/meshobject/generated.go @@ -0,0 +1,116 @@ +// Code generated by github.com/meshcloud/meshstack-cli/client/openapi/generator. DO NOT EDIT. + +package meshobject + +import ( + "encoding/json/jsontext" + "encoding/json/v2" + "fmt" + "uuid" + + "github.com/meshcloud/meshstack-cli/client/types/enum" +) + +type MeshBuildingBlockTargetRefDiscriminator string + +const ( + MeshBuildingBlockTargetRefDiscriminatorMeshTenant MeshBuildingBlockTargetRefDiscriminator = "meshTenant" + MeshBuildingBlockTargetRefDiscriminatorMeshWorkspace MeshBuildingBlockTargetRefDiscriminator = "meshWorkspace" +) + +var MeshBuildingBlockTargetRefDiscriminators = enum.Of[MeshBuildingBlockTargetRefDiscriminator]("meshTenant", "meshWorkspace") + +type MeshBuildingBlockTargetRefResponse struct { + Kind MeshBuildingBlockTargetRefDiscriminator + MeshTenant *MeshBuildingBlockTargetTenantRefResponse + MeshWorkspace *MeshBuildingBlockTargetWorkspaceRefResponse +} + +func (u MeshBuildingBlockTargetRefResponse) MarshalJSONTo(enc *jsontext.Encoder) error { + switch u.Kind { + case "meshTenant": + if u.MeshTenant != nil { + return json.MarshalEncode(enc, struct { + Discriminator MeshBuildingBlockTargetRefDiscriminator `json:"kind"` + *MeshBuildingBlockTargetTenantRefResponse `json:",embed"` + }{u.Kind, u.MeshTenant}) + } + case "meshWorkspace": + if u.MeshWorkspace != nil { + return json.MarshalEncode(enc, struct { + Discriminator MeshBuildingBlockTargetRefDiscriminator `json:"kind"` + *MeshBuildingBlockTargetWorkspaceRefResponse `json:",embed"` + }{u.Kind, u.MeshWorkspace}) + } + } + return fmt.Errorf("MeshBuildingBlockTargetRefResponse has no value for kind %q", u.Kind) +} + +// UnmarshalJSONFrom leaves every variant nil for a kind this client does not know yet. +func (u *MeshBuildingBlockTargetRefResponse) UnmarshalJSONFrom(dec *jsontext.Decoder) error { + raw, err := dec.ReadValue() + if err != nil { + return err + } + var discriminator struct { + Value MeshBuildingBlockTargetRefDiscriminator `json:"kind"` + } + if err := json.Unmarshal(raw, &discriminator); err != nil { + return err + } + *u = MeshBuildingBlockTargetRefResponse{Kind: discriminator.Value} + switch discriminator.Value { + case "meshTenant": + u.MeshTenant = new(MeshBuildingBlockTargetTenantRefResponse) + return json.Unmarshal(raw, u.MeshTenant) + case "meshWorkspace": + u.MeshWorkspace = new(MeshBuildingBlockTargetWorkspaceRefResponse) + return json.Unmarshal(raw, u.MeshWorkspace) + } + return nil +} + +type MeshBuildingBlockTargetTenantRefResponse struct { + // The uuid of the tenant for this Building Block. Only for kind "meshTenant"; a workspace has no uuid, use `name`. + Uuid uuid.UUID `json:"uuid"` + // The identifier of the workspace for this Building Block. Only for kind "meshWorkspace", where it replaces `uuid`. + Name *string `json:"name,omitzero"` +} + +type MeshBuildingBlockTargetWorkspaceRefResponse struct { + Name string `json:"name"` + Uuid *uuid.UUID `json:"uuid,omitzero"` +} + +type MeshObjectAuthor struct { + // The identifier of the author. Depending on the `type`, this is: + // * User: The UUID of the user, which matches the `metadata.uuid` of the meshUser in the meshObject API. + // * ApiKey: The UUID of the API key, which matches the `metadata.uuid` of the meshApiKey in the meshObject API. + // * ApiUser: The username of the API user. + // * System: Always `system`. + Identifier string `json:"identifier"` + // The type of the author. One of ApiKey, ApiUser, System, User. + Type MeshObjectAuthorType `json:"type"` + // The human-readable display name of the author, if available. + DisplayName *string `json:"displayName,omitzero"` + // The username of the author. Set for `User` authors; absent otherwise. + Username *string `json:"username,omitzero"` + // The email address of the author. Set for `User` authors; absent otherwise. + Email *string `json:"email,omitzero"` + // The external identifier of the author from the identity provider, used to identify the user in downstream cloud + // platforms. Set for `User` authors that have one; absent otherwise. + Euid *string `json:"euid,omitzero"` + // Deprecated: superseded by `type`, which carries the same value. Retained for backward compatibility. + AuthorType *MeshObjectAuthorType `json:"authorType,omitzero"` +} + +type MeshObjectAuthorType string + +const ( + MeshObjectAuthorTypeUser MeshObjectAuthorType = "User" + MeshObjectAuthorTypeApiKey MeshObjectAuthorType = "ApiKey" + MeshObjectAuthorTypeApiUser MeshObjectAuthorType = "ApiUser" + MeshObjectAuthorTypeSystem MeshObjectAuthorType = "System" +) + +var MeshObjectAuthorTypes = enum.Of[MeshObjectAuthorType]("User", "ApiKey", "ApiUser", "System") diff --git a/client/meshworkspace/v2/generated.go b/client/meshworkspace/v2/generated.go new file mode 100644 index 00000000..be6db238 --- /dev/null +++ b/client/meshworkspace/v2/generated.go @@ -0,0 +1,125 @@ +// Code generated by github.com/meshcloud/meshstack-cli/client/openapi/generator. DO NOT EDIT. + +// Package meshworkspace is the meshObject kind meshWorkspace in API version v2. +package meshworkspace + +import ( + "context" + "iter" + "time" + + "github.com/meshcloud/meshstack-cli/client/internal" + "github.com/meshcloud/meshstack-cli/client/meshobject" +) + +const ( + Kind = "meshWorkspace" + ApiVersion = "v2" + MediaType = "application/vnd.meshcloud.api.meshworkspace.v2.hal+json" +) + +type Client struct { + api internal.Api +} + +func NewClient(httpClient internal.HttpClient) Client { + return Client{internal.Api{ + HttpClient: httpClient, + Kind: Kind, + ApiVersion: ApiVersion, + MediaType: MediaType, + Path: "/api/meshobjects/meshworkspaces", + EmbeddedRel: "meshWorkspaces", + }} +} + +// Api lets the raw client of the CLI reach the same API version. +func (c Client) Api() internal.Api { + return c.api +} + +// Create workspace +func (c Client) Create(ctx context.Context, body Request) (Response, error) { + return c.api.Do[Response](ctx, internal.Call{Method: "POST", Path: "/api/meshobjects/meshworkspaces", SendsMeshObject: true, AnswersMeshObject: true}, body) +} + +// Delete workspace +func (c Client) Delete(ctx context.Context, workspace string) error { + _, err := c.api.Do[any](ctx, internal.Call{Method: "DELETE", Path: internal.Path("/api/meshobjects/meshworkspaces/{workspace}", workspace)}, nil) + return err +} + +// Get workspace +func (c Client) Get(ctx context.Context, workspace string) (Response, error) { + return c.api.Do[Response](ctx, internal.Call{Method: "GET", Path: internal.Path("/api/meshobjects/meshworkspaces/{workspace}", workspace), AnswersMeshObject: true}, nil) +} + +// List workspaces +func (c Client) List(ctx context.Context, filter ListFilter) iter.Seq2[Response, error] { + return c.api.List[Response](ctx, internal.Call{Method: "GET", Path: "/api/meshobjects/meshworkspaces", Query: filter, AnswersMeshObject: true}) +} + +// Create or update workspace +func (c Client) Update(ctx context.Context, workspace string, body Request) (Response, error) { + return c.api.Do[Response](ctx, internal.Call{Method: "PUT", Path: internal.Path("/api/meshobjects/meshworkspaces/{workspace}", workspace), SendsMeshObject: true, AnswersMeshObject: true}, body) +} + +type ListFilter struct { + Sort []string `json:"sort"` +} + +type MetadataRequest struct { + // The workspaceIdentifier as `name` to uniquely identify the meshWorkspace (read-only after creation). + Name string `json:"name"` + // Key/Value pairs of tags set on the meshWorkspace. Keep in mind that values are an array. You must always provide + // all the tags of the workspace inside this object. If you provide an empty object, the tags will be removed from + // your workspace. + Tags map[string][]string `json:"tags"` +} + +type MetadataResponse struct { + // The workspaceIdentifier as `name` to uniquely identify the meshWorkspace (read-only after creation). + Name string `json:"name"` + // Key/Value pairs of tags set on the meshWorkspace. Keep in mind that values are an array. You must always provide + // all the tags of the workspace inside this object. If you provide an empty object, the tags will be removed from + // your workspace. + Tags map[string][]string `json:"tags"` + // The meshWorkspace has been created at this date (e.g. `2020-12-22T09:37:43Z`). + CreatedOn time.Time `json:"createdOn"` + // If the meshWorkspace has already been deleted, the date when deletion happened is provided via this field (e.g. + // `2020-12-22T09:37:43Z`). + DeletedOn *time.Time `json:"deletedOn,omitzero"` +} + +type Request struct { + // Always contains the `name` to uniquely identify the meshWorkspace. Can additionally contain meta information like + // the meshWorkspace creation date. + Metadata MetadataRequest `json:"metadata"` + // All fields in this section describe the meshWorkspace. + Spec SpecRequest `json:"spec"` +} + +type Response struct { + // Always contains the `name` to uniquely identify the meshWorkspace. Can additionally contain meta information like + // the meshWorkspace creation date. + Metadata MetadataResponse `json:"metadata"` + // All fields in this section describe the meshWorkspace. + Spec SpecResponse `json:"spec"` + Links meshobject.Links `json:"_links,omitzero"` +} + +type SpecRequest struct { + // The display name of the meshWorkspace as it is shown in meshPanel. + DisplayName string `json:"displayName"` + // When enabled, you can open the platform builder at the top right of your screen when you visit the workspace in + // the panel. + PlatformBuilderAccessEnabled *bool `json:"platformBuilderAccessEnabled,omitzero"` +} + +type SpecResponse struct { + // The display name of the meshWorkspace as it is shown in meshPanel. + DisplayName string `json:"displayName"` + // When enabled, you can open the platform builder at the top right of your screen when you visit the workspace in + // the panel. + PlatformBuilderAccessEnabled bool `json:"platformBuilderAccessEnabled"` +} From 3688f724a1f1a50a7d1b42069f7432716beb568c Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Fri, 16 Oct 2026 14:01:15 +0200 Subject: [PATCH 08/18] feat(client): add the generated clients to client.Client and the raw client Co-Authored-By: Claude Opus 5.5 --- client/client.go | 19 ++++++++++++++++++- client/raw.go | 6 ++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/client/client.go b/client/client.go index ea7e0983..9f2e5e5e 100644 --- a/client/client.go +++ b/client/client.go @@ -4,6 +4,9 @@ import ( "context" "github.com/meshcloud/meshstack-cli/client/internal" + "github.com/meshcloud/meshstack-cli/client/meshbuildingblock/v2" + "github.com/meshcloud/meshstack-cli/client/meshbuildingblockrun/v1" + "github.com/meshcloud/meshstack-cli/client/meshworkspace/v2" "github.com/meshcloud/meshstack-cli/client/types/xurl" "github.com/meshcloud/meshstack-cli/internal/http" "github.com/meshcloud/meshstack-cli/internal/version" @@ -46,6 +49,11 @@ type Client struct { WorkspaceUserBinding MeshWorkspaceUserBindingClient Raw *RawClient + // The generated clients, one per kind in one API version. + MeshWorkspace meshworkspace.Client + MeshBuildingBlock meshbuildingblock.Client + MeshBuildingBlockRun meshbuildingblockrun.Client + // Endpoint is read by the Terraform provider's meshstack_instance data source. Endpoint xurl.URL } @@ -60,6 +68,9 @@ func New(ctx context.Context, endpoint xurl.URL, client http.Client, auth Author buildingBlockDefinitionVersion := newBuildingBlockDefinitionVersionClient(ctx, authorizedClient) buildingBlockRun := newBuildingBlockRunClient(ctx, authorizedClient) workspace := newWorkspaceClient(ctx, authorizedClient) + generatedWorkspace := meshworkspace.NewClient(authorizedClient) + generatedBuildingBlock := meshbuildingblock.NewClient(authorizedClient) + generatedBuildingBlockRun := meshbuildingblockrun.NewClient(authorizedClient) return Client{ ApiKey: newApiKeyClient(ctx, authorizedClient), BuildingBlock: newBuildingBlockClient(ctx, authorizedClient), @@ -91,7 +102,13 @@ func New(ctx context.Context, endpoint xurl.URL, client http.Client, auth Author with(buildingBlockRun.meshObject). with(buildingBlockDefinition.meshObject). withOwnOrder(buildingBlockDefinitionVersion.meshObject). - with(newEventLogMeshObject(ctx, authorizedClient)), + with(newEventLogMeshObject(ctx, authorizedClient)). + withApi[meshworkspace.Response](generatedWorkspace.Api()). + withApi[meshbuildingblock.Response](generatedBuildingBlock.Api()). + withApi[meshbuildingblockrun.Response](generatedBuildingBlockRun.Api()), + MeshWorkspace: generatedWorkspace, + MeshBuildingBlock: generatedBuildingBlock, + MeshBuildingBlockRun: generatedBuildingBlockRun, Endpoint: endpoint, } diff --git a/client/raw.go b/client/raw.go index ddc2e65f..a485634b 100644 --- a/client/raw.go +++ b/client/raw.go @@ -84,6 +84,12 @@ func (r *RawClient) with[M any](meshObject internal.MeshObjectClient[M]) *RawCli return r } +// withApi is with for a generated client. +func (r *RawClient) withApi[M any](api internal.Api) *RawClient { + r.apis[reflect.TypeFor[M]()] = rawApi{MeshObjectApi: api.MeshObjectApi(), sortsByCreatedAt: true} + return r +} + // withOwnOrder is with for a listing that answers 400 to any sort key, as that of building block // definition versions does, which orders by version number. func (r *RawClient) withOwnOrder[M any](meshObject internal.MeshObjectClient[M]) *RawClient { From 312a2a0c455262c7b24605a6cf353565207eb31e Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Fri, 16 Oct 2026 14:03:26 +0200 Subject: [PATCH 09/18] refactor: read workspaces, building blocks and runs through the generated packages A run lock now counts as unfinished only while the run is IN_PROGRESS: the API docs list IN_PROGRESS, SUCCEEDED and FAILED for a run, and no PENDING. Co-Authored-By: Claude Opus 5.5 --- cmd/buildingblock/approve_run.go | 6 ++- cmd/buildingblock/list.go | 15 +++--- cmd/buildingblock/trigger_run.go | 24 ++++----- .../trigger_run_internal_test.go | 6 +-- cmd/buildingblock/trigger_run_test.go | 2 +- cmd/buildingblockrun/list.go | 25 +++++---- cmd/buildingblockrun/logs.go | 54 +++++++------------ cmd/buildingblockrun/logs_test.go | 37 ++++++------- cmd/workspace/list.go | 4 +- internal/tfstate/buildingblock.go | 12 +++-- internal/tfstate/lock.go | 9 ++-- 11 files changed, 96 insertions(+), 98 deletions(-) diff --git a/cmd/buildingblock/approve_run.go b/cmd/buildingblock/approve_run.go index 88599341..cebf7fdb 100644 --- a/cmd/buildingblock/approve_run.go +++ b/cmd/buildingblock/approve_run.go @@ -77,7 +77,11 @@ func (a runAction) approve(ctx context.Context) error { if plan.Links.DownloadLogs == nil { return fmt.Errorf("meshStack shows you no logs of run %s, which planned run %s", plan.Metadata.Uuid, run.Metadata.Uuid) } - logs, err := a.meshStack.BuildingBlockRunAction.ReadLogs(ctx, *plan.Links.DownloadLogs) + planUuid, err := uuid.Parse(plan.Metadata.Uuid) + if err != nil { + return err + } + logs, err := a.meshStack.MeshBuildingBlockRun.Logs(ctx, planUuid) if err != nil { return err } diff --git a/cmd/buildingblock/list.go b/cmd/buildingblock/list.go index 7650d214..c4f6d88e 100644 --- a/cmd/buildingblock/list.go +++ b/cmd/buildingblock/list.go @@ -3,7 +3,7 @@ package buildingblock import ( "github.com/spf13/cobra" - "github.com/meshcloud/meshstack-cli/client" + "github.com/meshcloud/meshstack-cli/client/meshbuildingblock/v2" "github.com/meshcloud/meshstack-cli/cmd/internal" ) @@ -19,14 +19,15 @@ lists every building block the credential can see.`, meshstack bb list --limit unlimited -o ndjson`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { - var ( - filter client.MeshBuildingBlockV2ListFilter - err error - ) - if filter.WorkspaceIdentifier, err = internal.ListWorkspace(cmd.Context()); err != nil { + workspace, err := internal.ListWorkspace(cmd.Context()) + if err != nil { return err } - return flags.Run[client.MeshBuildingBlockV2](cmd, filter) + var filter meshbuildingblock.ListFilter + if workspace != nil { + filter.WorkspaceIdentifier = *workspace + } + return flags.Run[meshbuildingblock.Response](cmd, filter) }, } diff --git a/cmd/buildingblock/trigger_run.go b/cmd/buildingblock/trigger_run.go index 1c3529e9..3f8dfcb6 100644 --- a/cmd/buildingblock/trigger_run.go +++ b/cmd/buildingblock/trigger_run.go @@ -13,6 +13,7 @@ import ( "github.com/spf13/cobra" "github.com/meshcloud/meshstack-cli/client" + "github.com/meshcloud/meshstack-cli/client/meshbuildingblock/v2" "github.com/meshcloud/meshstack-cli/cmd/internal" ) @@ -48,7 +49,8 @@ status.latestRunUuid names the run. "meshstack buildingblockrun logs " if err != nil { return err } - accepted, err := meshStack.BuildingBlockV2.TriggerRun(ctx, buildingBlockUuid.String()) + // The endpoint needs dryRun in a body, though the API docs mark it optional. + accepted, err := meshStack.MeshBuildingBlock.TriggerRun(ctx, buildingBlockUuid, meshbuildingblock.TriggerRunRequest{DryRun: new(false)}) if err != nil { return err } @@ -83,23 +85,20 @@ type runStart struct { // awaitRunStart polls the building block until it reports something the trigger-run answer did not: // a new latest run, or a run start failure recorded anew. -func awaitRunStart(ctx context.Context, raw *client.RawClient, buildingBlockUuid uuid.UUID, accepted *client.MeshBuildingBlockV2Status, every, within time.Duration) (runStart, error) { - if accepted == nil { - accepted = &client.MeshBuildingBlockV2Status{} - } +func awaitRunStart(ctx context.Context, raw *client.RawClient, buildingBlockUuid uuid.UUID, accepted meshbuildingblock.StatusResponse, every, within time.Duration) (runStart, error) { ctx, cancel := context.WithTimeoutCause(ctx, within, fmt.Errorf( "meshStack accepted the run of building block %s, but had not started it %s later. `meshstack buildingblockrun list --building-block %s --limit 1` shows the run once it has", buildingBlockUuid, within, buildingBlockUuid)) defer cancel() for { - current, err := raw.Get[client.MeshBuildingBlockV2](ctx, buildingBlockUuid) + current, err := raw.Get[meshbuildingblock.Response](ctx, buildingBlockUuid) if err != nil && ctx.Err() != nil { return runStart{}, context.Cause(ctx) } else if err != nil { return runStart{}, err } var block struct { - Status *client.MeshBuildingBlockV2Status `json:"status"` + Status *meshbuildingblock.StatusResponse `json:"status"` } if err := json.Unmarshal(current, &block); err != nil { return runStart{}, err @@ -108,7 +107,7 @@ func awaitRunStart(ctx context.Context, raw *client.RawClient, buildingBlockUuid switch { case status.LatestRunUuid != nil && *status.LatestRunUuid != deref(accepted.LatestRunUuid): return runStart{raw: current, runUuid: *status.LatestRunUuid}, nil - case status.RunStartFailure != nil && deref(status.RunStartFailedOn) != deref(accepted.RunStartFailedOn): + case status.RunStartFailure != nil && !deref(status.RunStartFailedOn).Equal(deref(accepted.RunStartFailedOn)): return runStart{raw: current, failure: *status.RunStartFailure}, nil } } @@ -120,9 +119,10 @@ func awaitRunStart(ctx context.Context, raw *client.RawClient, buildingBlockUuid } } -func deref(s *string) string { - if s == nil { - return "" +func deref[T any](value *T) T { + var zero T + if value == nil { + return zero } - return *s + return *value } diff --git a/cmd/buildingblock/trigger_run_internal_test.go b/cmd/buildingblock/trigger_run_internal_test.go index 2295037e..33158c06 100644 --- a/cmd/buildingblock/trigger_run_internal_test.go +++ b/cmd/buildingblock/trigger_run_internal_test.go @@ -9,7 +9,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/meshcloud/meshstack-cli/client" + "github.com/meshcloud/meshstack-cli/client/meshbuildingblock/v2" "github.com/meshcloud/meshstack-cli/cmd/internal" "github.com/meshcloud/meshstack-cli/internal/testutil/fakemeshstack" "github.com/meshcloud/meshstack-cli/internal/testutil/testlogin" @@ -28,10 +28,10 @@ func TestAwaitRunStartTakesNothingTheTriggerRunAnswerReportedForTheStart(t *test meshStack, err := internal.ResolveClient(t.Context()) require.NoError(t, err) - _, err = awaitRunStart(t.Context(), meshStack.Raw, buildingBlockUuid, &client.MeshBuildingBlockV2Status{ + _, err = awaitRunStart(t.Context(), meshStack.Raw, buildingBlockUuid, meshbuildingblock.StatusResponse{ LatestRunUuid: new(previousRun), RunStartFailure: new("the previous request failed"), - RunStartFailedOn: new(failedOn), + RunStartFailedOn: new(time.Date(2026, 10, 1, 10, 0, 0, 0, time.UTC)), }, time.Millisecond, 50*time.Millisecond) require.Error(t, err) diff --git a/cmd/buildingblock/trigger_run_test.go b/cmd/buildingblock/trigger_run_test.go index 05c572ca..161519c5 100644 --- a/cmd/buildingblock/trigger_run_test.go +++ b/cmd/buildingblock/trigger_run_test.go @@ -24,7 +24,7 @@ func TestTriggerRunPrintsTheBuildingBlockAndFailsWithTheReasonMeshStackCouldNotS reason = "meshStack could not start a run for this Building Block.\n\nAsk your platform team to look up the details in the meshStack logs." ) failure := func(failedOn string) string { - return fmt.Sprintf(`{"metadata": {"uuid": %q, "ownedByWorkspace": "my-workspace"}, "spec": {}, `+ + return fmt.Sprintf(`{"kind": "meshBuildingBlock", "apiVersion": "v2-preview", "metadata": {"uuid": %q, "ownedByWorkspace": "my-workspace"}, "spec": {}, `+ `"status": {"status": "PENDING", "latestRunUuid": "a0000000-0000-4000-8000-000000000001", "runStartFailure": %q, "runStartFailedOn": %q}}`, buildingBlockUuid, reason, failedOn) } diff --git a/cmd/buildingblockrun/list.go b/cmd/buildingblockrun/list.go index cc4071cd..679273aa 100644 --- a/cmd/buildingblockrun/list.go +++ b/cmd/buildingblockrun/list.go @@ -14,6 +14,8 @@ import ( "github.com/spf13/cobra" "github.com/meshcloud/meshstack-cli/client" + "github.com/meshcloud/meshstack-cli/client/meshbuildingblock/v2" + "github.com/meshcloud/meshstack-cli/client/meshbuildingblockrun/v1" "github.com/meshcloud/meshstack-cli/cmd/internal" ) @@ -36,15 +38,16 @@ credential can see.`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { if buildingBlockUuid != uuid.Nil() { - return flags.Run[client.MeshBuildingBlockRun](cmd, client.MeshBuildingBlockRunListFilter{BuildingBlockUuid: buildingBlockUuid}) + return flags.Run[meshbuildingblockrun.Response](cmd, meshbuildingblockrun.ListFilter{BuildingBlockUuid: buildingBlockUuid.String()}) } - var ( - blockFilter client.MeshBuildingBlockV2ListFilter - err error - ) - if blockFilter.WorkspaceIdentifier, err = internal.ListWorkspace(cmd.Context()); err != nil { + workspace, err := internal.ListWorkspace(cmd.Context()) + if err != nil { return err } + var blockFilter meshbuildingblock.ListFilter + if workspace != nil { + blockFilter.WorkspaceIdentifier = *workspace + } return flags.RunSeq(cmd, func(ctx context.Context, meshStack client.Client, options client.ListOptions) iter.Seq2[jsontext.Value, error] { return allRuns(ctx, meshStack, blockFilter, options.PageSize) }) @@ -65,7 +68,7 @@ const runsPageSizeOfEachBlock = 10 // allRuns merges the runs of every building block, because the run list endpoint takes one building // block at a time and has no list of every run. -func allRuns(ctx context.Context, meshStack client.Client, blockFilter client.MeshBuildingBlockV2ListFilter, pageSize int) iter.Seq2[jsontext.Value, error] { +func allRuns(ctx context.Context, meshStack client.Client, blockFilter meshbuildingblock.ListFilter, pageSize int) iter.Seq2[jsontext.Value, error] { if pageSize > 0 { pageSize = min(pageSize, runsPageSizeOfEachBlock) } @@ -73,7 +76,7 @@ func allRuns(ctx context.Context, meshStack client.Client, blockFilter client.Me var runsOfEachBlock []iter.Seq2[jsontext.Value, error] // The blocks are read raw because only their uuid is needed, and a block the client cannot // fully decode still has runs to list. - for rawBlock, err := range meshStack.Raw.List[client.MeshBuildingBlockV2](ctx, blockFilter, client.ListOptions{}) { + for rawBlock, err := range meshStack.Raw.List[meshbuildingblock.Response](ctx, blockFilter, client.ListOptions{}) { if err != nil { yield(nil, err) return @@ -99,8 +102,8 @@ func allRuns(ctx context.Context, meshStack client.Client, blockFilter client.Me func runsOf(ctx context.Context, meshStack client.Client, buildingBlockUuid uuid.UUID, pageSize int) iter.Seq2[jsontext.Value, error] { return func(yield func(jsontext.Value, error) bool) { - filter := client.MeshBuildingBlockRunListFilter{BuildingBlockUuid: buildingBlockUuid} - for run, err := range meshStack.Raw.List[client.MeshBuildingBlockRun](ctx, filter, client.ListOptions{PageSize: pageSize}) { + filter := meshbuildingblockrun.ListFilter{BuildingBlockUuid: buildingBlockUuid.String()} + for run, err := range meshStack.Raw.List[meshbuildingblockrun.Response](ctx, filter, client.ListOptions{PageSize: pageSize}) { if err != nil { err = fmt.Errorf("listing the runs of building block %s: %w", buildingBlockUuid, err) } @@ -166,7 +169,7 @@ func (h *runHead) advance() (bool, error) { return false, err } var created struct { - Metadata client.MeshBuildingBlockRunMetadata `json:"metadata"` + Metadata meshbuildingblockrun.Metadata `json:"metadata"` } if err := json.Unmarshal(run, &created); err != nil { return false, fmt.Errorf("reading the metadata.createdAt of a building block run: %w", err) diff --git a/cmd/buildingblockrun/logs.go b/cmd/buildingblockrun/logs.go index bd788e6d..4aa98e19 100644 --- a/cmd/buildingblockrun/logs.go +++ b/cmd/buildingblockrun/logs.go @@ -2,7 +2,6 @@ package buildingblockrun import ( "context" - "encoding/json/v2" "errors" "fmt" "io" @@ -14,6 +13,7 @@ import ( "github.com/spf13/cobra" "github.com/meshcloud/meshstack-cli/client" + "github.com/meshcloud/meshstack-cli/client/meshbuildingblockrun/v1" "github.com/meshcloud/meshstack-cli/cmd/internal" ) @@ -49,7 +49,7 @@ the run succeeded and 1 otherwise, so a script can wait on a run with it.`, return readRun(ctx, meshStack, runUuid) }) } - logs, err := meshStack.Raw.Get[client.MeshBuildingBlockRun](ctx, runUuid, "logs") + logs, err := meshStack.Raw.Get[meshbuildingblockrun.Response](ctx, runUuid, "logs") if err != nil { return err } @@ -64,17 +64,6 @@ the run succeeded and 1 otherwise, so a script can wait on a run with it.`, return cmd } -// meshStack reports an aborted run as FAILED, so only the status of its steps can tell that it was -// aborted. MeshBuildingBlockRunRepresentationModelAssembler of ../meshfed-release (meshcloud-internal) -// maps the states. -const ( - runInProgress = "IN_PROGRESS" - runSucceeded = "SUCCEEDED" - runFailed = "FAILED" - stepFailed = "FAILED" - stepAborted = "ABORTED" -) - const ( pollInterval = 2 * time.Second maxPollWait = time.Minute @@ -83,24 +72,18 @@ const ( ) type runSnapshot struct { - status string - steps []client.MeshBuildingBlockRunStepLog + status meshbuildingblockrun.RunStatus + steps []meshbuildingblockrun.StepLogDTO } // readRun reads the run's status before its logs, so that the logs of a finished run are its // last ones. func readRun(ctx context.Context, meshStack client.Client, runUuid uuid.UUID) (runSnapshot, error) { - rawRun, err := meshStack.Raw.Get[client.MeshBuildingBlockRun](ctx, runUuid) + run, err := meshStack.MeshBuildingBlockRun.Get(ctx, runUuid) if err != nil { return runSnapshot{}, err } - var run struct { - Status string `json:"status"` - } - if err = json.Unmarshal(rawRun, &run); err != nil { - return runSnapshot{}, err - } - logs, err := meshStack.BuildingBlockRun.GetLogs(ctx, runUuid.String()) + logs, err := meshStack.MeshBuildingBlockRun.Logs(ctx, runUuid) if err != nil { return runSnapshot{}, err } @@ -142,10 +125,10 @@ func followRun(ctx context.Context, w io.Writer, runUuid uuid.UUID, read func(co return err } switch run.status { - case runInProgress: - case runSucceeded: + case meshbuildingblockrun.RunStatusInProgress: + case meshbuildingblockrun.RunStatusSucceeded: return nil - case runFailed: + case meshbuildingblockrun.RunStatusFailed: return run.failure(runUuid) default: return fmt.Errorf("building block run %s has the status %q, which the meshStack CLI does not know, so it stops following the run", runUuid, run.status) @@ -164,16 +147,17 @@ func isClientError(err error) bool { return ok && httpErr.IsClientError() } +// meshStack reports an aborted run as FAILED, so only the status of its steps can tell that it was +// aborted. MeshBuildingBlockRunRepresentationModelAssembler of ../meshfed-release (meshcloud-internal) +// maps the states. func (run runSnapshot) failure(runUuid uuid.UUID) error { var abortedStep string for _, step := range run.steps { - switch step.Status { - case stepFailed: + if step.Status == meshbuildingblockrun.BuildingBlockRunStepStatusFailed { return fmt.Errorf("building block run %s failed at step %q", runUuid, step.DisplayName) - case stepAborted: - if abortedStep == "" { - abortedStep = step.DisplayName - } + } + if step.Status == meshbuildingblockrun.BuildingBlockRunStepStatusAborted && abortedStep == "" { + abortedStep = step.DisplayName } } if abortedStep != "" { @@ -182,7 +166,7 @@ func (run runSnapshot) failure(runUuid uuid.UUID) error { return fmt.Errorf("building block run %s failed", runUuid) } -func WriteLogs(w io.Writer, logs client.MeshBuildingBlockRunLogs) error { +func WriteLogs(w io.Writer, logs meshbuildingblockrun.LogsDTO) error { var written writtenLogs return written.writeNew(w, logs.Steps) } @@ -193,13 +177,13 @@ type writtenLogs []writtenStep type writtenStep struct { displayName string - status string + status meshbuildingblockrun.BuildingBlockRunStepStatus statusWritten bool userLines messageLines systemLines messageLines } -func (written *writtenLogs) writeNew(w io.Writer, steps []client.MeshBuildingBlockRunStepLog) error { +func (written *writtenLogs) writeNew(w io.Writer, steps []meshbuildingblockrun.StepLogDTO) error { for i, step := range steps { if i == len(*written) { *written = append(*written, writtenStep{displayName: step.DisplayName}) diff --git a/cmd/buildingblockrun/logs_test.go b/cmd/buildingblockrun/logs_test.go index 4b0fc951..55eb83e1 100644 --- a/cmd/buildingblockrun/logs_test.go +++ b/cmd/buildingblockrun/logs_test.go @@ -15,54 +15,55 @@ import ( "github.com/stretchr/testify/require" "github.com/meshcloud/meshstack-cli/client" + "github.com/meshcloud/meshstack-cli/client/meshbuildingblockrun/v1" ) var runUuid = uuid.MustParse("7f3a2b1c-8d4e-4f6a-9b0c-1d2e3f4a5b6c") -func step(displayName, status string, userMessage, systemMessage *string) client.MeshBuildingBlockRunStepLog { - return client.MeshBuildingBlockRunStepLog{DisplayName: displayName, Status: status, UserMessage: userMessage, SystemMessage: systemMessage} +func step(displayName, status string, userMessage, systemMessage *string) meshbuildingblockrun.StepLogDTO { + return meshbuildingblockrun.StepLogDTO{DisplayName: displayName, Status: meshbuildingblockrun.BuildingBlockRunStepStatus(status), UserMessage: userMessage, SystemMessage: systemMessage} } func TestFollowWritesOnlyWhatIsNewAndStartsOverAtAStepThatTookAnotherStepsPlace(t *testing.T) { snapshots := []struct { - steps []client.MeshBuildingBlockRunStepLog + steps []meshbuildingblockrun.StepLogDTO want string }{ { - steps: []client.MeshBuildingBlockRunStepLog{step("Init", "IN_PROGRESS", new("Cloning\n"), nil)}, + steps: []meshbuildingblockrun.StepLogDTO{step("Init", "IN_PROGRESS", new("Cloning\n"), nil)}, want: "Init: IN_PROGRESS\nInit | Cloning\n", }, { - steps: []client.MeshBuildingBlockRunStepLog{step("Init", "IN_PROGRESS", new("Cloning\n"), nil)}, + steps: []meshbuildingblockrun.StepLogDTO{step("Init", "IN_PROGRESS", new("Cloning\n"), nil)}, want: "", }, { - steps: []client.MeshBuildingBlockRunStepLog{step("Init", "IN_PROGRESS", new("Cloning\nCloned"), nil)}, + steps: []meshbuildingblockrun.StepLogDTO{step("Init", "IN_PROGRESS", new("Cloning\nCloned"), nil)}, want: "Init | Cloned\n", }, { - steps: []client.MeshBuildingBlockRunStepLog{ + steps: []meshbuildingblockrun.StepLogDTO{ step("Init", "SUCCEEDED", new("Cloning\nCloned"), new("Cloning\nCloned")), step("Pipeline", "PENDING", nil, nil), }, want: "Init: SUCCEEDED\nPipeline: PENDING\n", }, { - steps: []client.MeshBuildingBlockRunStepLog{ + steps: []meshbuildingblockrun.StepLogDTO{ step("Init", "SUCCEEDED", new("Cloning\nCloned"), new("Cloning\nCloned")), step("Pipeline", "IN_PROGRESS", new("Run 1 is queued"), new("Run 1 is queued, see https://ci")), }, want: "Pipeline: IN_PROGRESS\nPipeline | Run 1 is queued\nPipeline (system) | Run 1 is queued, see https://ci\n", }, { - steps: []client.MeshBuildingBlockRunStepLog{ + steps: []meshbuildingblockrun.StepLogDTO{ step("Init", "SUCCEEDED", new("Cloning\nCloned"), new("Cloning\nCloned")), step("Pipeline", "IN_PROGRESS", new("Run 1 is running"), new("Run 1 is queued, see https://ci")), }, want: "Pipeline | Run 1 is running\n", }, { - steps: []client.MeshBuildingBlockRunStepLog{step("Apply", "SUCCEEDED", new("done"), nil)}, + steps: []meshbuildingblockrun.StepLogDTO{step("Apply", "SUCCEEDED", new("done"), nil)}, want: "Apply: SUCCEEDED\nApply | done\n", }, } @@ -92,11 +93,11 @@ func (f *fakeRun) read(context.Context) (runSnapshot, error) { return answer.snapshot, answer.err } -func inProgress(steps ...client.MeshBuildingBlockRunStepLog) fakeAnswer { - return fakeAnswer{snapshot: runSnapshot{status: runInProgress, steps: steps}} +func inProgress(steps ...meshbuildingblockrun.StepLogDTO) fakeAnswer { + return fakeAnswer{snapshot: runSnapshot{status: meshbuildingblockrun.RunStatusInProgress, steps: steps}} } -func finished(status string, steps ...client.MeshBuildingBlockRunStepLog) fakeAnswer { +func finished(status meshbuildingblockrun.RunStatus, steps ...meshbuildingblockrun.StepLogDTO) fakeAnswer { return fakeAnswer{snapshot: runSnapshot{status: status, steps: steps}} } @@ -106,16 +107,16 @@ func TestFollowEndsWithTheRun(t *testing.T) { finished fakeAnswer wantErr string }{ - {"succeeded", finished(runSucceeded, step("Apply", "SUCCEEDED", nil, nil)), ""}, + {"succeeded", finished(meshbuildingblockrun.RunStatusSucceeded, step("Apply", "SUCCEEDED", nil, nil)), ""}, { - "failed", finished(runFailed, step("Plan", "SUCCEEDED", nil, nil), step("Apply", "FAILED", nil, nil)), + "failed", finished(meshbuildingblockrun.RunStatusFailed, step("Plan", "SUCCEEDED", nil, nil), step("Apply", "FAILED", nil, nil)), `building block run 7f3a2b1c-8d4e-4f6a-9b0c-1d2e3f4a5b6c failed at step "Apply"`, }, { - "aborted, which meshStack reports as failed", finished(runFailed, step("Apply", "ABORTED", nil, nil)), + "aborted, which meshStack reports as failed", finished(meshbuildingblockrun.RunStatusFailed, step("Apply", "ABORTED", nil, nil)), `building block run 7f3a2b1c-8d4e-4f6a-9b0c-1d2e3f4a5b6c was aborted at step "Apply"`, }, - {"failed with no steps", finished(runFailed), "building block run 7f3a2b1c-8d4e-4f6a-9b0c-1d2e3f4a5b6c failed"}, + {"failed with no steps", finished(meshbuildingblockrun.RunStatusFailed), "building block run 7f3a2b1c-8d4e-4f6a-9b0c-1d2e3f4a5b6c failed"}, { "in a status the CLI does not know", finished("WAITING"), `building block run 7f3a2b1c-8d4e-4f6a-9b0c-1d2e3f4a5b6c has the status "WAITING", which the meshStack CLI does not know, so it stops following the run`, @@ -147,7 +148,7 @@ func TestFollowRetriesAFailedReadWithLongerWaits(t *testing.T) { run := &fakeRun{answers: []fakeAnswer{ inProgress(step("Apply", "IN_PROGRESS", new("one"), nil)), unavailable, unavailable, - finished(runSucceeded, step("Apply", "SUCCEEDED", new("one\ntwo"), nil)), + finished(meshbuildingblockrun.RunStatusSucceeded, step("Apply", "SUCCEEDED", new("one\ntwo"), nil)), }} var out strings.Builder start := time.Now() diff --git a/cmd/workspace/list.go b/cmd/workspace/list.go index 926e2070..6630b774 100644 --- a/cmd/workspace/list.go +++ b/cmd/workspace/list.go @@ -3,7 +3,7 @@ package workspace import ( "github.com/spf13/cobra" - "github.com/meshcloud/meshstack-cli/client" + "github.com/meshcloud/meshstack-cli/client/meshworkspace/v2" "github.com/meshcloud/meshstack-cli/cmd/internal" ) @@ -21,7 +21,7 @@ Organization Admin, lists them all. From any other workspace the list holds that meshstack ws list --workspace admin-workspace --limit unlimited -o ndjson`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { - return flags.Run[client.MeshWorkspace](cmd, client.MeshWorkspaceListFilter{}) + return flags.Run[meshworkspace.Response](cmd, meshworkspace.ListFilter{}) }, } diff --git a/internal/tfstate/buildingblock.go b/internal/tfstate/buildingblock.go index 8b14a552..bf525f6c 100644 --- a/internal/tfstate/buildingblock.go +++ b/internal/tfstate/buildingblock.go @@ -7,15 +7,16 @@ import ( "uuid" "github.com/meshcloud/meshstack-cli/client" + "github.com/meshcloud/meshstack-cli/client/meshbuildingblock/v2" ) type buildingBlock struct { - Metadata client.MeshBuildingBlockV2Metadata `json:"metadata"` - Status *client.MeshBuildingBlockV2Status `json:"status"` + Metadata meshbuildingblock.Metadata `json:"metadata"` + Status *meshbuildingblock.StatusResponse `json:"status"` } func readBuildingBlock(ctx context.Context, raw *client.RawClient, buildingBlockUuid uuid.UUID) (block buildingBlock, err error) { - read, err := raw.Get[client.MeshBuildingBlockV2](ctx, buildingBlockUuid) + read, err := raw.Get[meshbuildingblock.Response](ctx, buildingBlockUuid) if err != nil { return block, err } @@ -24,7 +25,8 @@ func readBuildingBlock(ctx context.Context, raw *client.RawClient, buildingBlock func (block buildingBlock) hasUnfinishedRun() bool { return block.Status != nil && - (block.Status.Status == client.BuildingBlockStatusInProgress || block.Status.Status == client.BuildingBlockStatusPending) + //nolint:staticcheck // SA1019: moving to status.action is a change of its own, which this switch of types leaves out + (block.Status.Status == meshbuildingblock.StatusValueInProgress || block.Status.Status == meshbuildingblock.StatusValuePending) } // OpenStore opens the state under the building block's own workspace where workspace is empty, @@ -46,7 +48,7 @@ func NoRunOf(ctx context.Context, raw *client.RawClient, buildingBlockUuid uuid. return err } if block.hasUnfinishedRun() { - return fmt.Errorf("building block %s has a run %s, which writes the state as well", buildingBlockUuid, block.Status.Status) + return fmt.Errorf("building block %s has a run %s, which writes the state as well", buildingBlockUuid, block.Status.Status) //nolint:staticcheck // SA1019: as in hasUnfinishedRun } return nil } diff --git a/internal/tfstate/lock.go b/internal/tfstate/lock.go index 3701a580..27ba99b5 100644 --- a/internal/tfstate/lock.go +++ b/internal/tfstate/lock.go @@ -9,6 +9,7 @@ import ( "uuid" "github.com/meshcloud/meshstack-cli/client" + "github.com/meshcloud/meshstack-cli/client/meshbuildingblockrun/v1" "github.com/meshcloud/meshstack-cli/internal/http" ) @@ -49,15 +50,17 @@ func (l Lock) NoUnfinishedRun(ctx context.Context, raw *client.RawClient) error if err != nil { return fmt.Errorf("the lock names the run %q, which is no uuid: %w", l.Holder.RunUuid, err) } - read, err := raw.Get[client.MeshBuildingBlockRun](ctx, runUuid) + read, err := raw.Get[meshbuildingblockrun.Response](ctx, runUuid) if err != nil { return err } - var run client.MeshBuildingBlockRun + var run struct { + Status meshbuildingblockrun.RunStatus `json:"status"` + } if err = json.Unmarshal(read, &run); err != nil { return err } - if run.Status == string(client.BuildingBlockStatusPending) || run.Status == string(client.BuildingBlockStatusInProgress) { + if run.Status == meshbuildingblockrun.RunStatusInProgress { return fmt.Errorf("building block run %s, which holds the lock, is %s and still writes the state", runUuid, run.Status) } return nil From 2f82f2e6635001d275c92ca19f2b9377a775c12c Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Thu, 22 Oct 2026 19:19:48 +0200 Subject: [PATCH 10/18] feat(client): read deprecations and path keys from the API docs Co-Authored-By: Claude Opus 5.5 --- client/openapi/operation.go | 74 ++++++++++++++++++++++++++++++++----- client/openapi/schema.go | 4 ++ client/openapi/spec.go | 4 +- 3 files changed, 72 insertions(+), 10 deletions(-) diff --git a/client/openapi/operation.go b/client/openapi/operation.go index 135b199b..3799ca8d 100644 --- a/client/openapi/operation.go +++ b/client/openapi/operation.go @@ -5,6 +5,7 @@ import ( "encoding/json/v2" "fmt" "slices" + "strings" ) // OperationSchemas are the parameters and bodies of an operation, with their schemas. @@ -15,15 +16,67 @@ type OperationSchemas struct { } type ParameterSchema struct { - Name, In string - Required bool - Schema *Schema + Name, In string + Required bool + Schema *Schema + Description string + PathKey PathKey +} + +// PathKey is the field of a meshObject that a path parameter takes, such as metadata.uuid, or a +// template of several fields, such as {metadata.ownedByWorkspace}.{metadata.name}. +type PathKey struct { + Key string `json:"x-meshstack-path-key"` + // Name is set where the key is a template, such as workspace.project for the one above. + Name string `json:"x-meshstack-path-key-name"` + // ByVersion holds the keys of the API versions that take another key than Key. + ByVersion map[string]string `json:"x-meshstack-path-key-by-version"` } // Content is the body of one media type. type Content struct { MediaType string Schema *Schema + Lifecycle *Lifecycle +} + +// Lifecycle is the state of one version of a meshObject kind. +type Lifecycle struct { + MeshObject string `json:"meshObject"` + State string `json:"state"` + RetiredAfter string `json:"retiredAfter"` + Notice string `json:"notice"` +} + +// Deprecation is empty for a version that is not deprecated. +func (l Lifecycle) Deprecation() string { + var text string + switch l.State { + case "Outdated": + text = "`" + l.MeshObject + "` is outdated." + case "Deprecated": + text = "`" + l.MeshObject + "` is deprecated." + if l.RetiredAfter != "" { + text = "`" + l.MeshObject + "` is deprecated and retired after " + l.RetiredAfter + "." + } + default: + return "" + } + return strings.TrimSpace(text + " " + l.Notice) +} + +// Lifecycle is that of the media type in the request body or a response, or nil. +func (s OperationSchemas) Lifecycle(mediaType string) *Lifecycle { + bodies := slices.Clone(s.RequestBody) + for _, response := range s.Responses { + bodies = append(bodies, response.Content...) + } + for _, body := range bodies { + if body.MediaType == mediaType && body.Lifecycle != nil { + return body.Lifecycle + } + } + return nil } type ResponseContent struct { @@ -64,10 +117,12 @@ func (o Operation) Schemas() (OperationSchemas, error) { func (p *ParameterSchema) UnmarshalJSONFrom(dec *jsontext.Decoder) error { var wire struct { - Name string `json:"name"` - In string `json:"in"` - Required bool `json:"required"` - Schema *Schema `json:"schema"` + Name string `json:"name"` + In string `json:"in"` + Required bool `json:"required"` + Schema *Schema `json:"schema"` + Description string `json:"description"` + PathKey PathKey `json:",embed"` } if err := json.UnmarshalDecode(dec, &wire); err != nil { return err @@ -80,12 +135,13 @@ func contents(members object) ([]Content, error) { var result []Content for _, m := range members { var mediaType struct { - Schema *Schema `json:"schema"` + Schema *Schema `json:"schema"` + Lifecycle *Lifecycle `json:"x-meshstack-lifecycle"` } if err := json.Unmarshal(m.value, &mediaType); err != nil { return nil, fmt.Errorf("%s: %w", m.name, err) } - result = append(result, Content{m.name, mediaType.Schema}) + result = append(result, Content{m.name, mediaType.Schema, mediaType.Lifecycle}) } return result, nil } diff --git a/client/openapi/schema.go b/client/openapi/schema.go index 965afa1d..d0bce498 100644 --- a/client/openapi/schema.go +++ b/client/openapi/schema.go @@ -26,6 +26,10 @@ type Schema struct { Nullable bool `json:"nullable"` ReadOnly bool `json:"readOnly"` WriteOnly bool `json:"writeOnly"` + Deprecated bool `json:"deprecated"` + // DeprecatedDescription says why a Deprecated schema is, where meshStack states it. + DeprecatedDescription string `json:"x-deprecated-description"` + Versions []Lifecycle `json:"x-meshstack-versions"` } type Discriminator struct { diff --git a/client/openapi/spec.go b/client/openapi/spec.go index 77fa7ecc..1634d94d 100644 --- a/client/openapi/spec.go +++ b/client/openapi/spec.go @@ -34,6 +34,7 @@ type Operation struct { Action string // MediaTypes are those of the request body and of every response, each once. MediaTypes []MediaType + Deprecated bool raw jsontext.Value } @@ -105,6 +106,7 @@ func parseOperation(method, pathTemplate string, raw jsontext.Value) (Operation, } var parsed struct { OperationId string `json:"operationId"` + Deprecated bool `json:"deprecated"` RequestBody content `json:"requestBody"` Responses map[string]content `json:"responses"` } @@ -116,7 +118,7 @@ func parseOperation(method, pathTemplate string, raw jsontext.Value) (Operation, names = slices.AppendSeq(names, maps.Keys(response.Content)) } slices.Sort(names) - operation := Operation{Method: method, PathTemplate: PathTemplate(pathTemplate), OperationId: parsed.OperationId, raw: raw} + operation := Operation{Method: method, PathTemplate: PathTemplate(pathTemplate), OperationId: parsed.OperationId, Deprecated: parsed.Deprecated, raw: raw} for _, name := range slices.Compact(names) { operation.MediaTypes = append(operation.MediaTypes, newMediaType(name, parsed.OperationId)) } From 221d956e58194dbbc8a8f447c0b0ecf71a88798f Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Thu, 22 Oct 2026 19:20:10 +0200 Subject: [PATCH 11/18] refactor(client): send the requests of generated clients through client/meshapi Another module's generated clients cannot import client/internal. Config.Runtime names the package for a module that brings its own, and client.Client.HttpClient is what their NewClient takes. Co-Authored-By: Claude Opus 5.5 --- client/client.go | 7 +++-- client/internal/mesh_object.go | 8 ++---- client/{internal => meshapi}/api.go | 28 ++++++++++++++----- client/meshbuildingblock/v2/generated.go | 24 ++++++++-------- client/meshbuildingblockrun/v1/generated.go | 16 +++++------ client/meshworkspace/v2/generated.go | 20 ++++++------- client/openapi/generator/generated.go.tmpl | 8 +++--- client/openapi/generator/generator.go | 11 +++++--- client/openapi/generator/model.go | 1 + client/openapi/generator/render.go | 10 +++++-- .../golden/meshother/v1/generated.go.golden | 12 ++++---- .../golden/meshthing/v2/generated.go.golden | 20 ++++++------- client/raw.go | 6 ++-- 13 files changed, 97 insertions(+), 74 deletions(-) rename client/{internal => meshapi}/api.go (86%) diff --git a/client/client.go b/client/client.go index 9f2e5e5e..8ebdc538 100644 --- a/client/client.go +++ b/client/client.go @@ -3,7 +3,7 @@ package client import ( "context" - "github.com/meshcloud/meshstack-cli/client/internal" + "github.com/meshcloud/meshstack-cli/client/meshapi" "github.com/meshcloud/meshstack-cli/client/meshbuildingblock/v2" "github.com/meshcloud/meshstack-cli/client/meshbuildingblockrun/v1" "github.com/meshcloud/meshstack-cli/client/meshworkspace/v2" @@ -53,13 +53,15 @@ type Client struct { MeshWorkspace meshworkspace.Client MeshBuildingBlock meshbuildingblock.Client MeshBuildingBlockRun meshbuildingblockrun.Client + // HttpClient is what the NewClient of a package that another module generates takes. + HttpClient meshapi.HttpClient // Endpoint is read by the Terraform provider's meshstack_instance data source. Endpoint xurl.URL } func New(ctx context.Context, endpoint xurl.URL, client http.Client, auth Authorization) Client { - authorizedClient := internal.HttpClient{ + authorizedClient := meshapi.HttpClient{ AuthorizedClient: client.WithAuthorization(auth), EndpointUrl: endpoint, } @@ -109,6 +111,7 @@ func New(ctx context.Context, endpoint xurl.URL, client http.Client, auth Author MeshWorkspace: generatedWorkspace, MeshBuildingBlock: generatedBuildingBlock, MeshBuildingBlockRun: generatedBuildingBlockRun, + HttpClient: authorizedClient, Endpoint: endpoint, } diff --git a/client/internal/mesh_object.go b/client/internal/mesh_object.go index 9147f6f0..77a1287b 100644 --- a/client/internal/mesh_object.go +++ b/client/internal/mesh_object.go @@ -13,15 +13,11 @@ import ( "strings" "unicode" - "github.com/meshcloud/meshstack-cli/client/types/xurl" + "github.com/meshcloud/meshstack-cli/client/meshapi" "github.com/meshcloud/meshstack-cli/internal/http" ) -type HttpClient struct { - http.AuthorizedClient - - EndpointUrl xurl.URL -} +type HttpClient = meshapi.HttpClient // MeshObjectApi is the API of one meshObject kind, in one version, with no Go type for its objects: // the requests and the paging they share, for a caller that decodes the answers itself. diff --git a/client/internal/api.go b/client/meshapi/api.go similarity index 86% rename from client/internal/api.go rename to client/meshapi/api.go index a10a8f07..e46223b4 100644 --- a/client/internal/api.go +++ b/client/meshapi/api.go @@ -1,4 +1,6 @@ -package internal +// Package meshapi sends the requests of the clients that client/openapi/generator writes. It is +// public so that another module can generate clients of its own. +package meshapi import ( "context" @@ -12,10 +14,18 @@ import ( "strings" "github.com/meshcloud/meshstack-cli/client/openapi" + "github.com/meshcloud/meshstack-cli/client/types/xurl" "github.com/meshcloud/meshstack-cli/internal/http" internaljson "github.com/meshcloud/meshstack-cli/internal/json" ) +// HttpClient sends requests to one meshStack, authorized. +type HttpClient struct { + http.AuthorizedClient + + EndpointUrl xurl.URL +} + // Api is one version of a meshObject kind as the generated clients call it. type Api struct { HttpClient @@ -86,12 +96,21 @@ func (a Api) Do[R any](ctx context.Context, call Call, body any, options ...http return result, a.decode(answer, call.AnswersMeshObject, &result) } +type page struct { + Embedded map[string][]jsontext.Value `json:"_embedded"` + Page struct { + TotalElements int `json:"totalElements"` + TotalPages int `json:"totalPages"` + Number int `json:"number"` + } `json:"page"` +} + // List pages through the objects that call lists. func (a Api) List[T any](ctx context.Context, call Call) iter.Seq2[T, error] { return func(yield func(T, error) bool) { var noItem T for pageNumber := 0; ; pageNumber++ { - response, err := a.Do[paginatedResponse[jsontext.Value]](ctx, Call{Method: call.Method, Path: call.Path, Query: call.Query}, nil, + response, err := a.Do[page](ctx, Call{Method: call.Method, Path: call.Path, Query: call.Query}, nil, http.WithUrlQuery(map[string]any{"page": pageNumber})) if err != nil && pageNumber > 0 { err = fmt.Errorf("cannot read page %d of the list: %w", pageNumber, err) @@ -122,11 +141,6 @@ func (a Api) List[T any](ctx context.Context, call Call) iter.Seq2[T, error] { } } -// MeshObjectApi lets the raw client reach the kind in the same version. -func (a Api) MeshObjectApi() MeshObjectApi { - return MeshObjectApi{a.AuthorizedClient, a.Kind, a.ApiVersion, a.EndpointUrl.JoinPath(a.Path)} -} - func (a Api) payload(body any, meshObject bool) (jsontext.Value, error) { encoded, err := internaljson.Marshal(body) if err != nil || !meshObject { diff --git a/client/meshbuildingblock/v2/generated.go b/client/meshbuildingblock/v2/generated.go index a3cc8744..559d4c4b 100644 --- a/client/meshbuildingblock/v2/generated.go +++ b/client/meshbuildingblock/v2/generated.go @@ -12,7 +12,7 @@ import ( "time" "uuid" - "github.com/meshcloud/meshstack-cli/client/internal" + "github.com/meshcloud/meshstack-cli/client/meshapi" "github.com/meshcloud/meshstack-cli/client/meshobject" "github.com/meshcloud/meshstack-cli/client/types" "github.com/meshcloud/meshstack-cli/client/types/enum" @@ -25,11 +25,11 @@ const ( ) type Client struct { - api internal.Api + api meshapi.Api } -func NewClient(httpClient internal.HttpClient) Client { - return Client{internal.Api{ +func NewClient(httpClient meshapi.HttpClient) Client { + return Client{meshapi.Api{ HttpClient: httpClient, Kind: Kind, ApiVersion: ApiVersion, @@ -40,45 +40,45 @@ func NewClient(httpClient internal.HttpClient) Client { } // Api lets the raw client of the CLI reach the same API version. -func (c Client) Api() internal.Api { +func (c Client) Api() meshapi.Api { return c.api } // Create building block func (c Client) Create(ctx context.Context, body Request) (Response, error) { - return c.api.Do[Response](ctx, internal.Call{Method: "POST", Path: "/api/meshobjects/meshbuildingblocks", SendsMeshObject: true, AnswersMeshObject: true}, body) + return c.api.Do[Response](ctx, meshapi.Call{Method: "POST", Path: "/api/meshobjects/meshbuildingblocks", SendsMeshObject: true, AnswersMeshObject: true}, body) } // Delete building block func (c Client) Delete(ctx context.Context, buildingBlock uuid.UUID) error { - _, err := c.api.Do[any](ctx, internal.Call{Method: "DELETE", Path: internal.Path("/api/meshobjects/meshbuildingblocks/{buildingBlock}", buildingBlock.String())}, nil) + _, err := c.api.Do[any](ctx, meshapi.Call{Method: "DELETE", Path: meshapi.Path("/api/meshobjects/meshbuildingblocks/{buildingBlock}", buildingBlock.String())}, nil) return err } // Get building block func (c Client) Get(ctx context.Context, buildingBlock uuid.UUID) (Response, error) { - return c.api.Do[Response](ctx, internal.Call{Method: "GET", Path: internal.Path("/api/meshobjects/meshbuildingblocks/{buildingBlock}", buildingBlock.String()), AnswersMeshObject: true}, nil) + return c.api.Do[Response](ctx, meshapi.Call{Method: "GET", Path: meshapi.Path("/api/meshobjects/meshbuildingblocks/{buildingBlock}", buildingBlock.String()), AnswersMeshObject: true}, nil) } // List building blocks func (c Client) List(ctx context.Context, filter ListFilter) iter.Seq2[Response, error] { - return c.api.List[Response](ctx, internal.Call{Method: "GET", Path: "/api/meshobjects/meshbuildingblocks", Query: filter, AnswersMeshObject: true}) + return c.api.List[Response](ctx, meshapi.Call{Method: "GET", Path: "/api/meshobjects/meshbuildingblocks", Query: filter, AnswersMeshObject: true}) } // Purge building block func (c Client) Purge(ctx context.Context, buildingBlock uuid.UUID) error { - _, err := c.api.Do[any](ctx, internal.Call{Method: "DELETE", Path: internal.Path("/api/meshobjects/meshbuildingblocks/{buildingBlock}/purge", buildingBlock.String())}, nil) + _, err := c.api.Do[any](ctx, meshapi.Call{Method: "DELETE", Path: meshapi.Path("/api/meshobjects/meshbuildingblocks/{buildingBlock}/purge", buildingBlock.String())}, nil) return err } // Trigger run of building block func (c Client) TriggerRun(ctx context.Context, buildingBlock uuid.UUID, body TriggerRunRequest) (Response, error) { - return c.api.Do[Response](ctx, internal.Call{Method: "POST", Path: internal.Path("/api/meshobjects/meshbuildingblocks/{buildingBlock}/trigger-run", buildingBlock.String()), AnswersMeshObject: true}, body) + return c.api.Do[Response](ctx, meshapi.Call{Method: "POST", Path: meshapi.Path("/api/meshobjects/meshbuildingblocks/{buildingBlock}/trigger-run", buildingBlock.String()), AnswersMeshObject: true}, body) } // Update building block func (c Client) Update(ctx context.Context, buildingBlock uuid.UUID, body Request) (Response, error) { - return c.api.Do[Response](ctx, internal.Call{Method: "PUT", Path: internal.Path("/api/meshobjects/meshbuildingblocks/{buildingBlock}", buildingBlock.String()), SendsMeshObject: true, AnswersMeshObject: true}, body) + return c.api.Do[Response](ctx, meshapi.Call{Method: "PUT", Path: meshapi.Path("/api/meshobjects/meshbuildingblocks/{buildingBlock}", buildingBlock.String()), SendsMeshObject: true, AnswersMeshObject: true}, body) } type ListFilter struct { diff --git a/client/meshbuildingblockrun/v1/generated.go b/client/meshbuildingblockrun/v1/generated.go index 5947611b..9c02dd95 100644 --- a/client/meshbuildingblockrun/v1/generated.go +++ b/client/meshbuildingblockrun/v1/generated.go @@ -12,7 +12,7 @@ import ( "time" "uuid" - "github.com/meshcloud/meshstack-cli/client/internal" + "github.com/meshcloud/meshstack-cli/client/meshapi" "github.com/meshcloud/meshstack-cli/client/meshobject" "github.com/meshcloud/meshstack-cli/client/types/enum" ) @@ -24,11 +24,11 @@ const ( ) type Client struct { - api internal.Api + api meshapi.Api } -func NewClient(httpClient internal.HttpClient) Client { - return Client{internal.Api{ +func NewClient(httpClient meshapi.HttpClient) Client { + return Client{meshapi.Api{ HttpClient: httpClient, Kind: Kind, ApiVersion: ApiVersion, @@ -39,23 +39,23 @@ func NewClient(httpClient internal.HttpClient) Client { } // Api lets the raw client of the CLI reach the same API version. -func (c Client) Api() internal.Api { +func (c Client) Api() meshapi.Api { return c.api } // Get building block run func (c Client) Get(ctx context.Context, buildingBlockRun uuid.UUID) (Response, error) { - return c.api.Do[Response](ctx, internal.Call{Method: "GET", Path: internal.Path("/api/meshobjects/meshbuildingblockruns/{buildingBlockRun}", buildingBlockRun.String()), AnswersMeshObject: true}, nil) + return c.api.Do[Response](ctx, meshapi.Call{Method: "GET", Path: meshapi.Path("/api/meshobjects/meshbuildingblockruns/{buildingBlockRun}", buildingBlockRun.String()), AnswersMeshObject: true}, nil) } // List building block runs func (c Client) List(ctx context.Context, filter ListFilter) iter.Seq2[Response, error] { - return c.api.List[Response](ctx, internal.Call{Method: "GET", Path: "/api/meshobjects/meshbuildingblockruns", Query: filter, AnswersMeshObject: true}) + return c.api.List[Response](ctx, meshapi.Call{Method: "GET", Path: "/api/meshobjects/meshbuildingblockruns", Query: filter, AnswersMeshObject: true}) } // Download logs of building block run func (c Client) Logs(ctx context.Context, buildingBlockRun uuid.UUID) (LogsDTO, error) { - return c.api.Do[LogsDTO](ctx, internal.Call{Method: "GET", Path: internal.Path("/api/meshobjects/meshbuildingblockruns/{buildingBlockRun}/logs", buildingBlockRun.String())}, nil) + return c.api.Do[LogsDTO](ctx, meshapi.Call{Method: "GET", Path: meshapi.Path("/api/meshobjects/meshbuildingblockruns/{buildingBlockRun}/logs", buildingBlockRun.String())}, nil) } type ListFilter struct { diff --git a/client/meshworkspace/v2/generated.go b/client/meshworkspace/v2/generated.go index be6db238..27110e7f 100644 --- a/client/meshworkspace/v2/generated.go +++ b/client/meshworkspace/v2/generated.go @@ -8,7 +8,7 @@ import ( "iter" "time" - "github.com/meshcloud/meshstack-cli/client/internal" + "github.com/meshcloud/meshstack-cli/client/meshapi" "github.com/meshcloud/meshstack-cli/client/meshobject" ) @@ -19,11 +19,11 @@ const ( ) type Client struct { - api internal.Api + api meshapi.Api } -func NewClient(httpClient internal.HttpClient) Client { - return Client{internal.Api{ +func NewClient(httpClient meshapi.HttpClient) Client { + return Client{meshapi.Api{ HttpClient: httpClient, Kind: Kind, ApiVersion: ApiVersion, @@ -34,34 +34,34 @@ func NewClient(httpClient internal.HttpClient) Client { } // Api lets the raw client of the CLI reach the same API version. -func (c Client) Api() internal.Api { +func (c Client) Api() meshapi.Api { return c.api } // Create workspace func (c Client) Create(ctx context.Context, body Request) (Response, error) { - return c.api.Do[Response](ctx, internal.Call{Method: "POST", Path: "/api/meshobjects/meshworkspaces", SendsMeshObject: true, AnswersMeshObject: true}, body) + return c.api.Do[Response](ctx, meshapi.Call{Method: "POST", Path: "/api/meshobjects/meshworkspaces", SendsMeshObject: true, AnswersMeshObject: true}, body) } // Delete workspace func (c Client) Delete(ctx context.Context, workspace string) error { - _, err := c.api.Do[any](ctx, internal.Call{Method: "DELETE", Path: internal.Path("/api/meshobjects/meshworkspaces/{workspace}", workspace)}, nil) + _, err := c.api.Do[any](ctx, meshapi.Call{Method: "DELETE", Path: meshapi.Path("/api/meshobjects/meshworkspaces/{workspace}", workspace)}, nil) return err } // Get workspace func (c Client) Get(ctx context.Context, workspace string) (Response, error) { - return c.api.Do[Response](ctx, internal.Call{Method: "GET", Path: internal.Path("/api/meshobjects/meshworkspaces/{workspace}", workspace), AnswersMeshObject: true}, nil) + return c.api.Do[Response](ctx, meshapi.Call{Method: "GET", Path: meshapi.Path("/api/meshobjects/meshworkspaces/{workspace}", workspace), AnswersMeshObject: true}, nil) } // List workspaces func (c Client) List(ctx context.Context, filter ListFilter) iter.Seq2[Response, error] { - return c.api.List[Response](ctx, internal.Call{Method: "GET", Path: "/api/meshobjects/meshworkspaces", Query: filter, AnswersMeshObject: true}) + return c.api.List[Response](ctx, meshapi.Call{Method: "GET", Path: "/api/meshobjects/meshworkspaces", Query: filter, AnswersMeshObject: true}) } // Create or update workspace func (c Client) Update(ctx context.Context, workspace string, body Request) (Response, error) { - return c.api.Do[Response](ctx, internal.Call{Method: "PUT", Path: internal.Path("/api/meshobjects/meshworkspaces/{workspace}", workspace), SendsMeshObject: true, AnswersMeshObject: true}, body) + return c.api.Do[Response](ctx, meshapi.Call{Method: "PUT", Path: meshapi.Path("/api/meshobjects/meshworkspaces/{workspace}", workspace), SendsMeshObject: true, AnswersMeshObject: true}, body) } type ListFilter struct { diff --git a/client/openapi/generator/generated.go.tmpl b/client/openapi/generator/generated.go.tmpl index 3c3a83b7..32e8f711 100644 --- a/client/openapi/generator/generated.go.tmpl +++ b/client/openapi/generator/generated.go.tmpl @@ -20,11 +20,11 @@ const ( {{- if .Operations}} type Client struct { - api internal.Api + api {{$.Runtime}}.Api } -func NewClient(httpClient internal.HttpClient) Client { - return Client{internal.Api{ +func NewClient(httpClient {{$.Runtime}}.HttpClient) Client { + return Client{ {{- $.Runtime}}.Api{ HttpClient: httpClient, Kind: {{if .Kind}}Kind{{else}}""{{end}}, ApiVersion: {{if .Kind}}ApiVersion{{else}}""{{end}}, @@ -35,7 +35,7 @@ func NewClient(httpClient internal.HttpClient) Client { } // Api lets the raw client of the CLI reach the same API version. -func (c Client) Api() internal.Api { +func (c Client) Api() {{$.Runtime}}.Api { return c.api } {{- range .Operations}} diff --git a/client/openapi/generator/generator.go b/client/openapi/generator/generator.go index ecc44e8b..6a3fca9c 100644 --- a/client/openapi/generator/generator.go +++ b/client/openapi/generator/generator.go @@ -24,6 +24,9 @@ type Config struct { // MapType decides the Go type of a schema before the generator does. It must map every oneOf // without a discriminator. MapType TypeMapper + // Runtime is the import path of the package that sends the requests of a generated client, + // DefaultRuntime unless another module brings its own. + Runtime string } // Package selects the operations of one meshObject kind in one API version, or of operation IDs. @@ -67,10 +70,10 @@ func (l Location) child(property string) Location { type TypeMapper func(at Location, schema *openapi.Schema) (GoType, bool) const ( - header = "// Code generated by github.com/meshcloud/meshstack-cli/client/openapi/generator. DO NOT EDIT.\n\n" - runtimeImport = "github.com/meshcloud/meshstack-cli/client/internal" - enumImport = "github.com/meshcloud/meshstack-cli/client/types/enum" - fileName = "generated.go" + header = "// Code generated by github.com/meshcloud/meshstack-cli/client/openapi/generator. DO NOT EDIT.\n\n" + DefaultRuntime = "github.com/meshcloud/meshstack-cli/client/meshapi" + enumImport = "github.com/meshcloud/meshstack-cli/client/types/enum" + fileName = "generated.go" ) // Generate returns the content of each generated file by its path below Config.Module. diff --git a/client/openapi/generator/model.go b/client/openapi/generator/model.go index 79d4b386..2db08f1c 100644 --- a/client/openapi/generator/model.go +++ b/client/openapi/generator/model.go @@ -138,6 +138,7 @@ type param struct { } func newGenerator(spec openapi.Spec, cfg Config) *generator { + cfg.Runtime = cmp.Or(cfg.Runtime, DefaultRuntime) shared := &pkg{Path: cfg.Shared, Name: path.Base(cfg.Shared), names: map[string]bool{}} shared.importPath = path.Join(cfg.Module, cfg.Shared) return &generator{ diff --git a/client/openapi/generator/render.go b/client/openapi/generator/render.go index 4ffc4840..b11a1b54 100644 --- a/client/openapi/generator/render.go +++ b/client/openapi/generator/render.go @@ -6,6 +6,7 @@ import ( "fmt" "go/format" "maps" + "path" "slices" "strconv" "strings" @@ -24,6 +25,7 @@ type fileData struct { Header string Doc string Package string + Runtime string StdImports []string Imports []string Kind string @@ -76,6 +78,7 @@ func (g *generator) render(p *pkg) ([]byte, error) { data := fileData{ Header: header, Package: p.Name, + Runtime: path.Base(g.cfg.Runtime), Kind: p.Kind, ApiVersion: p.ApiVersion, MediaType: p.mediaType, @@ -97,7 +100,7 @@ func (g *generator) render(p *pkg) ([]byte, error) { } if len(p.operations) > 0 { r.imports["context"] = true - r.imports[runtimeImport] = true + r.imports[g.cfg.Runtime] = true } for _, d := range p.types { data.Types = append(data.Types, r.typeData(d)) @@ -141,6 +144,7 @@ func (r renderer) expr(t typeRef) string { } func (r renderer) operation(o *operation) operationData { + runtime := path.Base(r.g.cfg.Runtime) params := []string{"ctx context.Context"} pathArgs := []string{strconv.Quote(o.pathTemplate)} for _, p := range o.pathParams { @@ -154,7 +158,7 @@ func (r renderer) operation(o *operation) operationData { } call := []string{"Method: " + strconv.Quote(o.method)} if len(o.pathParams) > 0 { - call = append(call, "Path: internal.Path("+strings.Join(pathArgs, ", ")+")") + call = append(call, "Path: "+runtime+".Path("+strings.Join(pathArgs, ", ")+")") } else { call = append(call, "Path: "+pathArgs[0]) } @@ -186,7 +190,7 @@ func (r renderer) operation(o *operation) operationData { data.Returns = "iter.Seq2[" + data.Result + ", error]" } data.Params = strings.Join(params, ", ") - data.Call = "internal.Call{" + strings.Join(call, ", ") + "}" + data.Call = runtime + ".Call{" + strings.Join(call, ", ") + "}" return data } diff --git a/client/openapi/generator/testdata/golden/meshother/v1/generated.go.golden b/client/openapi/generator/testdata/golden/meshother/v1/generated.go.golden index bfb05a42..3dbd25cf 100644 --- a/client/openapi/generator/testdata/golden/meshother/v1/generated.go.golden +++ b/client/openapi/generator/testdata/golden/meshother/v1/generated.go.golden @@ -7,7 +7,7 @@ import ( "context" "example.com/client/meshobject" - "github.com/meshcloud/meshstack-cli/client/internal" + "github.com/meshcloud/meshstack-cli/client/meshapi" ) const ( @@ -17,11 +17,11 @@ const ( ) type Client struct { - api internal.Api + api meshapi.Api } -func NewClient(httpClient internal.HttpClient) Client { - return Client{internal.Api{ +func NewClient(httpClient meshapi.HttpClient) Client { + return Client{meshapi.Api{ HttpClient: httpClient, Kind: Kind, ApiVersion: ApiVersion, @@ -32,13 +32,13 @@ func NewClient(httpClient internal.HttpClient) Client { } // Api lets the raw client of the CLI reach the same API version. -func (c Client) Api() internal.Api { +func (c Client) Api() meshapi.Api { return c.api } // Get other func (c Client) Get(ctx context.Context, other string) (Response, error) { - return c.api.Do[Response](ctx, internal.Call{Method: "GET", Path: internal.Path("/api/meshobjects/meshothers/{other}", other), AnswersMeshObject: true}, nil) + return c.api.Do[Response](ctx, meshapi.Call{Method: "GET", Path: meshapi.Path("/api/meshobjects/meshothers/{other}", other), AnswersMeshObject: true}, nil) } type Response struct { diff --git a/client/openapi/generator/testdata/golden/meshthing/v2/generated.go.golden b/client/openapi/generator/testdata/golden/meshthing/v2/generated.go.golden index 7da4f81d..9e099726 100644 --- a/client/openapi/generator/testdata/golden/meshthing/v2/generated.go.golden +++ b/client/openapi/generator/testdata/golden/meshthing/v2/generated.go.golden @@ -11,7 +11,7 @@ import ( "example.com/client/meshobject" "example.com/client/types" - "github.com/meshcloud/meshstack-cli/client/internal" + "github.com/meshcloud/meshstack-cli/client/meshapi" "github.com/meshcloud/meshstack-cli/client/types/enum" ) @@ -22,11 +22,11 @@ const ( ) type Client struct { - api internal.Api + api meshapi.Api } -func NewClient(httpClient internal.HttpClient) Client { - return Client{internal.Api{ +func NewClient(httpClient meshapi.HttpClient) Client { + return Client{meshapi.Api{ HttpClient: httpClient, Kind: Kind, ApiVersion: ApiVersion, @@ -37,34 +37,34 @@ func NewClient(httpClient internal.HttpClient) Client { } // Api lets the raw client of the CLI reach the same API version. -func (c Client) Api() internal.Api { +func (c Client) Api() meshapi.Api { return c.api } // Create thing func (c Client) Create(ctx context.Context, body Request) (Response, error) { - return c.api.Do[Response](ctx, internal.Call{Method: "POST", Path: "/api/meshobjects/meshthings", SendsMeshObject: true, AnswersMeshObject: true}, body) + return c.api.Do[Response](ctx, meshapi.Call{Method: "POST", Path: "/api/meshobjects/meshthings", SendsMeshObject: true, AnswersMeshObject: true}, body) } // Delete thing func (c Client) Delete(ctx context.Context, thing uuid.UUID) error { - _, err := c.api.Do[any](ctx, internal.Call{Method: "DELETE", Path: internal.Path("/api/meshobjects/meshthings/{thing}", thing.String())}, nil) + _, err := c.api.Do[any](ctx, meshapi.Call{Method: "DELETE", Path: meshapi.Path("/api/meshobjects/meshthings/{thing}", thing.String())}, nil) return err } // Get thing func (c Client) Get(ctx context.Context, thing uuid.UUID) (Response, error) { - return c.api.Do[Response](ctx, internal.Call{Method: "GET", Path: internal.Path("/api/meshobjects/meshthings/{thing}", thing.String()), AnswersMeshObject: true}, nil) + return c.api.Do[Response](ctx, meshapi.Call{Method: "GET", Path: meshapi.Path("/api/meshobjects/meshthings/{thing}", thing.String()), AnswersMeshObject: true}, nil) } // List things func (c Client) List(ctx context.Context, filter ListFilter) iter.Seq2[Response, error] { - return c.api.List[Response](ctx, internal.Call{Method: "GET", Path: "/api/meshobjects/meshthings", Query: filter, AnswersMeshObject: true}) + return c.api.List[Response](ctx, meshapi.Call{Method: "GET", Path: "/api/meshobjects/meshthings", Query: filter, AnswersMeshObject: true}) } // Paint thing func (c Client) Paint(ctx context.Context, thing uuid.UUID, body PaintRequest) (Response, error) { - return c.api.Do[Response](ctx, internal.Call{Method: "POST", Path: internal.Path("/api/meshobjects/meshthings/{thing}/paint", thing.String()), AnswersMeshObject: true}, body) + return c.api.Do[Response](ctx, meshapi.Call{Method: "POST", Path: meshapi.Path("/api/meshobjects/meshthings/{thing}/paint", thing.String()), AnswersMeshObject: true}, body) } type ListFilter struct { diff --git a/client/raw.go b/client/raw.go index a485634b..c1698160 100644 --- a/client/raw.go +++ b/client/raw.go @@ -11,6 +11,7 @@ import ( "uuid" "github.com/meshcloud/meshstack-cli/client/internal" + "github.com/meshcloud/meshstack-cli/client/meshapi" "github.com/meshcloud/meshstack-cli/internal/http" ) @@ -85,8 +86,9 @@ func (r *RawClient) with[M any](meshObject internal.MeshObjectClient[M]) *RawCli } // withApi is with for a generated client. -func (r *RawClient) withApi[M any](api internal.Api) *RawClient { - r.apis[reflect.TypeFor[M]()] = rawApi{MeshObjectApi: api.MeshObjectApi(), sortsByCreatedAt: true} +func (r *RawClient) withApi[M any](api meshapi.Api) *RawClient { + meshObject := internal.MeshObjectApi{AuthorizedClient: api.AuthorizedClient, Kind: api.Kind, ApiVersion: api.ApiVersion, ApiUrl: api.EndpointUrl.JoinPath(api.Path)} + r.apis[reflect.TypeFor[M]()] = rawApi{MeshObjectApi: meshObject, sortsByCreatedAt: true} return r } From 24e95add5f9d30a7dc0861a7eaa6d7d485785b33 Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Thu, 22 Oct 2026 19:20:44 +0200 Subject: [PATCH 12/18] feat(client): mark deprecated API versions and name path parameters after their key A generated package, type or method of a deprecated version or operation carries a "Deprecated:" paragraph, which staticcheck reports at each use. A path parameter takes the name of the key it holds, as Get(ctx, uuid) or Get(ctx, name), and a template key such as {metadata.ownedByWorkspace}.{metadata.name} takes one parameter per field, which the client joins. The packages are regenerated from the latest spec. Co-Authored-By: Claude Opus 5.5 --- client/meshbuildingblock/v2/generated.go | 40 +++++-- client/meshbuildingblockrun/v1/generated.go | 12 +- client/meshobject/generated.go | 8 +- client/meshworkspace/v2/generated.go | 18 ++- client/openapi/generator/model.go | 17 +-- client/openapi/generator/operations.go | 112 +++++++++++++++++- client/openapi/generator/render.go | 72 +++++++++-- .../golden/meshother/v1/generated.go.golden | 11 +- .../golden/meshthing/v2/generated.go.golden | 21 +++- client/openapi/generator/testdata/spec.json | 14 ++- 10 files changed, 265 insertions(+), 60 deletions(-) diff --git a/client/meshbuildingblock/v2/generated.go b/client/meshbuildingblock/v2/generated.go index 559d4c4b..b4f8ef35 100644 --- a/client/meshbuildingblock/v2/generated.go +++ b/client/meshbuildingblock/v2/generated.go @@ -50,14 +50,18 @@ func (c Client) Create(ctx context.Context, body Request) (Response, error) { } // Delete building block -func (c Client) Delete(ctx context.Context, buildingBlock uuid.UUID) error { - _, err := c.api.Do[any](ctx, meshapi.Call{Method: "DELETE", Path: meshapi.Path("/api/meshobjects/meshbuildingblocks/{buildingBlock}", buildingBlock.String())}, nil) +// +// uuid is the UUID of the meshBuildingBlock. +func (c Client) Delete(ctx context.Context, uuid uuid.UUID) error { + _, err := c.api.Do[any](ctx, meshapi.Call{Method: "DELETE", Path: meshapi.Path("/api/meshobjects/meshbuildingblocks/{buildingBlock}", uuid.String())}, nil) return err } // Get building block -func (c Client) Get(ctx context.Context, buildingBlock uuid.UUID) (Response, error) { - return c.api.Do[Response](ctx, meshapi.Call{Method: "GET", Path: meshapi.Path("/api/meshobjects/meshbuildingblocks/{buildingBlock}", buildingBlock.String()), AnswersMeshObject: true}, nil) +// +// uuid is the UUID of the meshBuildingBlock. +func (c Client) Get(ctx context.Context, uuid uuid.UUID) (Response, error) { + return c.api.Do[Response](ctx, meshapi.Call{Method: "GET", Path: meshapi.Path("/api/meshobjects/meshbuildingblocks/{buildingBlock}", uuid.String()), AnswersMeshObject: true}, nil) } // List building blocks @@ -66,19 +70,25 @@ func (c Client) List(ctx context.Context, filter ListFilter) iter.Seq2[Response, } // Purge building block -func (c Client) Purge(ctx context.Context, buildingBlock uuid.UUID) error { - _, err := c.api.Do[any](ctx, meshapi.Call{Method: "DELETE", Path: meshapi.Path("/api/meshobjects/meshbuildingblocks/{buildingBlock}/purge", buildingBlock.String())}, nil) +// +// uuid is the UUID of the meshBuildingBlock. +func (c Client) Purge(ctx context.Context, uuid uuid.UUID) error { + _, err := c.api.Do[any](ctx, meshapi.Call{Method: "DELETE", Path: meshapi.Path("/api/meshobjects/meshbuildingblocks/{buildingBlock}/purge", uuid.String())}, nil) return err } // Trigger run of building block -func (c Client) TriggerRun(ctx context.Context, buildingBlock uuid.UUID, body TriggerRunRequest) (Response, error) { - return c.api.Do[Response](ctx, meshapi.Call{Method: "POST", Path: meshapi.Path("/api/meshobjects/meshbuildingblocks/{buildingBlock}/trigger-run", buildingBlock.String()), AnswersMeshObject: true}, body) +// +// uuid is the UUID of the meshBuildingBlock. +func (c Client) TriggerRun(ctx context.Context, uuid uuid.UUID, body TriggerRunRequest) (Response, error) { + return c.api.Do[Response](ctx, meshapi.Call{Method: "POST", Path: meshapi.Path("/api/meshobjects/meshbuildingblocks/{buildingBlock}/trigger-run", uuid.String()), AnswersMeshObject: true}, body) } // Update building block -func (c Client) Update(ctx context.Context, buildingBlock uuid.UUID, body Request) (Response, error) { - return c.api.Do[Response](ctx, meshapi.Call{Method: "PUT", Path: meshapi.Path("/api/meshobjects/meshbuildingblocks/{buildingBlock}", buildingBlock.String()), SendsMeshObject: true, AnswersMeshObject: true}, body) +// +// uuid is the UUID of the meshBuildingBlock. +func (c Client) Update(ctx context.Context, uuid uuid.UUID, body Request) (Response, error) { + return c.api.Do[Response](ctx, meshapi.Call{Method: "PUT", Path: meshapi.Path("/api/meshobjects/meshbuildingblocks/{buildingBlock}", uuid.String()), SendsMeshObject: true, AnswersMeshObject: true}, body) } type ListFilter struct { @@ -361,8 +371,10 @@ type SpecRequest struct { // value is returned as its hash only, because meshStack does not store the plaintext. Inputs map[string]InputRequest `json:"inputs"` // (Deprecated: Use parentBuildingBlockRefs instead) List of parent Building Blocks, which are required for the Building Block. + // Each parent must belong to the same workspace as the Building Block, on any of its tenants or on the workspace itself. ParentBuildingBlocks []MeshParentBuildingBlock `json:"parentBuildingBlocks,omitzero"` // List of references to the parent Building Blocks of this Building Block. Each entry contains a uuid and kind. + // Each parent must belong to the same workspace as the Building Block, on any of its tenants or on the workspace itself. ParentBuildingBlockRefs []Ref `json:"parentBuildingBlockRefs,omitzero"` } @@ -379,8 +391,10 @@ type SpecResponse struct { // value is returned as its hash only, because meshStack does not store the plaintext. Inputs map[string]InputResponse `json:"inputs"` // (Deprecated: Use parentBuildingBlockRefs instead) List of parent Building Blocks, which are required for the Building Block. + // Each parent must belong to the same workspace as the Building Block, on any of its tenants or on the workspace itself. ParentBuildingBlocks []MeshParentBuildingBlock `json:"parentBuildingBlocks"` // List of references to the parent Building Blocks of this Building Block. Each entry contains a uuid and kind. + // Each parent must belong to the same workspace as the Building Block, on any of its tenants or on the workspace itself. ParentBuildingBlockRefs []Ref `json:"parentBuildingBlockRefs,omitzero"` } @@ -497,14 +511,16 @@ func (u *TargetRefRequest) UnmarshalJSONFrom(dec *jsontext.Decoder) error { } type TargetTenantRefRequest struct { - // The uuid of the tenant for this Building Block. Only for kind "meshTenant"; a workspace has no uuid, use `name`. + // The UUID of the meshTenant. Uuid *uuid.UUID `json:"uuid,omitzero"` - // The identifier of the workspace for this Building Block. Only for kind "meshWorkspace", where it replaces `uuid`. + // Rejected: a meshTenant target is referenced by its `uuid`. Name *string `json:"name,omitzero"` } type TargetWorkspaceRefRequest struct { + // The identifier of the meshWorkspace. Name *string `json:"name,omitzero"` + // Rejected: a meshWorkspace target is referenced by its identifier in `name`. Uuid *string `json:"uuid,omitzero"` } diff --git a/client/meshbuildingblockrun/v1/generated.go b/client/meshbuildingblockrun/v1/generated.go index 9c02dd95..9b80968d 100644 --- a/client/meshbuildingblockrun/v1/generated.go +++ b/client/meshbuildingblockrun/v1/generated.go @@ -44,8 +44,10 @@ func (c Client) Api() meshapi.Api { } // Get building block run -func (c Client) Get(ctx context.Context, buildingBlockRun uuid.UUID) (Response, error) { - return c.api.Do[Response](ctx, meshapi.Call{Method: "GET", Path: meshapi.Path("/api/meshobjects/meshbuildingblockruns/{buildingBlockRun}", buildingBlockRun.String()), AnswersMeshObject: true}, nil) +// +// uuid is the UUID of the meshBuildingBlockRun. +func (c Client) Get(ctx context.Context, uuid uuid.UUID) (Response, error) { + return c.api.Do[Response](ctx, meshapi.Call{Method: "GET", Path: meshapi.Path("/api/meshobjects/meshbuildingblockruns/{buildingBlockRun}", uuid.String()), AnswersMeshObject: true}, nil) } // List building block runs @@ -54,8 +56,10 @@ func (c Client) List(ctx context.Context, filter ListFilter) iter.Seq2[Response, } // Download logs of building block run -func (c Client) Logs(ctx context.Context, buildingBlockRun uuid.UUID) (LogsDTO, error) { - return c.api.Do[LogsDTO](ctx, meshapi.Call{Method: "GET", Path: meshapi.Path("/api/meshobjects/meshbuildingblockruns/{buildingBlockRun}/logs", buildingBlockRun.String())}, nil) +// +// uuid is the UUID of the meshBuildingBlockRun. +func (c Client) Logs(ctx context.Context, uuid uuid.UUID) (LogsDTO, error) { + return c.api.Do[LogsDTO](ctx, meshapi.Call{Method: "GET", Path: meshapi.Path("/api/meshobjects/meshbuildingblockruns/{buildingBlockRun}/logs", uuid.String())}, nil) } type ListFilter struct { diff --git a/client/meshobject/generated.go b/client/meshobject/generated.go index 02998aa9..93acee38 100644 --- a/client/meshobject/generated.go +++ b/client/meshobject/generated.go @@ -71,15 +71,13 @@ func (u *MeshBuildingBlockTargetRefResponse) UnmarshalJSONFrom(dec *jsontext.Dec } type MeshBuildingBlockTargetTenantRefResponse struct { - // The uuid of the tenant for this Building Block. Only for kind "meshTenant"; a workspace has no uuid, use `name`. + // The UUID of the meshTenant. Uuid uuid.UUID `json:"uuid"` - // The identifier of the workspace for this Building Block. Only for kind "meshWorkspace", where it replaces `uuid`. - Name *string `json:"name,omitzero"` } type MeshBuildingBlockTargetWorkspaceRefResponse struct { - Name string `json:"name"` - Uuid *uuid.UUID `json:"uuid,omitzero"` + // The identifier of the meshWorkspace. + Name string `json:"name"` } type MeshObjectAuthor struct { diff --git a/client/meshworkspace/v2/generated.go b/client/meshworkspace/v2/generated.go index 27110e7f..641b872f 100644 --- a/client/meshworkspace/v2/generated.go +++ b/client/meshworkspace/v2/generated.go @@ -44,14 +44,18 @@ func (c Client) Create(ctx context.Context, body Request) (Response, error) { } // Delete workspace -func (c Client) Delete(ctx context.Context, workspace string) error { - _, err := c.api.Do[any](ctx, meshapi.Call{Method: "DELETE", Path: meshapi.Path("/api/meshobjects/meshworkspaces/{workspace}", workspace)}, nil) +// +// name is the `metadata.name` of the meshWorkspace. +func (c Client) Delete(ctx context.Context, name string) error { + _, err := c.api.Do[any](ctx, meshapi.Call{Method: "DELETE", Path: meshapi.Path("/api/meshobjects/meshworkspaces/{workspace}", name)}, nil) return err } // Get workspace -func (c Client) Get(ctx context.Context, workspace string) (Response, error) { - return c.api.Do[Response](ctx, meshapi.Call{Method: "GET", Path: meshapi.Path("/api/meshobjects/meshworkspaces/{workspace}", workspace), AnswersMeshObject: true}, nil) +// +// name is the `metadata.name` of the meshWorkspace. +func (c Client) Get(ctx context.Context, name string) (Response, error) { + return c.api.Do[Response](ctx, meshapi.Call{Method: "GET", Path: meshapi.Path("/api/meshobjects/meshworkspaces/{workspace}", name), AnswersMeshObject: true}, nil) } // List workspaces @@ -60,8 +64,10 @@ func (c Client) List(ctx context.Context, filter ListFilter) iter.Seq2[Response, } // Create or update workspace -func (c Client) Update(ctx context.Context, workspace string, body Request) (Response, error) { - return c.api.Do[Response](ctx, meshapi.Call{Method: "PUT", Path: meshapi.Path("/api/meshobjects/meshworkspaces/{workspace}", workspace), SendsMeshObject: true, AnswersMeshObject: true}, body) +// +// name is the `metadata.name` of the meshWorkspace. +func (c Client) Update(ctx context.Context, name string, body Request) (Response, error) { + return c.api.Do[Response](ctx, meshapi.Call{Method: "PUT", Path: meshapi.Path("/api/meshobjects/meshworkspaces/{workspace}", name), SendsMeshObject: true, AnswersMeshObject: true}, body) } type ListFilter struct { diff --git a/client/openapi/generator/model.go b/client/openapi/generator/model.go index 2db08f1c..bb87a1a8 100644 --- a/client/openapi/generator/model.go +++ b/client/openapi/generator/model.go @@ -46,6 +46,7 @@ type pkg struct { // collection is the path the kind's objects are below, such as /api/meshobjects/meshworkspaces. collection string embeddedRel string + lifecycle *openapi.Lifecycle operations []*operation types []*decl names map[string]bool @@ -118,11 +119,14 @@ func (r typeRef) container() bool { } type operation struct { - name string - doc string - method string - pathTemplate string - pathParams []param + name string + doc string + method string + pathTemplate string + pathParams []param + // pathArgs fill the parameters of pathTemplate, in its order. + pathArgs [][]argPart + deprecation string queryType string query []param body *typeRef @@ -133,8 +137,7 @@ type operation struct { } type param struct { - name, goName, goType string - uuid bool + name, goName, goType, doc string } func newGenerator(spec openapi.Spec, cfg Config) *generator { diff --git a/client/openapi/generator/operations.go b/client/openapi/generator/operations.go index a72848fd..c7a6c05c 100644 --- a/client/openapi/generator/operations.go +++ b/client/openapi/generator/operations.go @@ -4,6 +4,7 @@ import ( "cmp" "errors" "fmt" + "go/token" "path" "slices" "strings" @@ -103,11 +104,22 @@ func (g *generator) operation(p *pkg, selected openapi.Operation) (*operation, e if err != nil { return nil, err } + if lifecycle := schemas.Lifecycle(p.mediaType); lifecycle != nil { + p.lifecycle = lifecycle + } + if selected.Deprecated { + doc, docErr := g.spec.Doc(selected) + if docErr != nil { + return nil, docErr + } + o.deprecation = cmp.Or(strings.TrimSpace(strings.TrimPrefix(doc.Description, "Deprecated:")), "This operation is deprecated.") + } for _, parameter := range schemas.Parameters { - if parameterErr := g.addParameter(o, parameter); parameterErr != nil { + if parameterErr := g.addParameter(p, o, parameter); parameterErr != nil { return nil, parameterErr } } + o.disambiguatePathParams() if len(o.query) > 0 { o.queryType = o.name + "Query" if o.list { @@ -149,7 +161,7 @@ func (g *generator) summary(operation openapi.Operation) (string, error) { return doc.Summary, err } -func (g *generator) addParameter(o *operation, parameter openapi.ParameterSchema) error { +func (g *generator) addParameter(p *pkg, o *operation, parameter openapi.ParameterSchema) error { if parameter.Schema == nil || parameter.In == "header" { return nil } @@ -159,11 +171,11 @@ func (g *generator) addParameter(o *operation, parameter openapi.ParameterSchema } switch parameter.In { case "path": - p := param{name: parameter.Name, goName: lowerFirst(goName(parameter.Name)), goType: "string"} - if resolved.Format == "uuid" { - p.goType, p.uuid = "uuid.UUID", true + key := parameter.PathKey.Key + if byVersion, ok := parameter.PathKey.ByVersion[p.ApiVersion]; ok { + key = byVersion } - o.pathParams = append(o.pathParams, p) + o.addPathParam(parameter, key, resolved.Format == "uuid") case "query": if parameter.Name == "page" || parameter.Name == "size" { return nil @@ -177,6 +189,94 @@ func (g *generator) addParameter(o *operation, parameter openapi.ParameterSchema return nil } +// addPathParam names a path parameter after the key it takes, as uuid or name. A template key with +// a name of as many parts, as {metadata.ownedByWorkspace}.{metadata.name} with workspace.project, +// takes one parameter per part, so that a caller passes the fields and the client writes the +// template. +func (o *operation) addPathParam(parameter openapi.ParameterSchema, key string, isUuid bool) { + add := func(name, goType, doc string) int { + o.pathParams = append(o.pathParams, param{name: parameter.Name, goName: argName(name), goType: goType, doc: doc}) + return len(o.pathParams) - 1 + } + names := strings.Split(parameter.PathKey.Name, ".") + if parts := templateParts(key); countFields(parts) == len(names) { + var arg []argPart + for _, part := range parts { + if part.field { + part.param = add(names[countFields(arg)], "string", parameter.Description) + } + arg = append(arg, part) + } + o.pathArgs = append(o.pathArgs, arg) + return + } + name := parameter.Name + if key != "" && !strings.Contains(key, "{") { + name = key[strings.LastIndex(key, ".")+1:] + } + goType := "string" + if isUuid { + goType = "uuid.UUID" + } + o.pathArgs = append(o.pathArgs, []argPart{{field: true, param: add(name, goType, parameter.Description)}}) +} + +func argName(name string) string { + name = lowerFirst(goName(name)) + if token.IsKeyword(name) { + return name + "Value" + } + return name +} + +// argPart is a literal of a key's template, or a field, which a parameter of the method fills. +type argPart struct { + text string + field bool + param int +} + +func templateParts(template string) []argPart { + var parts []argPart + for rest := template; rest != ""; { + before, after, found := strings.Cut(rest, "{") + if before != "" { + parts = append(parts, argPart{text: before}) + } + if !found { + break + } + field, after, _ := strings.Cut(after, "}") + parts = append(parts, argPart{text: field, field: true}) + rest = after + } + return parts +} + +func countFields(parts []argPart) int { + n := 0 + for _, part := range parts { + if part.field { + n++ + } + } + return n +} + +// disambiguatePathParams prefixes the name of the path parameter where two keys share a name, as +// workspaceName and userName. +func (o *operation) disambiguatePathParams() { + count := map[string]int{} + for _, p := range o.pathParams { + count[p.goName]++ + } + for i, p := range o.pathParams { + if count[p.goName] > 1 { + o.pathParams[i].goName = lowerFirst(goName(p.name)) + upperFirst(p.goName) + } + } +} + // queryType keeps an enum a string, because a query parameter is no type of the API's objects. func (g *generator) queryType(s *openapi.Schema) (string, error) { if s.Type == "array" && s.Items != nil { diff --git a/client/openapi/generator/render.go b/client/openapi/generator/render.go index b11a1b54..5e9f0b19 100644 --- a/client/openapi/generator/render.go +++ b/client/openapi/generator/render.go @@ -2,6 +2,7 @@ package generator import ( "bytes" + "cmp" _ "embed" "fmt" "go/format" @@ -86,7 +87,7 @@ func (g *generator) render(p *pkg) ([]byte, error) { EmbeddedRel: p.embeddedRel, } if p.Kind != "" { - data.Doc = fmt.Sprintf("Package %s is the meshObject kind %s in API version %s.", p.Name, p.Kind, p.ApiVersion) + data.Doc = withDeprecation(fmt.Sprintf("Package %s is the meshObject kind %s in API version %s.", p.Name, p.Kind, p.ApiVersion), p.deprecation()) } for _, o := range p.operations { data.Operations = append(data.Operations, r.operation(o)) @@ -146,14 +147,33 @@ func (r renderer) expr(t typeRef) string { func (r renderer) operation(o *operation) operationData { runtime := path.Base(r.g.cfg.Runtime) params := []string{"ctx context.Context"} - pathArgs := []string{strconv.Quote(o.pathTemplate)} for _, p := range o.pathParams { params = append(params, p.goName+" "+p.goType) - if p.uuid { + if p.goType == "uuid.UUID" { r.imports["uuid"] = true - pathArgs = append(pathArgs, p.goName+".String()") - } else { - pathArgs = append(pathArgs, p.goName) + } + } + docs := []string{o.doc} + pathArgs := []string{strconv.Quote(o.pathTemplate)} + for _, arg := range o.pathArgs { + var expr, names []string + var doc string + for _, part := range arg { + if !part.field { + expr = append(expr, strconv.Quote(part.text)) + continue + } + p := o.pathParams[part.param] + names, doc = append(names, p.goName), p.doc + if p.goType == "uuid.UUID" { + expr = append(expr, p.goName+".String()") + } else { + expr = append(expr, p.goName) + } + } + pathArgs = append(pathArgs, strings.Join(expr, " + ")) + if doc != "" { + docs = append(docs, paramDoc(names, doc)) } } call := []string{"Method: " + strconv.Quote(o.method)} @@ -170,7 +190,8 @@ func (r renderer) operation(o *operation) operationData { params = append(params, name+" "+o.queryType) call = append(call, "Query: "+name) } - data := operationData{Name: o.name, Doc: o.doc, List: o.list, Body: "nil", Returns: "error"} + doc := strings.Join(docs, "\n\n") + data := operationData{Name: o.name, Doc: withDeprecation(doc, cmp.Or(o.deprecation, r.p.deprecation())), List: o.list, Body: "nil", Returns: "error"} if o.body != nil { params = append(params, "body "+r.expr(*o.body)) data.Body = "body" @@ -196,7 +217,7 @@ func (r renderer) operation(o *operation) operationData { func (r renderer) typeData(d *decl) typeData { t := d.t - data := typeData{Name: d.name, Doc: t.schema.Description} + data := typeData{Name: d.name, Doc: withDeprecation(t.schema.Description, cmp.Or(t.deprecation(), d.home.deprecation()))} dir := d.dir if dir == 0 { dir = t.dirs()[len(t.dirs())-1] @@ -254,6 +275,41 @@ func plural(name string) string { return name + "s" } +// paramDoc reads "uuid is the UUID of the meshTenant." where the text allows it. +func paramDoc(names []string, text string) string { + if rest, ok := strings.CutPrefix(text, "The "); ok && len(names) == 1 { + return names[0] + " is the " + rest + } + return strings.Join(names, " and ") + ": " + text +} + +// withDeprecation adds the paragraph that Go tools such as staticcheck read. +func withDeprecation(doc, deprecation string) string { + if deprecation == "" { + return doc + } + return strings.TrimSpace(doc + "\n\nDeprecated: " + deprecation) +} + +// deprecation is empty for a package of no deprecated API version. +func (p *pkg) deprecation() string { + if p.lifecycle == nil { + return "" + } + return p.lifecycle.Deprecation() +} + +func (t *namedType) deprecation() string { + if !t.schema.Deprecated { + return "" + } + var ofVersion string + for _, version := range t.schema.Versions { + ofVersion = cmp.Or(ofVersion, version.Deprecation()) + } + return cmp.Or(t.schema.DeprecatedDescription, ofVersion, "This type is deprecated.") +} + func comment(text string) string { if text == "" { return "" diff --git a/client/openapi/generator/testdata/golden/meshother/v1/generated.go.golden b/client/openapi/generator/testdata/golden/meshother/v1/generated.go.golden index 3dbd25cf..3527e516 100644 --- a/client/openapi/generator/testdata/golden/meshother/v1/generated.go.golden +++ b/client/openapi/generator/testdata/golden/meshother/v1/generated.go.golden @@ -1,6 +1,8 @@ // Code generated by github.com/meshcloud/meshstack-cli/client/openapi/generator. DO NOT EDIT. // Package meshother is the meshObject kind meshOther in API version v1. +// +// Deprecated: `meshOther.v1` is outdated. Please update to `meshOther.v2`. package meshother import ( @@ -37,10 +39,15 @@ func (c Client) Api() meshapi.Api { } // Get other -func (c Client) Get(ctx context.Context, other string) (Response, error) { - return c.api.Do[Response](ctx, meshapi.Call{Method: "GET", Path: meshapi.Path("/api/meshobjects/meshothers/{other}", other), AnswersMeshObject: true}, nil) +// +// thing and other: The thing and the other, separated by '.'. +// +// Deprecated: `meshOther.v1` is outdated. Please update to `meshOther.v2`. +func (c Client) Get(ctx context.Context, thing string, other string) (Response, error) { + return c.api.Do[Response](ctx, meshapi.Call{Method: "GET", Path: meshapi.Path("/api/meshobjects/meshothers/{other}", thing+"."+other), AnswersMeshObject: true}, nil) } +// Deprecated: `meshOther.v1` is outdated. Please update to `meshOther.v2`. type Response struct { Owner meshobject.Owner `json:"owner"` Links meshobject.Links `json:"_links,omitzero"` diff --git a/client/openapi/generator/testdata/golden/meshthing/v2/generated.go.golden b/client/openapi/generator/testdata/golden/meshthing/v2/generated.go.golden index 9e099726..dbc8a4e3 100644 --- a/client/openapi/generator/testdata/golden/meshthing/v2/generated.go.golden +++ b/client/openapi/generator/testdata/golden/meshthing/v2/generated.go.golden @@ -47,14 +47,18 @@ func (c Client) Create(ctx context.Context, body Request) (Response, error) { } // Delete thing -func (c Client) Delete(ctx context.Context, thing uuid.UUID) error { - _, err := c.api.Do[any](ctx, meshapi.Call{Method: "DELETE", Path: meshapi.Path("/api/meshobjects/meshthings/{thing}", thing.String())}, nil) +// +// uuid is the UUID of the meshThing. +func (c Client) Delete(ctx context.Context, uuid uuid.UUID) error { + _, err := c.api.Do[any](ctx, meshapi.Call{Method: "DELETE", Path: meshapi.Path("/api/meshobjects/meshthings/{thing}", uuid.String())}, nil) return err } // Get thing -func (c Client) Get(ctx context.Context, thing uuid.UUID) (Response, error) { - return c.api.Do[Response](ctx, meshapi.Call{Method: "GET", Path: meshapi.Path("/api/meshobjects/meshthings/{thing}", thing.String()), AnswersMeshObject: true}, nil) +// +// uuid is the UUID of the meshThing. +func (c Client) Get(ctx context.Context, uuid uuid.UUID) (Response, error) { + return c.api.Do[Response](ctx, meshapi.Call{Method: "GET", Path: meshapi.Path("/api/meshobjects/meshthings/{thing}", uuid.String()), AnswersMeshObject: true}, nil) } // List things @@ -63,8 +67,12 @@ func (c Client) List(ctx context.Context, filter ListFilter) iter.Seq2[Response, } // Paint thing -func (c Client) Paint(ctx context.Context, thing uuid.UUID, body PaintRequest) (Response, error) { - return c.api.Do[Response](ctx, meshapi.Call{Method: "POST", Path: meshapi.Path("/api/meshobjects/meshthings/{thing}/paint", thing.String()), AnswersMeshObject: true}, body) +// +// uuid is the UUID of the meshThing. +// +// Deprecated: use `PUT /api/meshobjects/meshthings/{thing}`, which paints as well. +func (c Client) Paint(ctx context.Context, uuid uuid.UUID, body PaintRequest) (Response, error) { + return c.api.Do[Response](ctx, meshapi.Call{Method: "POST", Path: meshapi.Path("/api/meshobjects/meshthings/{thing}/paint", uuid.String()), AnswersMeshObject: true}, body) } type ListFilter struct { @@ -93,6 +101,7 @@ type Metadata struct { Owner *meshobject.Owner `json:"owner,omitzero"` } +// Deprecated: Paint with an update instead. type PaintRequest struct { DryRun *bool `json:"dryRun,omitzero"` } diff --git a/client/openapi/generator/testdata/spec.json b/client/openapi/generator/testdata/spec.json index 11b97264..5ad2edd1 100644 --- a/client/openapi/generator/testdata/spec.json +++ b/client/openapi/generator/testdata/spec.json @@ -40,6 +40,8 @@ "/api/meshobjects/meshthings/{thing}/paint": { "post": { "summary": "Paint thing", + "description": "Deprecated: use `PUT /api/meshobjects/meshthings/{thing}`, which paints as well.", + "deprecated": true, "operationId": "meshThingPaint", "parameters": [{"$ref": "#/components/parameters/thing"}], "requestBody": {"content": {"application/vnd.meshcloud.api.meshthing.v2.hal+json": {"schema": {"$ref": "#/components/schemas/MeshThingPaintRequest"}}}}, @@ -50,9 +52,9 @@ "get": { "summary": "Get other", "operationId": "meshOtherGet", - "parameters": [{"name": "other", "in": "path", "required": true, "schema": {"type": "string"}}], + "parameters": [{"name": "other", "in": "path", "required": true, "description": "The thing and the other, separated by '.'.", "schema": {"type": "string"}, "x-meshstack-path-key": "{metadata.ownedByThing}.{metadata.name}", "x-meshstack-path-key-name": "thing.other"}], "responses": { - "200": {"description": "OK", "content": {"application/vnd.meshcloud.api.meshother.v1.hal+json": {"schema": {"$ref": "#/components/schemas/HalEntityModelMeshOther"}}}} + "200": {"description": "OK", "content": {"application/vnd.meshcloud.api.meshother.v1.hal+json": {"schema": {"$ref": "#/components/schemas/HalEntityModelMeshOther"}, "x-meshstack-lifecycle": {"meshObject": "meshOther.v1", "state": "Outdated", "notice": "Please update to `meshOther.v2`."}}}} } } } @@ -60,10 +62,10 @@ "components": { "parameters": { "page": {"name": "page", "in": "query", "schema": {"type": "integer", "format": "int32"}}, - "thing": {"name": "thing", "in": "path", "required": true, "schema": {"$ref": "#/components/schemas/string_uuid"}} + "thing": {"name": "thing", "in": "path", "required": true, "description": "The UUID of the meshThing.", "schema": {"$ref": "#/components/schemas/string_uuid"}, "x-meshstack-path-key": "metadata.uuid"} }, "responses": { - "Thing": {"description": "The thing", "content": {"application/vnd.meshcloud.api.meshthing.v2.hal+json": {"schema": {"$ref": "#/components/schemas/HalEntityModelMeshThingV2"}}}}, + "Thing": {"description": "The thing", "content": {"application/vnd.meshcloud.api.meshthing.v2.hal+json": {"schema": {"$ref": "#/components/schemas/HalEntityModelMeshThingV2"}, "x-meshstack-lifecycle": {"meshObject": "meshThing.v2", "state": "GA"}}}}, "BadRequest": {"description": "Bad request", "content": {"application/json": {"schema": {"type": "object"}}}} }, "schemas": { @@ -148,6 +150,8 @@ }, "MeshThingPaintRequest": { "type": "object", + "deprecated": true, + "x-deprecated-description": "Paint with an update instead.", "properties": {"dryRun": {"type": "boolean"}} }, "Part": {"type": "object", "required": ["size"], "properties": {"size": {"type": "number"}}}, @@ -177,6 +181,8 @@ "Owner": {"type": "object", "required": ["name"], "properties": {"name": {"type": "string"}, "target": {"$ref": "#/components/schemas/TargetRef"}}}, "MeshOther": { "type": "object", + "deprecated": true, + "x-meshstack-versions": [{"meshObject": "meshOther.v1", "state": "Outdated", "notice": "Please update to `meshOther.v2`."}], "required": ["kind", "apiVersion", "owner"], "properties": { "kind": {"type": "string", "enum": ["meshOther"]}, From 5cd78c01219447c2ac2431c00b37b744e8b37ef7 Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Fri, 23 Oct 2026 13:50:58 +0200 Subject: [PATCH 13/18] feat(client): mark a deprecated version as removed after its end of life or as outdated The "Deprecated:" paragraph of a generated package, type or method reads "removed after ." for a version with an end of life, and "a newer version is available." otherwise. The API docs no longer carry x-deprecated-description or a "Deprecated:" description prefix. Co-Authored-By: Claude Opus 5.5 --- client/openapi/generator/operations.go | 6 +----- client/openapi/generator/render.go | 4 +++- .../golden/meshother/v1/generated.go.golden | 6 +++--- .../golden/meshthing/v2/generated.go.golden | 4 ++-- client/openapi/generator/testdata/spec.json | 6 ++---- client/openapi/operation.go | 20 ++++++++----------- client/openapi/schema.go | 4 +--- 7 files changed, 20 insertions(+), 30 deletions(-) diff --git a/client/openapi/generator/operations.go b/client/openapi/generator/operations.go index c7a6c05c..eb33883c 100644 --- a/client/openapi/generator/operations.go +++ b/client/openapi/generator/operations.go @@ -108,11 +108,7 @@ func (g *generator) operation(p *pkg, selected openapi.Operation) (*operation, e p.lifecycle = lifecycle } if selected.Deprecated { - doc, docErr := g.spec.Doc(selected) - if docErr != nil { - return nil, docErr - } - o.deprecation = cmp.Or(strings.TrimSpace(strings.TrimPrefix(doc.Description, "Deprecated:")), "This operation is deprecated.") + o.deprecation = cmp.Or(p.deprecation(), openapi.NewerVersionAvailable) } for _, parameter := range schemas.Parameters { if parameterErr := g.addParameter(p, o, parameter); parameterErr != nil { diff --git a/client/openapi/generator/render.go b/client/openapi/generator/render.go index 5e9f0b19..5faffc00 100644 --- a/client/openapi/generator/render.go +++ b/client/openapi/generator/render.go @@ -12,6 +12,8 @@ import ( "strconv" "strings" "text/template" + + "github.com/meshcloud/meshstack-cli/client/openapi" ) //go:embed generated.go.tmpl @@ -307,7 +309,7 @@ func (t *namedType) deprecation() string { for _, version := range t.schema.Versions { ofVersion = cmp.Or(ofVersion, version.Deprecation()) } - return cmp.Or(t.schema.DeprecatedDescription, ofVersion, "This type is deprecated.") + return cmp.Or(ofVersion, openapi.NewerVersionAvailable) } func comment(text string) string { diff --git a/client/openapi/generator/testdata/golden/meshother/v1/generated.go.golden b/client/openapi/generator/testdata/golden/meshother/v1/generated.go.golden index 3527e516..3038102b 100644 --- a/client/openapi/generator/testdata/golden/meshother/v1/generated.go.golden +++ b/client/openapi/generator/testdata/golden/meshother/v1/generated.go.golden @@ -2,7 +2,7 @@ // Package meshother is the meshObject kind meshOther in API version v1. // -// Deprecated: `meshOther.v1` is outdated. Please update to `meshOther.v2`. +// Deprecated: removed after 2025-07-01. package meshother import ( @@ -42,12 +42,12 @@ func (c Client) Api() meshapi.Api { // // thing and other: The thing and the other, separated by '.'. // -// Deprecated: `meshOther.v1` is outdated. Please update to `meshOther.v2`. +// Deprecated: removed after 2025-07-01. func (c Client) Get(ctx context.Context, thing string, other string) (Response, error) { return c.api.Do[Response](ctx, meshapi.Call{Method: "GET", Path: meshapi.Path("/api/meshobjects/meshothers/{other}", thing+"."+other), AnswersMeshObject: true}, nil) } -// Deprecated: `meshOther.v1` is outdated. Please update to `meshOther.v2`. +// Deprecated: removed after 2025-07-01. type Response struct { Owner meshobject.Owner `json:"owner"` Links meshobject.Links `json:"_links,omitzero"` diff --git a/client/openapi/generator/testdata/golden/meshthing/v2/generated.go.golden b/client/openapi/generator/testdata/golden/meshthing/v2/generated.go.golden index dbc8a4e3..a25ea12f 100644 --- a/client/openapi/generator/testdata/golden/meshthing/v2/generated.go.golden +++ b/client/openapi/generator/testdata/golden/meshthing/v2/generated.go.golden @@ -70,7 +70,7 @@ func (c Client) List(ctx context.Context, filter ListFilter) iter.Seq2[Response, // // uuid is the UUID of the meshThing. // -// Deprecated: use `PUT /api/meshobjects/meshthings/{thing}`, which paints as well. +// Deprecated: a newer version is available. func (c Client) Paint(ctx context.Context, uuid uuid.UUID, body PaintRequest) (Response, error) { return c.api.Do[Response](ctx, meshapi.Call{Method: "POST", Path: meshapi.Path("/api/meshobjects/meshthings/{thing}/paint", uuid.String()), AnswersMeshObject: true}, body) } @@ -101,7 +101,7 @@ type Metadata struct { Owner *meshobject.Owner `json:"owner,omitzero"` } -// Deprecated: Paint with an update instead. +// Deprecated: a newer version is available. type PaintRequest struct { DryRun *bool `json:"dryRun,omitzero"` } diff --git a/client/openapi/generator/testdata/spec.json b/client/openapi/generator/testdata/spec.json index 5ad2edd1..35b9e071 100644 --- a/client/openapi/generator/testdata/spec.json +++ b/client/openapi/generator/testdata/spec.json @@ -40,7 +40,6 @@ "/api/meshobjects/meshthings/{thing}/paint": { "post": { "summary": "Paint thing", - "description": "Deprecated: use `PUT /api/meshobjects/meshthings/{thing}`, which paints as well.", "deprecated": true, "operationId": "meshThingPaint", "parameters": [{"$ref": "#/components/parameters/thing"}], @@ -54,7 +53,7 @@ "operationId": "meshOtherGet", "parameters": [{"name": "other", "in": "path", "required": true, "description": "The thing and the other, separated by '.'.", "schema": {"type": "string"}, "x-meshstack-path-key": "{metadata.ownedByThing}.{metadata.name}", "x-meshstack-path-key-name": "thing.other"}], "responses": { - "200": {"description": "OK", "content": {"application/vnd.meshcloud.api.meshother.v1.hal+json": {"schema": {"$ref": "#/components/schemas/HalEntityModelMeshOther"}, "x-meshstack-lifecycle": {"meshObject": "meshOther.v1", "state": "Outdated", "notice": "Please update to `meshOther.v2`."}}}} + "200": {"description": "OK", "content": {"application/vnd.meshcloud.api.meshother.v1.hal+json": {"schema": {"$ref": "#/components/schemas/HalEntityModelMeshOther"}, "x-meshstack-lifecycle": {"meshObject": "meshOther.v1", "state": "Deprecated", "retiredAfter": "2025-07-01", "latest": "meshOther.v2", "notice": "Please update to `meshOther.v2`."}}}} } } } @@ -151,7 +150,6 @@ "MeshThingPaintRequest": { "type": "object", "deprecated": true, - "x-deprecated-description": "Paint with an update instead.", "properties": {"dryRun": {"type": "boolean"}} }, "Part": {"type": "object", "required": ["size"], "properties": {"size": {"type": "number"}}}, @@ -182,7 +180,7 @@ "MeshOther": { "type": "object", "deprecated": true, - "x-meshstack-versions": [{"meshObject": "meshOther.v1", "state": "Outdated", "notice": "Please update to `meshOther.v2`."}], + "x-meshstack-versions": [{"meshObject": "meshOther.v1", "state": "Deprecated", "retiredAfter": "2025-07-01", "latest": "meshOther.v2", "notice": "Please update to `meshOther.v2`."}], "required": ["kind", "apiVersion", "owner"], "properties": { "kind": {"type": "string", "enum": ["meshOther"]}, diff --git a/client/openapi/operation.go b/client/openapi/operation.go index 3799ca8d..38ba1458 100644 --- a/client/openapi/operation.go +++ b/client/openapi/operation.go @@ -5,7 +5,6 @@ import ( "encoding/json/v2" "fmt" "slices" - "strings" ) // OperationSchemas are the parameters and bodies of an operation, with their schemas. @@ -50,21 +49,18 @@ type Lifecycle struct { // Deprecation is empty for a version that is not deprecated. func (l Lifecycle) Deprecation() string { - var text string - switch l.State { - case "Outdated": - text = "`" + l.MeshObject + "` is outdated." - case "Deprecated": - text = "`" + l.MeshObject + "` is deprecated." - if l.RetiredAfter != "" { - text = "`" + l.MeshObject + "` is deprecated and retired after " + l.RetiredAfter + "." - } - default: + switch { + case l.State != "Outdated" && l.State != "Deprecated" && l.State != "Retired": return "" + case l.RetiredAfter != "": + return "removed after " + l.RetiredAfter + "." } - return strings.TrimSpace(text + " " + l.Notice) + return NewerVersionAvailable } +// NewerVersionAvailable is the deprecation of a version without an end of life. +const NewerVersionAvailable = "a newer version is available." + // Lifecycle is that of the media type in the request body or a response, or nil. func (s OperationSchemas) Lifecycle(mediaType string) *Lifecycle { bodies := slices.Clone(s.RequestBody) diff --git a/client/openapi/schema.go b/client/openapi/schema.go index d0bce498..61d98e9c 100644 --- a/client/openapi/schema.go +++ b/client/openapi/schema.go @@ -27,9 +27,7 @@ type Schema struct { ReadOnly bool `json:"readOnly"` WriteOnly bool `json:"writeOnly"` Deprecated bool `json:"deprecated"` - // DeprecatedDescription says why a Deprecated schema is, where meshStack states it. - DeprecatedDescription string `json:"x-deprecated-description"` - Versions []Lifecycle `json:"x-meshstack-versions"` + Versions []Lifecycle `json:"x-meshstack-versions"` } type Discriminator struct { From 99d37f32da2f19fa1ec1d42d3770dc40a7eac893 Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Fri, 23 Oct 2026 13:51:07 +0200 Subject: [PATCH 14/18] feat(client): type the query parameters of generated clients by their schema A query parameter of format uuid is a uuid.UUID, and an enum is its typed string with constants: a component's own type, or one the package names after the filter and the parameter, such as ListFilterSort. Every field is omitzero, so a zero UUID stays out of the query. Co-Authored-By: Claude Opus 5.5 --- client/openapi/generator/model.go | 1 + client/openapi/generator/operations.go | 53 ++++++++++--------- client/openapi/generator/render.go | 2 +- .../golden/meshthing/v2/generated.go.golden | 14 ++++- client/openapi/generator/testdata/spec.json | 5 +- 5 files changed, 45 insertions(+), 30 deletions(-) diff --git a/client/openapi/generator/model.go b/client/openapi/generator/model.go index bb87a1a8..324b4496 100644 --- a/client/openapi/generator/model.go +++ b/client/openapi/generator/model.go @@ -138,6 +138,7 @@ type operation struct { type param struct { name, goName, goType, doc string + ref typeRef } func newGenerator(spec openapi.Spec, cfg Config) *generator { diff --git a/client/openapi/generator/operations.go b/client/openapi/generator/operations.go index eb33883c..c688f1d7 100644 --- a/client/openapi/generator/operations.go +++ b/client/openapi/generator/operations.go @@ -110,17 +110,18 @@ func (g *generator) operation(p *pkg, selected openapi.Operation) (*operation, e if selected.Deprecated { o.deprecation = cmp.Or(p.deprecation(), openapi.NewerVersionAvailable) } + o.queryType = o.name + "Query" + if o.list { + o.queryType = "ListFilter" + } for _, parameter := range schemas.Parameters { if parameterErr := g.addParameter(p, o, parameter); parameterErr != nil { return nil, parameterErr } } o.disambiguatePathParams() - if len(o.query) > 0 { - o.queryType = o.name + "Query" - if o.list { - o.queryType = "ListFilter" - } + if len(o.query) == 0 { + o.queryType = "" } if body := p.content(schemas.RequestBody); body != nil { r, sendsMeshObject, bodyErr := g.root(p, body, request) @@ -176,11 +177,11 @@ func (g *generator) addParameter(p *pkg, o *operation, parameter openapi.Paramet if parameter.Name == "page" || parameter.Name == "size" { return nil } - goType, err := g.queryType(resolved) + r, err := g.queryRef(p, Location{Schema: o.queryType, Property: parameter.Name}, parameter.Schema) if err != nil { return fmt.Errorf("query parameter %s: %w", parameter.Name, err) } - o.query = append(o.query, param{name: parameter.Name, goName: goName(parameter.Name), goType: goType}) + o.query = append(o.query, param{name: parameter.Name, goName: goName(parameter.Name), ref: r}) } return nil } @@ -273,25 +274,27 @@ func (o *operation) disambiguatePathParams() { } } -// queryType keeps an enum a string, because a query parameter is no type of the API's objects. -func (g *generator) queryType(s *openapi.Schema) (string, error) { - if s.Type == "array" && s.Items != nil { - items, err := g.resolve(s.Items) - if err != nil { - return "", err +// queryRef keys an inline enum by the package, since the ListFilter of every kind names its enums +// alike. +func (g *generator) queryRef(p *pkg, at Location, s *openapi.Schema) (typeRef, error) { + resolved, err := g.resolve(s) + if err != nil { + return typeRef{}, err + } + switch { + case resolved.Type == "array" && resolved.Items != nil: + items, err := g.queryRef(p, at, resolved.Items) + return typeRef{slice: &items}, err + case len(resolved.Enum) > 0 && s.Ref == "": + key := "#" + p.importPath + "#" + at.String() + t, ok := g.types[key] + if !ok { + t = &namedType{loc: at, schema: resolved, kind: enumType} + g.types[key] = t } - element, err := g.queryType(items) - return "[]" + element, err - } - switch s.Type { - case "integer": - return "int64", nil - case "boolean": - return "bool", nil - case "number": - return "float64", nil - } - return "string", nil + return typeRef{named: t, dir: request}, g.visit(p, t, request) + } + return g.ref(p, s, request, at) } // content is the schema of the package's media type, or of the one media type an operation of no diff --git a/client/openapi/generator/render.go b/client/openapi/generator/render.go index 5faffc00..fdb3bd93 100644 --- a/client/openapi/generator/render.go +++ b/client/openapi/generator/render.go @@ -96,7 +96,7 @@ func (g *generator) render(p *pkg) ([]byte, error) { if o.queryType != "" { query := typeData{Name: o.queryType, Kind: "object"} for _, q := range o.query { - query.Fields = append(query.Fields, fieldData{Name: q.goName, Type: q.goType, Tag: fmt.Sprintf("`json:%q`", q.name)}) + query.Fields = append(query.Fields, fieldData{Name: q.goName, Type: r.expr(q.ref), Tag: fmt.Sprintf("`json:%q`", q.name+",omitzero")}) } data.Queries = append(data.Queries, query) } diff --git a/client/openapi/generator/testdata/golden/meshthing/v2/generated.go.golden b/client/openapi/generator/testdata/golden/meshthing/v2/generated.go.golden index a25ea12f..34d9dd2e 100644 --- a/client/openapi/generator/testdata/golden/meshthing/v2/generated.go.golden +++ b/client/openapi/generator/testdata/golden/meshthing/v2/generated.go.golden @@ -76,8 +76,9 @@ func (c Client) Paint(ctx context.Context, uuid uuid.UUID, body PaintRequest) (R } type ListFilter struct { - Sort []string `json:"sort"` - OwnerUuid string `json:"ownerUuid"` + Sort []ListFilterSort `json:"sort,omitzero"` + OwnerUuid uuid.UUID `json:"ownerUuid,omitzero"` + TargetKind meshobject.TargetKind `json:"targetKind,omitzero"` } type AwsConfig struct { @@ -94,6 +95,15 @@ type GcpConfig struct { Project string `json:"project"` } +type ListFilterSort string + +const ( + ListFilterSortCreatedAt ListFilterSort = "createdAt" + ListFilterSortCreatedAtDesc ListFilterSort = "createdAt,desc" +) + +var ListFilterSorts = enum.Of[ListFilterSort]("createdAt", "createdAt,desc") + type Metadata struct { Uuid uuid.UUID `json:"uuid"` CreatedOn time.Time `json:"createdOn"` diff --git a/client/openapi/generator/testdata/spec.json b/client/openapi/generator/testdata/spec.json index 35b9e071..84a8e663 100644 --- a/client/openapi/generator/testdata/spec.json +++ b/client/openapi/generator/testdata/spec.json @@ -7,8 +7,9 @@ "operationId": "meshThingList", "parameters": [ {"$ref": "#/components/parameters/page"}, - {"name": "sort", "in": "query", "schema": {"$ref": "#/components/schemas/array_string_0"}}, - {"name": "ownerUuid", "in": "query", "schema": {"type": "string", "format": "uuid"}} + {"name": "sort", "in": "query", "schema": {"type": "array", "items": {"type": "string", "enum": ["createdAt", "createdAt,desc"]}}}, + {"name": "ownerUuid", "in": "query", "schema": {"type": "string", "format": "uuid"}}, + {"name": "targetKind", "in": "query", "schema": {"$ref": "#/components/schemas/TargetKind"}} ], "responses": { "200": {"description": "OK", "content": {"application/vnd.meshcloud.api.meshthing.v2.hal+json": {"schema": {"$ref": "#/components/schemas/HalPageMeshThingV2"}}}} From 71c8b7c85e51a4f98dc330049101f4a409206b23 Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Fri, 23 Oct 2026 13:51:15 +0200 Subject: [PATCH 15/18] feat(client): take typed filters in the generated clients The ListFilter of meshworkspace/v2, meshbuildingblock/v2 and meshbuildingblockrun/v1 takes UUIDs as uuid.UUID and enum values as typed constants, such as the sort keys a list accepts. Co-Authored-By: Claude Opus 5.5 --- client/meshbuildingblock/v2/generated.go | 64 +++++++++++++++------ client/meshbuildingblockrun/v1/generated.go | 14 ++++- client/meshworkspace/v2/generated.go | 13 ++++- cmd/buildingblockrun/list.go | 4 +- 4 files changed, 72 insertions(+), 23 deletions(-) diff --git a/client/meshbuildingblock/v2/generated.go b/client/meshbuildingblock/v2/generated.go index b4f8ef35..905870a1 100644 --- a/client/meshbuildingblock/v2/generated.go +++ b/client/meshbuildingblock/v2/generated.go @@ -92,24 +92,24 @@ func (c Client) Update(ctx context.Context, uuid uuid.UUID, body Request) (Respo } type ListFilter struct { - WorkspaceIdentifier string `json:"workspaceIdentifier"` - CustomerIdentifier string `json:"customerIdentifier"` - ProjectIdentifier string `json:"projectIdentifier"` - PlatformIdentifier string `json:"platformIdentifier"` - Name string `json:"name"` - DefinitionName string `json:"definitionName"` - DefinitionUuid string `json:"definitionUuid"` - VersionNumber string `json:"versionNumber"` - Status string `json:"status"` - Sort []string `json:"sort"` - VersionUuid string `json:"versionUuid"` - TenantUuid string `json:"tenantUuid"` - TargetRefKind string `json:"targetRefKind"` - HealthStatus []string `json:"healthStatus"` - Action []string `json:"action"` - ManagedByWorkspaceIdentifier string `json:"managedByWorkspaceIdentifier"` - ManagedByDefinitionUuid string `json:"managedByDefinitionUuid"` - LifecycleState []string `json:"lifecycleState"` + WorkspaceIdentifier string `json:"workspaceIdentifier,omitzero"` + CustomerIdentifier string `json:"customerIdentifier,omitzero"` + ProjectIdentifier string `json:"projectIdentifier,omitzero"` + PlatformIdentifier string `json:"platformIdentifier,omitzero"` + Name string `json:"name,omitzero"` + DefinitionName string `json:"definitionName,omitzero"` + DefinitionUuid string `json:"definitionUuid,omitzero"` + VersionNumber string `json:"versionNumber,omitzero"` + Status StatusValue `json:"status,omitzero"` + Sort []ListFilterSort `json:"sort,omitzero"` + VersionUuid uuid.UUID `json:"versionUuid,omitzero"` + TenantUuid uuid.UUID `json:"tenantUuid,omitzero"` + TargetRefKind meshobject.MeshBuildingBlockTargetRefDiscriminator `json:"targetRefKind,omitzero"` + HealthStatus []HealthStatusValue `json:"healthStatus,omitzero"` + Action []ActionValue `json:"action,omitzero"` + ManagedByWorkspaceIdentifier string `json:"managedByWorkspaceIdentifier,omitzero"` + ManagedByDefinitionUuid uuid.UUID `json:"managedByDefinitionUuid,omitzero"` + LifecycleState []ListFilterLifecycleState `json:"lifecycleState,omitzero"` } type ActionValue string @@ -261,6 +261,34 @@ type InputResponse struct { IsOptional bool `json:"isOptional"` } +type ListFilterLifecycleState string + +const ( + ListFilterLifecycleStateActive ListFilterLifecycleState = "ACTIVE" + ListFilterLifecycleStateDeleted ListFilterLifecycleState = "DELETED" +) + +var ListFilterLifecycleStates = enum.Of[ListFilterLifecycleState]("ACTIVE", "DELETED") + +type ListFilterSort string + +const ( + ListFilterSortCreatedAt ListFilterSort = "createdAt" + ListFilterSortCreatedAtAsc ListFilterSort = "createdAt,asc" + ListFilterSortCreatedAtDesc ListFilterSort = "createdAt,desc" + ListFilterSortName ListFilterSort = "name" + ListFilterSortNameAsc ListFilterSort = "name,asc" + ListFilterSortNameDesc ListFilterSort = "name,desc" + ListFilterSortStatus ListFilterSort = "status" + ListFilterSortStatusAsc ListFilterSort = "status,asc" + ListFilterSortStatusDesc ListFilterSort = "status,desc" + ListFilterSortUuid ListFilterSort = "uuid" + ListFilterSortUuidAsc ListFilterSort = "uuid,asc" + ListFilterSortUuidDesc ListFilterSort = "uuid,desc" +) + +var ListFilterSorts = enum.Of[ListFilterSort]("createdAt", "createdAt,asc", "createdAt,desc", "name", "name,asc", "name,desc", "status", "status,asc", "status,desc", "uuid", "uuid,asc", "uuid,desc") + type MeshLifecycle struct { // The lifecycle state of this meshObject. One of ACTIVE, DEACTIVATED, MARKED_FOR_DELETION, DELETED. State MeshLifecycleState `json:"state"` diff --git a/client/meshbuildingblockrun/v1/generated.go b/client/meshbuildingblockrun/v1/generated.go index 9b80968d..4fb2b218 100644 --- a/client/meshbuildingblockrun/v1/generated.go +++ b/client/meshbuildingblockrun/v1/generated.go @@ -63,8 +63,8 @@ func (c Client) Logs(ctx context.Context, uuid uuid.UUID) (LogsDTO, error) { } type ListFilter struct { - BuildingBlockUuid string `json:"buildingBlockUuid"` - Sort []string `json:"sort"` + BuildingBlockUuid uuid.UUID `json:"buildingBlockUuid,omitzero"` + Sort []ListFilterSort `json:"sort,omitzero"` } type Behavior string @@ -203,6 +203,16 @@ type KnownHostEntry struct { KeyValue string `json:"keyValue"` } +type ListFilterSort string + +const ( + ListFilterSortCreatedAt ListFilterSort = "createdAt" + ListFilterSortCreatedAtAsc ListFilterSort = "createdAt,asc" + ListFilterSortCreatedAtDesc ListFilterSort = "createdAt,desc" +) + +var ListFilterSorts = enum.Of[ListFilterSort]("createdAt", "createdAt,asc", "createdAt,desc") + type LogsDTO struct { // List of steps in the building block run. Steps []StepLogDTO `json:"steps"` diff --git a/client/meshworkspace/v2/generated.go b/client/meshworkspace/v2/generated.go index 641b872f..8ce48727 100644 --- a/client/meshworkspace/v2/generated.go +++ b/client/meshworkspace/v2/generated.go @@ -10,6 +10,7 @@ import ( "github.com/meshcloud/meshstack-cli/client/meshapi" "github.com/meshcloud/meshstack-cli/client/meshobject" + "github.com/meshcloud/meshstack-cli/client/types/enum" ) const ( @@ -71,9 +72,19 @@ func (c Client) Update(ctx context.Context, name string, body Request) (Response } type ListFilter struct { - Sort []string `json:"sort"` + Sort []ListFilterSort `json:"sort,omitzero"` } +type ListFilterSort string + +const ( + ListFilterSortCreatedAt ListFilterSort = "createdAt" + ListFilterSortCreatedAtAsc ListFilterSort = "createdAt,asc" + ListFilterSortCreatedAtDesc ListFilterSort = "createdAt,desc" +) + +var ListFilterSorts = enum.Of[ListFilterSort]("createdAt", "createdAt,asc", "createdAt,desc") + type MetadataRequest struct { // The workspaceIdentifier as `name` to uniquely identify the meshWorkspace (read-only after creation). Name string `json:"name"` diff --git a/cmd/buildingblockrun/list.go b/cmd/buildingblockrun/list.go index 679273aa..f813c1a8 100644 --- a/cmd/buildingblockrun/list.go +++ b/cmd/buildingblockrun/list.go @@ -38,7 +38,7 @@ credential can see.`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { if buildingBlockUuid != uuid.Nil() { - return flags.Run[meshbuildingblockrun.Response](cmd, meshbuildingblockrun.ListFilter{BuildingBlockUuid: buildingBlockUuid.String()}) + return flags.Run[meshbuildingblockrun.Response](cmd, meshbuildingblockrun.ListFilter{BuildingBlockUuid: buildingBlockUuid}) } workspace, err := internal.ListWorkspace(cmd.Context()) if err != nil { @@ -102,7 +102,7 @@ func allRuns(ctx context.Context, meshStack client.Client, blockFilter meshbuild func runsOf(ctx context.Context, meshStack client.Client, buildingBlockUuid uuid.UUID, pageSize int) iter.Seq2[jsontext.Value, error] { return func(yield func(jsontext.Value, error) bool) { - filter := meshbuildingblockrun.ListFilter{BuildingBlockUuid: buildingBlockUuid.String()} + filter := meshbuildingblockrun.ListFilter{BuildingBlockUuid: buildingBlockUuid} for run, err := range meshStack.Raw.List[meshbuildingblockrun.Response](ctx, filter, client.ListOptions{PageSize: pageSize}) { if err != nil { err = fmt.Errorf("listing the runs of building block %s: %w", buildingBlockUuid, err) From ae3276ce316a657aea8f9d9d456dac63f31beb90 Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Sun, 25 Oct 2026 22:37:00 +0100 Subject: [PATCH 16/18] refactor(client): regenerate meshBuildingBlock v2 and meshBuildingBlockRun v1 from the current spec UUID fields such as definitionUuid, latestRunUuid and the run's metadata.uuid are uuid.UUID. Building block definition version refs and building block refs lose name. The target ref is one MeshBuildingBlockTargetRef for both directions, with required name or uuid. Its kind and the run's implementation type are string, since the spec now types each variant's discriminator inline. Co-Authored-By: Claude Opus 5.5 --- client/meshbuildingblock/v2/generated.go | 125 +++++------------- client/meshbuildingblockrun/v1/generated.go | 38 ++---- client/meshobject/generated.go | 43 +++--- cmd/buildingblock/trigger_run.go | 2 +- .../trigger_run_internal_test.go | 2 +- cmd/buildingblockrun/list_internal_test.go | 19 ++- 6 files changed, 73 insertions(+), 156 deletions(-) diff --git a/client/meshbuildingblock/v2/generated.go b/client/meshbuildingblock/v2/generated.go index 905870a1..52a87762 100644 --- a/client/meshbuildingblock/v2/generated.go +++ b/client/meshbuildingblock/v2/generated.go @@ -5,9 +5,6 @@ package meshbuildingblock import ( "context" - "encoding/json/jsontext" - "encoding/json/v2" - "fmt" "iter" "time" "uuid" @@ -92,24 +89,24 @@ func (c Client) Update(ctx context.Context, uuid uuid.UUID, body Request) (Respo } type ListFilter struct { - WorkspaceIdentifier string `json:"workspaceIdentifier,omitzero"` - CustomerIdentifier string `json:"customerIdentifier,omitzero"` - ProjectIdentifier string `json:"projectIdentifier,omitzero"` - PlatformIdentifier string `json:"platformIdentifier,omitzero"` - Name string `json:"name,omitzero"` - DefinitionName string `json:"definitionName,omitzero"` - DefinitionUuid string `json:"definitionUuid,omitzero"` - VersionNumber string `json:"versionNumber,omitzero"` - Status StatusValue `json:"status,omitzero"` - Sort []ListFilterSort `json:"sort,omitzero"` - VersionUuid uuid.UUID `json:"versionUuid,omitzero"` - TenantUuid uuid.UUID `json:"tenantUuid,omitzero"` - TargetRefKind meshobject.MeshBuildingBlockTargetRefDiscriminator `json:"targetRefKind,omitzero"` - HealthStatus []HealthStatusValue `json:"healthStatus,omitzero"` - Action []ActionValue `json:"action,omitzero"` - ManagedByWorkspaceIdentifier string `json:"managedByWorkspaceIdentifier,omitzero"` - ManagedByDefinitionUuid uuid.UUID `json:"managedByDefinitionUuid,omitzero"` - LifecycleState []ListFilterLifecycleState `json:"lifecycleState,omitzero"` + WorkspaceIdentifier string `json:"workspaceIdentifier,omitzero"` + CustomerIdentifier string `json:"customerIdentifier,omitzero"` + ProjectIdentifier string `json:"projectIdentifier,omitzero"` + PlatformIdentifier string `json:"platformIdentifier,omitzero"` + Name string `json:"name,omitzero"` + DefinitionName string `json:"definitionName,omitzero"` + DefinitionUuid uuid.UUID `json:"definitionUuid,omitzero"` + VersionNumber string `json:"versionNumber,omitzero"` + Status StatusValue `json:"status,omitzero"` + Sort []ListFilterSort `json:"sort,omitzero"` + VersionUuid uuid.UUID `json:"versionUuid,omitzero"` + TenantUuid uuid.UUID `json:"tenantUuid,omitzero"` + TargetRefKind TargetRefDiscriminator `json:"targetRefKind,omitzero"` + HealthStatus []HealthStatusValue `json:"healthStatus,omitzero"` + Action []ActionValue `json:"action,omitzero"` + ManagedByWorkspaceIdentifier string `json:"managedByWorkspaceIdentifier,omitzero"` + ManagedByDefinitionUuid uuid.UUID `json:"managedByDefinitionUuid,omitzero"` + LifecycleState []ListFilterLifecycleState `json:"lifecycleState,omitzero"` } type ActionValue string @@ -145,7 +142,6 @@ type DefinitionVersionRefRequest struct { Uuid uuid.UUID `json:"uuid"` // This is a fixed value "meshBuildingBlockDefinitionVersion" Kind *DefinitionVersionRefRequestKind `json:"kind,omitzero"` - Name *string `json:"name,omitzero"` } // This is a fixed value "meshBuildingBlockDefinitionVersion" @@ -162,7 +158,6 @@ type DefinitionVersionRefResponse struct { Uuid uuid.UUID `json:"uuid"` // This is a fixed value "meshBuildingBlockDefinitionVersion" Kind DefinitionVersionRefResponseKind `json:"kind"` - Name *string `json:"name,omitzero"` } // This is a fixed value "meshBuildingBlockDefinitionVersion" @@ -317,15 +312,15 @@ type MeshParentBuildingBlock struct { // (Deprecated: Use parentBuildingBlockRefs instead) Reference to the parent Building Block, identified by its uuid. BuildingBlockRef *Ref `json:"buildingBlockRef,omitzero"` // (Deprecated: Use parentBuildingBlockRefs instead) The UUID of the parent Building Block. - BuildingBlockUuid *string `json:"buildingBlockUuid,omitzero"` + BuildingBlockUuid *uuid.UUID `json:"buildingBlockUuid,omitzero"` // (Deprecated: Use parentBuildingBlockRefs instead) The UUID of the Building Block Definition of the parent Building Block. It is - // derived from the referenced Building Block, and ignored when supplied in a request. - DefinitionUuid *string `json:"definitionUuid,omitzero"` + // derived from the referenced Building Block. In a request, a valid UUID is ignored and a malformed one gets 400. + DefinitionUuid *uuid.UUID `json:"definitionUuid,omitzero"` } type Metadata struct { // The UUID to uniquely identify the Building Block. - Uuid *string `json:"uuid,omitzero"` + Uuid *uuid.UUID `json:"uuid,omitzero"` // The identifier of the meshWorkspace the Building Blocks belongs to. OwnedByWorkspace string `json:"ownedByWorkspace"` } @@ -357,7 +352,6 @@ type Ref struct { Uuid uuid.UUID `json:"uuid"` // The kind of the referenced meshObject. Always "meshBuildingBlock". Kind RefKind `json:"kind"` - Name *string `json:"name,omitzero"` } // The kind of the referenced meshObject. Always "meshBuildingBlock". @@ -391,7 +385,7 @@ type SpecRequest struct { BuildingBlockDefinitionVersionRef DefinitionVersionRefRequest `json:"buildingBlockDefinitionVersionRef"` // The reference to either a tenant or workspace the Building Block is located on. A tenant is referenced by `uuid`, // a workspace by its identifier in `name`. - TargetRef TargetRefRequest `json:"targetRef"` + TargetRef meshobject.MeshBuildingBlockTargetRef `json:"targetRef"` // The display name of the Building Block as it is shown in meshPanel. DisplayName string `json:"displayName"` // Map of input keys to their input data. A request contains only the inputs it may update, given sufficient @@ -411,7 +405,7 @@ type SpecResponse struct { BuildingBlockDefinitionVersionRef DefinitionVersionRefResponse `json:"buildingBlockDefinitionVersionRef"` // The reference to either a tenant or workspace the Building Block is located on. A tenant is referenced by `uuid`, // a workspace by its identifier in `name`. - TargetRef meshobject.MeshBuildingBlockTargetRefResponse `json:"targetRef"` + TargetRef meshobject.MeshBuildingBlockTargetRef `json:"targetRef"` // The display name of the Building Block as it is shown in meshPanel. DisplayName string `json:"displayName"` // Map of input keys to their input data. A request contains only the inputs it may update, given sufficient @@ -455,11 +449,11 @@ type StatusResponse struct { // excluded — see `status.latestDryRunUuid`. Null when there is no modifying run yet. Use the meshBuildingBlockRun // API to read the run and its logs; access to that content is governed there (run transparency does not gate this // identifier). - LatestRunUuid *string `json:"latestRunUuid,omitzero"` + LatestRunUuid *uuid.UUID `json:"latestRunUuid,omitzero"` // The UUID of the latest dry run for this Building Block, but only when it is the newest run — i.e. no modifying // (apply/destroy) run is newer; null otherwise. This guarantees the field never points to a stale dry run that a // later modifying run has superseded. - LatestDryRunUuid *string `json:"latestDryRunUuid,omitzero"` + LatestDryRunUuid *uuid.UUID `json:"latestDryRunUuid,omitzero"` // Why meshStack could not start the run this Building Block was last asked to perform, phrased for the user of the // Building Block and saying what they can do about it. Null while nothing stands in the way of a run. RunStartFailure *string `json:"runStartFailure,omitzero"` @@ -488,69 +482,14 @@ const ( var StatusValues = enum.Of[StatusValue]("WAITING_FOR_DEPENDENT_INPUT", "WAITING_FOR_OPERATOR_INPUT", "WAITING_FOR_USER_INPUT", "WAITING_FOR_APPROVAL", "PENDING", "IN_PROGRESS", "SUCCEEDED", "FAILED", "ABORTED") -type TargetRefRequest struct { - Kind meshobject.MeshBuildingBlockTargetRefDiscriminator - MeshTenant *TargetTenantRefRequest - MeshWorkspace *TargetWorkspaceRefRequest -} - -func (u TargetRefRequest) MarshalJSONTo(enc *jsontext.Encoder) error { - switch u.Kind { - case "meshTenant": - if u.MeshTenant != nil { - return json.MarshalEncode(enc, struct { - Discriminator meshobject.MeshBuildingBlockTargetRefDiscriminator `json:"kind"` - *TargetTenantRefRequest `json:",embed"` - }{u.Kind, u.MeshTenant}) - } - case "meshWorkspace": - if u.MeshWorkspace != nil { - return json.MarshalEncode(enc, struct { - Discriminator meshobject.MeshBuildingBlockTargetRefDiscriminator `json:"kind"` - *TargetWorkspaceRefRequest `json:",embed"` - }{u.Kind, u.MeshWorkspace}) - } - } - return fmt.Errorf("TargetRefRequest has no value for kind %q", u.Kind) -} - -// UnmarshalJSONFrom leaves every variant nil for a kind this client does not know yet. -func (u *TargetRefRequest) UnmarshalJSONFrom(dec *jsontext.Decoder) error { - raw, err := dec.ReadValue() - if err != nil { - return err - } - var discriminator struct { - Value meshobject.MeshBuildingBlockTargetRefDiscriminator `json:"kind"` - } - if err := json.Unmarshal(raw, &discriminator); err != nil { - return err - } - *u = TargetRefRequest{Kind: discriminator.Value} - switch discriminator.Value { - case "meshTenant": - u.MeshTenant = new(TargetTenantRefRequest) - return json.Unmarshal(raw, u.MeshTenant) - case "meshWorkspace": - u.MeshWorkspace = new(TargetWorkspaceRefRequest) - return json.Unmarshal(raw, u.MeshWorkspace) - } - return nil -} +type TargetRefDiscriminator string -type TargetTenantRefRequest struct { - // The UUID of the meshTenant. - Uuid *uuid.UUID `json:"uuid,omitzero"` - // Rejected: a meshTenant target is referenced by its `uuid`. - Name *string `json:"name,omitzero"` -} +const ( + TargetRefDiscriminatorMeshTenant TargetRefDiscriminator = "meshTenant" + TargetRefDiscriminatorMeshWorkspace TargetRefDiscriminator = "meshWorkspace" +) -type TargetWorkspaceRefRequest struct { - // The identifier of the meshWorkspace. - Name *string `json:"name,omitzero"` - // Rejected: a meshWorkspace target is referenced by its identifier in `name`. - Uuid *string `json:"uuid,omitzero"` -} +var TargetRefDiscriminators = enum.Of[TargetRefDiscriminator]("meshTenant", "meshWorkspace") type Transition struct { // The date this happened (e.g. `2020-12-22T09:37:43Z`). diff --git a/client/meshbuildingblockrun/v1/generated.go b/client/meshbuildingblockrun/v1/generated.go index 4fb2b218..32addfa3 100644 --- a/client/meshbuildingblockrun/v1/generated.go +++ b/client/meshbuildingblockrun/v1/generated.go @@ -79,18 +79,18 @@ var Behaviors = enum.Of[Behavior]("APPLY", "DETECT", "DESTROY") type BuildingBlock struct { // UUID of the referenced building block. - Uuid string `json:"uuid"` + Uuid uuid.UUID `json:"uuid"` Spec MeshBuildingBlockSpecForRun `json:"spec"` } type BuildingBlockDefinition struct { // UUID of the building block definition. - Uuid string `json:"uuid"` + Uuid uuid.UUID `json:"uuid"` Spec MeshBuildingBlockMinimalDefinitionSpec `json:"spec"` } type BuildingBlockImplementation struct { - Type BuildingBlockImplementationDiscriminator + Type string Manual *MeshManualBuildingBlockImplementation Terraform *MeshBuildingBlockTerraformImplementation GithubWorkflow *MeshBuildingBlockGithubImplementation @@ -103,35 +103,35 @@ func (u BuildingBlockImplementation) MarshalJSONTo(enc *jsontext.Encoder) error case "MANUAL": if u.Manual != nil { return json.MarshalEncode(enc, struct { - Discriminator BuildingBlockImplementationDiscriminator `json:"type"` + Discriminator string `json:"type"` *MeshManualBuildingBlockImplementation `json:",embed"` }{u.Type, u.Manual}) } case "TERRAFORM": if u.Terraform != nil { return json.MarshalEncode(enc, struct { - Discriminator BuildingBlockImplementationDiscriminator `json:"type"` + Discriminator string `json:"type"` *MeshBuildingBlockTerraformImplementation `json:",embed"` }{u.Type, u.Terraform}) } case "GITHUB_WORKFLOW": if u.GithubWorkflow != nil { return json.MarshalEncode(enc, struct { - Discriminator BuildingBlockImplementationDiscriminator `json:"type"` + Discriminator string `json:"type"` *MeshBuildingBlockGithubImplementation `json:",embed"` }{u.Type, u.GithubWorkflow}) } case "GITLAB_CICD": if u.GitlabCicd != nil { return json.MarshalEncode(enc, struct { - Discriminator BuildingBlockImplementationDiscriminator `json:"type"` + Discriminator string `json:"type"` *MeshBuildingBlockGitlabImplementation `json:",embed"` }{u.Type, u.GitlabCicd}) } case "AZURE_DEVOPS": if u.AzureDevops != nil { return json.MarshalEncode(enc, struct { - Discriminator BuildingBlockImplementationDiscriminator `json:"type"` + Discriminator string `json:"type"` *MeshBuildingBlockAzureDevOpsImplementation `json:",embed"` }{u.Type, u.AzureDevops}) } @@ -146,7 +146,7 @@ func (u *BuildingBlockImplementation) UnmarshalJSONFrom(dec *jsontext.Decoder) e return err } var discriminator struct { - Value BuildingBlockImplementationDiscriminator `json:"type"` + Value string `json:"type"` } if err := json.Unmarshal(raw, &discriminator); err != nil { return err @@ -172,18 +172,6 @@ func (u *BuildingBlockImplementation) UnmarshalJSONFrom(dec *jsontext.Decoder) e return nil } -type BuildingBlockImplementationDiscriminator string - -const ( - BuildingBlockImplementationDiscriminatorManual BuildingBlockImplementationDiscriminator = "MANUAL" - BuildingBlockImplementationDiscriminatorTerraform BuildingBlockImplementationDiscriminator = "TERRAFORM" - BuildingBlockImplementationDiscriminatorGithubWorkflow BuildingBlockImplementationDiscriminator = "GITHUB_WORKFLOW" - BuildingBlockImplementationDiscriminatorGitlabCicd BuildingBlockImplementationDiscriminator = "GITLAB_CICD" - BuildingBlockImplementationDiscriminatorAzureDevops BuildingBlockImplementationDiscriminator = "AZURE_DEVOPS" -) - -var BuildingBlockImplementationDiscriminators = enum.Of[BuildingBlockImplementationDiscriminator]("MANUAL", "TERRAFORM", "GITHUB_WORKFLOW", "GITLAB_CICD", "AZURE_DEVOPS") - type BuildingBlockRunStepStatus string const ( @@ -295,7 +283,7 @@ type MeshBuildingBlockSpecForRun struct { // The reference to either the tenant or workspace the building block is located on. Contains a `kind` // (`meshTenant` or `meshWorkspace`). For `meshTenant`, the tenant is identified by its `uuid`; for // `meshWorkspace`, the workspace is identified by its `name` field, which holds the workspace identifier. - TargetRef meshobject.MeshBuildingBlockTargetRefResponse `json:"targetRef"` + TargetRef meshobject.MeshBuildingBlockTargetRef `json:"targetRef"` // The identifier of the workspace of the building block. WorkspaceIdentifier string `json:"workspaceIdentifier"` // The identifier of the project of the building block. @@ -327,14 +315,14 @@ type MeshManualBuildingBlockImplementation struct { type MeshParentBuildingBlock struct { // The UUID of the parent Building Block. - BuildingBlockUuid string `json:"buildingBlockUuid"` + BuildingBlockUuid uuid.UUID `json:"buildingBlockUuid"` // The UUID of the Building Block Definition of the parent Building Block. - DefinitionUuid string `json:"definitionUuid"` + DefinitionUuid uuid.UUID `json:"definitionUuid"` } type Metadata struct { // The UUID of this building block run. - Uuid string `json:"uuid"` + Uuid uuid.UUID `json:"uuid"` // When meshStack recorded this run, as an ISO-8601 instant (e.g. `2020-12-22T09:37:43Z`). A run is recorded when // it enters the queue, which is not necessarily when a runner picked it up: a run that waits for an input, a // parent Building Block or an approval is recorded long before it executes. The list endpoint sorts on this diff --git a/client/meshobject/generated.go b/client/meshobject/generated.go index 93acee38..a3b691a7 100644 --- a/client/meshobject/generated.go +++ b/client/meshobject/generated.go @@ -11,71 +11,62 @@ import ( "github.com/meshcloud/meshstack-cli/client/types/enum" ) -type MeshBuildingBlockTargetRefDiscriminator string - -const ( - MeshBuildingBlockTargetRefDiscriminatorMeshTenant MeshBuildingBlockTargetRefDiscriminator = "meshTenant" - MeshBuildingBlockTargetRefDiscriminatorMeshWorkspace MeshBuildingBlockTargetRefDiscriminator = "meshWorkspace" -) - -var MeshBuildingBlockTargetRefDiscriminators = enum.Of[MeshBuildingBlockTargetRefDiscriminator]("meshTenant", "meshWorkspace") - -type MeshBuildingBlockTargetRefResponse struct { - Kind MeshBuildingBlockTargetRefDiscriminator - MeshTenant *MeshBuildingBlockTargetTenantRefResponse - MeshWorkspace *MeshBuildingBlockTargetWorkspaceRefResponse +type MeshBuildingBlockTargetRef struct { + Kind string + MeshTenant *MeshBuildingBlockTargetTenantRef + MeshWorkspace *MeshBuildingBlockTargetWorkspaceRef } -func (u MeshBuildingBlockTargetRefResponse) MarshalJSONTo(enc *jsontext.Encoder) error { +func (u MeshBuildingBlockTargetRef) MarshalJSONTo(enc *jsontext.Encoder) error { switch u.Kind { case "meshTenant": if u.MeshTenant != nil { return json.MarshalEncode(enc, struct { - Discriminator MeshBuildingBlockTargetRefDiscriminator `json:"kind"` - *MeshBuildingBlockTargetTenantRefResponse `json:",embed"` + Discriminator string `json:"kind"` + *MeshBuildingBlockTargetTenantRef `json:",embed"` }{u.Kind, u.MeshTenant}) } case "meshWorkspace": if u.MeshWorkspace != nil { return json.MarshalEncode(enc, struct { - Discriminator MeshBuildingBlockTargetRefDiscriminator `json:"kind"` - *MeshBuildingBlockTargetWorkspaceRefResponse `json:",embed"` + Discriminator string `json:"kind"` + *MeshBuildingBlockTargetWorkspaceRef `json:",embed"` }{u.Kind, u.MeshWorkspace}) } } - return fmt.Errorf("MeshBuildingBlockTargetRefResponse has no value for kind %q", u.Kind) + return fmt.Errorf("MeshBuildingBlockTargetRef has no value for kind %q", u.Kind) } // UnmarshalJSONFrom leaves every variant nil for a kind this client does not know yet. -func (u *MeshBuildingBlockTargetRefResponse) UnmarshalJSONFrom(dec *jsontext.Decoder) error { +func (u *MeshBuildingBlockTargetRef) UnmarshalJSONFrom(dec *jsontext.Decoder) error { raw, err := dec.ReadValue() if err != nil { return err } var discriminator struct { - Value MeshBuildingBlockTargetRefDiscriminator `json:"kind"` + Value string `json:"kind"` } if err := json.Unmarshal(raw, &discriminator); err != nil { return err } - *u = MeshBuildingBlockTargetRefResponse{Kind: discriminator.Value} + *u = MeshBuildingBlockTargetRef{Kind: discriminator.Value} switch discriminator.Value { case "meshTenant": - u.MeshTenant = new(MeshBuildingBlockTargetTenantRefResponse) + u.MeshTenant = new(MeshBuildingBlockTargetTenantRef) return json.Unmarshal(raw, u.MeshTenant) case "meshWorkspace": - u.MeshWorkspace = new(MeshBuildingBlockTargetWorkspaceRefResponse) + u.MeshWorkspace = new(MeshBuildingBlockTargetWorkspaceRef) return json.Unmarshal(raw, u.MeshWorkspace) } return nil } -type MeshBuildingBlockTargetTenantRefResponse struct { +type MeshBuildingBlockTargetTenantRef struct { // The UUID of the meshTenant. Uuid uuid.UUID `json:"uuid"` } -type MeshBuildingBlockTargetWorkspaceRefResponse struct { +type MeshBuildingBlockTargetWorkspaceRef struct { // The identifier of the meshWorkspace. Name string `json:"name"` } diff --git a/cmd/buildingblock/trigger_run.go b/cmd/buildingblock/trigger_run.go index 3f8dfcb6..bf789735 100644 --- a/cmd/buildingblock/trigger_run.go +++ b/cmd/buildingblock/trigger_run.go @@ -79,7 +79,7 @@ status.latestRunUuid names the run. "meshstack buildingblockrun logs " // building block as meshStack reported it then. type runStart struct { raw jsontext.Value - runUuid string + runUuid uuid.UUID failure string } diff --git a/cmd/buildingblock/trigger_run_internal_test.go b/cmd/buildingblock/trigger_run_internal_test.go index 33158c06..82179b9f 100644 --- a/cmd/buildingblock/trigger_run_internal_test.go +++ b/cmd/buildingblock/trigger_run_internal_test.go @@ -29,7 +29,7 @@ func TestAwaitRunStartTakesNothingTheTriggerRunAnswerReportedForTheStart(t *test require.NoError(t, err) _, err = awaitRunStart(t.Context(), meshStack.Raw, buildingBlockUuid, meshbuildingblock.StatusResponse{ - LatestRunUuid: new(previousRun), + LatestRunUuid: new(uuid.MustParse(previousRun)), RunStartFailure: new("the previous request failed"), RunStartFailedOn: new(time.Date(2026, 10, 1, 10, 0, 0, 0, time.UTC)), }, time.Millisecond, 50*time.Millisecond) diff --git a/cmd/buildingblockrun/list_internal_test.go b/cmd/buildingblockrun/list_internal_test.go index 0e0c5768..61f90955 100644 --- a/cmd/buildingblockrun/list_internal_test.go +++ b/cmd/buildingblockrun/list_internal_test.go @@ -7,6 +7,7 @@ import ( "fmt" "iter" "testing" + "uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -18,7 +19,7 @@ func blockRuns(block string, pulled *int, createdAts ...string) iter.Seq2[jsonte return func(yield func(jsontext.Value, error) bool) { for i, createdAt := range createdAts { *pulled++ - run := fmt.Sprintf(`{"metadata": {"uuid": "%s/%d", "createdAt": %q}}`, block, i, createdAt) + run := fmt.Sprintf(`{"name": "%s/%d", "metadata": {"uuid": "%s", "createdAt": %q}}`, block, i, uuid.New(), createdAt) if !yield(jsontext.Value(run), nil) { return } @@ -26,22 +27,20 @@ func blockRuns(block string, pulled *int, createdAts ...string) iter.Seq2[jsonte } } -func uuidsOf(t *testing.T, runs iter.Seq2[jsontext.Value, error], n int) []string { +func namesOf(t *testing.T, runs iter.Seq2[jsontext.Value, error], n int) []string { t.Helper() - var uuids []string + var names []string for run, err := range runs { require.NoError(t, err) var listed struct { - Metadata struct { - Uuid string `json:"uuid"` - } `json:"metadata"` + Name string `json:"name"` } require.NoError(t, json.Unmarshal(run, &listed)) - if uuids = append(uuids, listed.Metadata.Uuid); len(uuids) == n { + if names = append(names, listed.Name); len(names) == n { break } } - return uuids + return names } func TestMergeNewestFirst(t *testing.T) { @@ -54,7 +53,7 @@ func TestMergeNewestFirst(t *testing.T) { blockRuns("d", &pulled, "2026-10-01T03:00:00.000Z"), }) - assert.Equal(t, []string{"a/0", "c/0", "a/1", "c/1", "d/0", "a/2", "c/2"}, uuidsOf(t, merged, 0), + assert.Equal(t, []string{"a/0", "c/0", "a/1", "c/1", "d/0", "a/2", "c/2"}, namesOf(t, merged, 0), "runs created at the same time keep the order of their blocks") }) @@ -66,7 +65,7 @@ func TestMergeNewestFirst(t *testing.T) { blockRuns("c", &pulledC, "2026-10-01T01:00:00Z"), }) - assert.Equal(t, []string{"a/0", "a/1"}, uuidsOf(t, merged, 2)) + assert.Equal(t, []string{"a/0", "a/1"}, namesOf(t, merged, 2)) assert.Equal(t, 2, pulledA, "no further than the last listed run of a") assert.Equal(t, 1, pulledB, "only the first run of b, before the merge listed any") assert.Equal(t, 1, pulledC, "only the first run of c, before the merge listed any") From 546de244b7ba7de0a8263b0ed35a8f0850b4a9a6 Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Mon, 26 Oct 2026 13:15:30 +0100 Subject: [PATCH 17/18] feat(client): mark deprecated fields and filters of generated clients A property or query parameter the spec marks deprecated gets a Go "Deprecated:" paragraph, so staticcheck reports its use. The text is the API's "**Deprecated:**" paragraph, of the package's API version for a filter, else "see the API documentation.". Co-Authored-By: Claude Opus 5.5 --- client/openapi/generator/generated.go.tmpl | 5 ++- client/openapi/generator/model.go | 6 +++- client/openapi/generator/operations.go | 6 +++- client/openapi/generator/render.go | 21 ++++++++++++- .../golden/meshthing/v2/generated.go.golden | 31 +++++++++++++------ client/openapi/generator/testdata/spec.json | 6 ++-- client/openapi/operation.go | 2 ++ 7 files changed, 60 insertions(+), 17 deletions(-) diff --git a/client/openapi/generator/generated.go.tmpl b/client/openapi/generator/generated.go.tmpl index 32e8f711..3ab00049 100644 --- a/client/openapi/generator/generated.go.tmpl +++ b/client/openapi/generator/generated.go.tmpl @@ -56,7 +56,10 @@ func (c Client) Api() {{$.Runtime}}.Api { type {{.Name}} struct { {{- range .Fields}} - {{.Name}} {{.Type}} {{.Tag}} +{{- if .Doc}} +{{comment .Doc}} +{{- else}} +{{end}} {{.Name}} {{.Type}} {{.Tag}} {{- end}} } {{- end}} diff --git a/client/openapi/generator/model.go b/client/openapi/generator/model.go index 324b4496..30f1adf1 100644 --- a/client/openapi/generator/model.go +++ b/client/openapi/generator/model.go @@ -414,13 +414,17 @@ func (g *generator) field(p *pkg, t *namedType, property openapi.Property, dir d readOnly, _, _ := g.access(property.Schema) required := t.schema.IsRequired(property.Name) && (!readOnly || dir != request) optional := !required || property.Schema.Nullable || resolved.Nullable + doc := cmp.Or(property.Schema.Description, resolved.Description) + if property.Schema.Deprecated { + doc = deprecated(doc) + } return field{ name: goName(property.Name), json: property.Name, ref: r, pointer: optional && !r.container(), omitZero: optional, - doc: cmp.Or(property.Schema.Description, resolved.Description), + doc: doc, }, nil } diff --git a/client/openapi/generator/operations.go b/client/openapi/generator/operations.go index c688f1d7..8fc8d586 100644 --- a/client/openapi/generator/operations.go +++ b/client/openapi/generator/operations.go @@ -181,7 +181,11 @@ func (g *generator) addParameter(p *pkg, o *operation, parameter openapi.Paramet if err != nil { return fmt.Errorf("query parameter %s: %w", parameter.Name, err) } - o.query = append(o.query, param{name: parameter.Name, goName: goName(parameter.Name), ref: r}) + var doc string + if parameter.Deprecated { + doc = deprecated(versionSection(parameter.Description, p.ApiVersion)) + } + o.query = append(o.query, param{name: parameter.Name, goName: goName(parameter.Name), ref: r, doc: doc}) } return nil } diff --git a/client/openapi/generator/render.go b/client/openapi/generator/render.go index fdb3bd93..171739e4 100644 --- a/client/openapi/generator/render.go +++ b/client/openapi/generator/render.go @@ -96,7 +96,7 @@ func (g *generator) render(p *pkg) ([]byte, error) { if o.queryType != "" { query := typeData{Name: o.queryType, Kind: "object"} for _, q := range o.query { - query.Fields = append(query.Fields, fieldData{Name: q.goName, Type: r.expr(q.ref), Tag: fmt.Sprintf("`json:%q`", q.name+",omitzero")}) + query.Fields = append(query.Fields, fieldData{Name: q.goName, Type: r.expr(q.ref), Tag: fmt.Sprintf("`json:%q`", q.name+",omitzero"), Doc: q.doc}) } data.Queries = append(data.Queries, query) } @@ -293,6 +293,25 @@ func withDeprecation(doc, deprecation string) string { return strings.TrimSpace(doc + "\n\nDeprecated: " + deprecation) } +// deprecated turns the API's "**Deprecated:**" paragraph of a field into the one Go tools read. +func deprecated(text string) string { + doc, note, found := strings.Cut(text, "**Deprecated:** ") + if !found { + return withDeprecation(text, "see the API documentation.") + } + return withDeprecation(strings.TrimSpace(doc), note) +} + +// versionSection is the "**v2:** …" part of a text that differs by API version. +func versionSection(text, version string) string { + _, section, found := strings.Cut(text, "**"+version+":** ") + if !found { + return text + } + section, _, _ = strings.Cut(section, "\n\n**v") + return section +} + // deprecation is empty for a package of no deprecated API version. func (p *pkg) deprecation() string { if p.lifecycle == nil { diff --git a/client/openapi/generator/testdata/golden/meshthing/v2/generated.go.golden b/client/openapi/generator/testdata/golden/meshthing/v2/generated.go.golden index 34d9dd2e..54cd7a30 100644 --- a/client/openapi/generator/testdata/golden/meshthing/v2/generated.go.golden +++ b/client/openapi/generator/testdata/golden/meshthing/v2/generated.go.golden @@ -76,8 +76,11 @@ func (c Client) Paint(ctx context.Context, uuid uuid.UUID, body PaintRequest) (R } type ListFilter struct { - Sort []ListFilterSort `json:"sort,omitzero"` - OwnerUuid uuid.UUID `json:"ownerUuid,omitzero"` + Sort []ListFilterSort `json:"sort,omitzero"` + OwnerUuid uuid.UUID `json:"ownerUuid,omitzero"` + // The kind of the target. + // + // Deprecated: Use `ownerUuid`. TargetKind meshobject.TargetKind `json:"targetKind,omitzero"` } @@ -152,10 +155,14 @@ var SpecColors = enum.Of[SpecColor]("DEEP_RED", "green") type SpecRequest struct { // The name. // It spans two lines. - Name string `json:"name"` - Count *int64 `json:"count,omitzero"` - Color *SpecColor `json:"color,omitzero"` - Value types.SecretOrAny `json:"value,omitzero"` + Name string `json:"name"` + // Deprecated: see the API documentation. + Count *int64 `json:"count,omitzero"` + Color *SpecColor `json:"color,omitzero"` + Value types.SecretOrAny `json:"value,omitzero"` + // The tags. + // + // Deprecated: Use `parts`. Tags map[string][]string `json:"tags,omitzero"` Parts []Part `json:"parts,omitzero"` Spare *Part `json:"spare,omitzero"` @@ -167,10 +174,14 @@ type SpecRequest struct { type SpecResponse struct { // The name. // It spans two lines. - Name string `json:"name"` - Count *int64 `json:"count,omitzero"` - Color *SpecColor `json:"color,omitzero"` - Value types.SecretOrAny `json:"value,omitzero"` + Name string `json:"name"` + // Deprecated: see the API documentation. + Count *int64 `json:"count,omitzero"` + Color *SpecColor `json:"color,omitzero"` + Value types.SecretOrAny `json:"value,omitzero"` + // The tags. + // + // Deprecated: Use `parts`. Tags map[string][]string `json:"tags,omitzero"` Parts []Part `json:"parts,omitzero"` Spare *Part `json:"spare,omitzero"` diff --git a/client/openapi/generator/testdata/spec.json b/client/openapi/generator/testdata/spec.json index 84a8e663..34157cab 100644 --- a/client/openapi/generator/testdata/spec.json +++ b/client/openapi/generator/testdata/spec.json @@ -9,7 +9,7 @@ {"$ref": "#/components/parameters/page"}, {"name": "sort", "in": "query", "schema": {"type": "array", "items": {"type": "string", "enum": ["createdAt", "createdAt,desc"]}}}, {"name": "ownerUuid", "in": "query", "schema": {"type": "string", "format": "uuid"}}, - {"name": "targetKind", "in": "query", "schema": {"$ref": "#/components/schemas/TargetKind"}} + {"name": "targetKind", "in": "query", "deprecated": true, "description": "**v1:** The kind.\n\n**Deprecated:** v2 has `ownerUuid`.\n\n**v2:** The kind of the target.\n\n**Deprecated:** Use `ownerUuid`.", "schema": {"$ref": "#/components/schemas/TargetKind"}} ], "responses": { "200": {"description": "OK", "content": {"application/vnd.meshcloud.api.meshthing.v2.hal+json": {"schema": {"$ref": "#/components/schemas/HalPageMeshThingV2"}}}} @@ -130,10 +130,10 @@ "required": ["name", "count", "secret"], "properties": { "name": {"type": "string", "description": "The name.\nIt spans two lines."}, - "count": {"type": "integer", "format": "int64", "nullable": true}, + "count": {"type": "integer", "format": "int64", "nullable": true, "deprecated": true}, "color": {"type": "string", "enum": ["DEEP_RED", "green"]}, "value": {"nullable": true, "oneOf": [{"type": "string"}, {"type": "boolean"}]}, - "tags": {"allOf": [{"$ref": "#/components/schemas/MapOfList"}]}, + "tags": {"description": "The tags.\n\n**Deprecated:** Use `parts`.", "deprecated": true, "allOf": [{"$ref": "#/components/schemas/MapOfList"}]}, "parts": {"type": "array", "items": {"$ref": "#/components/schemas/Part_0"}}, "spare": {"$ref": "#/components/schemas/Part"}, "target": {"$ref": "#/components/schemas/TargetRef"}, diff --git a/client/openapi/operation.go b/client/openapi/operation.go index 38ba1458..7a9e5a9c 100644 --- a/client/openapi/operation.go +++ b/client/openapi/operation.go @@ -17,6 +17,7 @@ type OperationSchemas struct { type ParameterSchema struct { Name, In string Required bool + Deprecated bool Schema *Schema Description string PathKey PathKey @@ -116,6 +117,7 @@ func (p *ParameterSchema) UnmarshalJSONFrom(dec *jsontext.Decoder) error { Name string `json:"name"` In string `json:"in"` Required bool `json:"required"` + Deprecated bool `json:"deprecated"` Schema *Schema `json:"schema"` Description string `json:"description"` PathKey PathKey `json:",embed"` From c7746f2021488d2a02b6ee8cef5f11c7183be7f2 Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Mon, 26 Oct 2026 13:15:30 +0100 Subject: [PATCH 18/18] refactor(client): regenerate meshBuildingBlock v2 from the final spec parentBuildingBlocks, the parent refs' buildingBlockUuid and definitionUuid, status.status and the status filter are deprecated, as is authorType of meshobject.MeshObjectAuthor. Co-Authored-By: Claude Opus 5.5 --- client/meshbuildingblock/v2/generated.go | 71 ++++++++++++++++-------- client/meshobject/generated.go | 4 +- 2 files changed, 52 insertions(+), 23 deletions(-) diff --git a/client/meshbuildingblock/v2/generated.go b/client/meshbuildingblock/v2/generated.go index 52a87762..96c929f6 100644 --- a/client/meshbuildingblock/v2/generated.go +++ b/client/meshbuildingblock/v2/generated.go @@ -89,14 +89,25 @@ func (c Client) Update(ctx context.Context, uuid uuid.UUID, body Request) (Respo } type ListFilter struct { - WorkspaceIdentifier string `json:"workspaceIdentifier,omitzero"` - CustomerIdentifier string `json:"customerIdentifier,omitzero"` - ProjectIdentifier string `json:"projectIdentifier,omitzero"` - PlatformIdentifier string `json:"platformIdentifier,omitzero"` - Name string `json:"name,omitzero"` - DefinitionName string `json:"definitionName,omitzero"` - DefinitionUuid uuid.UUID `json:"definitionUuid,omitzero"` - VersionNumber string `json:"versionNumber,omitzero"` + WorkspaceIdentifier string `json:"workspaceIdentifier,omitzero"` + CustomerIdentifier string `json:"customerIdentifier,omitzero"` + ProjectIdentifier string `json:"projectIdentifier,omitzero"` + PlatformIdentifier string `json:"platformIdentifier,omitzero"` + Name string `json:"name,omitzero"` + DefinitionName string `json:"definitionName,omitzero"` + DefinitionUuid uuid.UUID `json:"definitionUuid,omitzero"` + VersionNumber string `json:"versionNumber,omitzero"` + // Filter for Building Blocks that have a certain status. + // + // Deprecated: Use the `healthStatus` and `action` filters: + // - `WAITING_FOR_USER_INPUT`, `WAITING_FOR_OPERATOR_INPUT`: `action=PROVIDE_INPUT` + // - `WAITING_FOR_DEPENDENT_INPUT`: `action=WAITING_FOR_DEPENDENCY` + // - `WAITING_FOR_APPROVAL`: `action=APPROVE_RUN` + // - `PENDING`: `action=PENDING` + // - `IN_PROGRESS`: `action=IN_PROGRESS` + // - `FAILED`: `action=REVIEW_ERRORS` + // - `SUCCEEDED`: `healthStatus=SUCCEEDED` + // - `ABORTED`: no equivalent Status StatusValue `json:"status,omitzero"` Sort []ListFilterSort `json:"sort,omitzero"` VersionUuid uuid.UUID `json:"versionUuid,omitzero"` @@ -309,12 +320,16 @@ const ( var MeshLifecycleStates = enum.Of[MeshLifecycleState]("ACTIVE", "DEACTIVATED", "MARKED_FOR_DELETION", "DELETED") type MeshParentBuildingBlock struct { - // (Deprecated: Use parentBuildingBlockRefs instead) Reference to the parent Building Block, identified by its uuid. + // Reference to the parent Building Block, identified by its uuid. BuildingBlockRef *Ref `json:"buildingBlockRef,omitzero"` - // (Deprecated: Use parentBuildingBlockRefs instead) The UUID of the parent Building Block. + // The UUID of the parent Building Block. + // + // Deprecated: Use `buildingBlockRef`. BuildingBlockUuid *uuid.UUID `json:"buildingBlockUuid,omitzero"` - // (Deprecated: Use parentBuildingBlockRefs instead) The UUID of the Building Block Definition of the parent Building Block. It is - // derived from the referenced Building Block. In a request, a valid UUID is ignored and a malformed one gets 400. + // The UUID of the Building Block Definition of the parent Building Block. In a request, a valid UUID is ignored and + // a malformed one gets 400. + // + // Deprecated: It is derived from the referenced Building Block. DefinitionUuid *uuid.UUID `json:"definitionUuid,omitzero"` } @@ -392,8 +407,10 @@ type SpecRequest struct { // permission. A response contains all inputs the Building Block Definition defines, with their values; a sensitive // value is returned as its hash only, because meshStack does not store the plaintext. Inputs map[string]InputRequest `json:"inputs"` - // (Deprecated: Use parentBuildingBlockRefs instead) List of parent Building Blocks, which are required for the Building Block. - // Each parent must belong to the same workspace as the Building Block, on any of its tenants or on the workspace itself. + // List of parent Building Blocks, which are required for the Building Block. Each parent must belong to the same + // workspace as the Building Block, on any of its tenants or on the workspace itself. + // + // Deprecated: Use `parentBuildingBlockRefs`. ParentBuildingBlocks []MeshParentBuildingBlock `json:"parentBuildingBlocks,omitzero"` // List of references to the parent Building Blocks of this Building Block. Each entry contains a uuid and kind. // Each parent must belong to the same workspace as the Building Block, on any of its tenants or on the workspace itself. @@ -412,8 +429,10 @@ type SpecResponse struct { // permission. A response contains all inputs the Building Block Definition defines, with their values; a sensitive // value is returned as its hash only, because meshStack does not store the plaintext. Inputs map[string]InputResponse `json:"inputs"` - // (Deprecated: Use parentBuildingBlockRefs instead) List of parent Building Blocks, which are required for the Building Block. - // Each parent must belong to the same workspace as the Building Block, on any of its tenants or on the workspace itself. + // List of parent Building Blocks, which are required for the Building Block. Each parent must belong to the same + // workspace as the Building Block, on any of its tenants or on the workspace itself. + // + // Deprecated: Use `parentBuildingBlockRefs`. ParentBuildingBlocks []MeshParentBuildingBlock `json:"parentBuildingBlocks"` // List of references to the parent Building Blocks of this Building Block. Each entry contains a uuid and kind. // Each parent must belong to the same workspace as the Building Block, on any of its tenants or on the workspace itself. @@ -421,12 +440,20 @@ type SpecResponse struct { } type StatusResponse struct { - // Deprecated: rely on `status.healthStatus` and `status.action` instead, which separate the two questions this field - // conflates — whether the Building Block is healthy, and what it needs from you next. The status of the Building - // Block. One of WAITING_FOR_DEPENDENT_INPUT, WAITING_FOR_OPERATOR_INPUT, WAITING_FOR_USER_INPUT, - // WAITING_FOR_APPROVAL, PENDING, IN_PROGRESS, SUCCEEDED, FAILED, ABORTED. A request that triggers a run reports - // `PENDING` right away, before the run itself exists, so you can poll this field for that run's outcome without - // mistaking the previous run's status for it. + // The status of the Building Block. A request that triggers a run reports `PENDING` right away, before the run + // itself exists, so you can poll this field for that run's outcome without mistaking the previous run's status for + // it. + // + // Deprecated: Use `status.healthStatus` and `status.action`. They separate the two questions this field conflates: whether the + // Building Block is healthy, and what it needs from you next. The values map as follows: + // - `WAITING_FOR_USER_INPUT`, `WAITING_FOR_OPERATOR_INPUT`: `action` is `PROVIDE_INPUT` + // - `WAITING_FOR_DEPENDENT_INPUT`: `action` is `WAITING_FOR_DEPENDENCY` + // - `WAITING_FOR_APPROVAL`: `action` is `APPROVE_RUN` + // - `PENDING`: `action` is `PENDING` + // - `IN_PROGRESS`: `action` is `IN_PROGRESS` + // - `FAILED`: `action` is `REVIEW_ERRORS` + // - `SUCCEEDED`: `healthStatus` is `SUCCEEDED` + // - `ABORTED`: no equivalent Status StatusValue `json:"status"` // The outcome of the latest run that speaks for the Building Block: an apply or destroy run, or a preflight that // failed or confirmed there is nothing to change. A preflight that planned a change leaves this field to the run diff --git a/client/meshobject/generated.go b/client/meshobject/generated.go index a3b691a7..0dca7c50 100644 --- a/client/meshobject/generated.go +++ b/client/meshobject/generated.go @@ -89,7 +89,9 @@ type MeshObjectAuthor struct { // The external identifier of the author from the identity provider, used to identify the user in downstream cloud // platforms. Set for `User` authors that have one; absent otherwise. Euid *string `json:"euid,omitzero"` - // Deprecated: superseded by `type`, which carries the same value. Retained for backward compatibility. + // The type of the author. + // + // Deprecated: Use `type`, which carries the same value. AuthorType *MeshObjectAuthorType `json:"authorType,omitzero"` }