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/client.go b/client/client.go index ea7e0983..8ebdc538 100644 --- a/client/client.go +++ b/client/client.go @@ -3,7 +3,10 @@ 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" "github.com/meshcloud/meshstack-cli/client/types/xurl" "github.com/meshcloud/meshstack-cli/internal/http" "github.com/meshcloud/meshstack-cli/internal/version" @@ -46,12 +49,19 @@ 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 + // 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, } @@ -60,6 +70,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 +104,14 @@ 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, + HttpClient: authorizedClient, Endpoint: endpoint, } 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/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/meshapi/api.go b/client/meshapi/api.go new file mode 100644 index 00000000..e46223b4 --- /dev/null +++ b/client/meshapi/api.go @@ -0,0 +1,173 @@ +// 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" + "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/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 + + 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) +} + +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[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) + } + 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 + } + } + } +} + +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 +} diff --git a/client/meshbuildingblock/v2/generated.go b/client/meshbuildingblock/v2/generated.go new file mode 100644 index 00000000..96c929f6 --- /dev/null +++ b/client/meshbuildingblock/v2/generated.go @@ -0,0 +1,531 @@ +// 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" + "iter" + "time" + "uuid" + + "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" +) + +const ( + Kind = "meshBuildingBlock" + ApiVersion = "v2-preview" + MediaType = "application/vnd.meshcloud.api.meshbuildingblock.v2-preview.hal+json" +) + +type Client struct { + api meshapi.Api +} + +func NewClient(httpClient meshapi.HttpClient) Client { + return Client{meshapi.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() 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, meshapi.Call{Method: "POST", Path: "/api/meshobjects/meshbuildingblocks", SendsMeshObject: true, AnswersMeshObject: true}, body) +} + +// Delete building block +// +// 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 +// +// 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 +func (c Client) List(ctx context.Context, filter ListFilter) iter.Seq2[Response, error] { + return c.api.List[Response](ctx, meshapi.Call{Method: "GET", Path: "/api/meshobjects/meshbuildingblocks", Query: filter, AnswersMeshObject: true}) +} + +// Purge building block +// +// 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 +// +// 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 +// +// 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 { + 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"` + 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 + +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"` +} + +// 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"` +} + +// 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 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"` + // 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 { + // Reference to the parent Building Block, identified by its uuid. + BuildingBlockRef *Ref `json:"buildingBlockRef,omitzero"` + // The UUID of the parent Building Block. + // + // Deprecated: Use `buildingBlockRef`. + BuildingBlockUuid *uuid.UUID `json:"buildingBlockUuid,omitzero"` + // 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"` +} + +type Metadata struct { + // The UUID to uniquely identify the Building Block. + Uuid *uuid.UUID `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"` +} + +// 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 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 + // 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"` + // 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. + 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.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 + // 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"` + // 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. + ParentBuildingBlockRefs []Ref `json:"parentBuildingBlockRefs,omitzero"` +} + +type StatusResponse struct { + // 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 + // 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 *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 *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"` + // 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 TargetRefDiscriminator string + +const ( + TargetRefDiscriminatorMeshTenant TargetRefDiscriminator = "meshTenant" + TargetRefDiscriminatorMeshWorkspace TargetRefDiscriminator = "meshWorkspace" +) + +var TargetRefDiscriminators = enum.Of[TargetRefDiscriminator]("meshTenant", "meshWorkspace") + +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..32addfa3 --- /dev/null +++ b/client/meshbuildingblockrun/v1/generated.go @@ -0,0 +1,419 @@ +// 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/meshapi" + "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 meshapi.Api +} + +func NewClient(httpClient meshapi.HttpClient) Client { + return Client{meshapi.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() meshapi.Api { + return c.api +} + +// Get building block run +// +// 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 +func (c Client) List(ctx context.Context, filter ListFilter) iter.Seq2[Response, error] { + return c.api.List[Response](ctx, meshapi.Call{Method: "GET", Path: "/api/meshobjects/meshbuildingblockruns", Query: filter, AnswersMeshObject: true}) +} + +// Download logs of building block run +// +// 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 { + BuildingBlockUuid uuid.UUID `json:"buildingBlockUuid,omitzero"` + Sort []ListFilterSort `json:"sort,omitzero"` +} + +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 uuid.UUID `json:"uuid"` + Spec MeshBuildingBlockSpecForRun `json:"spec"` +} + +type BuildingBlockDefinition struct { + // UUID of the building block definition. + Uuid uuid.UUID `json:"uuid"` + Spec MeshBuildingBlockMinimalDefinitionSpec `json:"spec"` +} + +type BuildingBlockImplementation struct { + Type string + 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 string `json:"type"` + *MeshManualBuildingBlockImplementation `json:",embed"` + }{u.Type, u.Manual}) + } + case "TERRAFORM": + if u.Terraform != nil { + return json.MarshalEncode(enc, struct { + Discriminator string `json:"type"` + *MeshBuildingBlockTerraformImplementation `json:",embed"` + }{u.Type, u.Terraform}) + } + case "GITHUB_WORKFLOW": + if u.GithubWorkflow != nil { + return json.MarshalEncode(enc, struct { + Discriminator string `json:"type"` + *MeshBuildingBlockGithubImplementation `json:",embed"` + }{u.Type, u.GithubWorkflow}) + } + case "GITLAB_CICD": + if u.GitlabCicd != nil { + return json.MarshalEncode(enc, struct { + Discriminator string `json:"type"` + *MeshBuildingBlockGitlabImplementation `json:",embed"` + }{u.Type, u.GitlabCicd}) + } + case "AZURE_DEVOPS": + if u.AzureDevops != nil { + return json.MarshalEncode(enc, struct { + Discriminator string `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 string `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 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 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"` +} + +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.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. + 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 uuid.UUID `json:"buildingBlockUuid"` + // The UUID of the Building Block Definition of the parent Building Block. + DefinitionUuid uuid.UUID `json:"definitionUuid"` +} + +type Metadata struct { + // The UUID of this building block run. + 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 + // 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..0dca7c50 --- /dev/null +++ b/client/meshobject/generated.go @@ -0,0 +1,107 @@ +// 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 MeshBuildingBlockTargetRef struct { + Kind string + MeshTenant *MeshBuildingBlockTargetTenantRef + MeshWorkspace *MeshBuildingBlockTargetWorkspaceRef +} + +func (u MeshBuildingBlockTargetRef) MarshalJSONTo(enc *jsontext.Encoder) error { + switch u.Kind { + case "meshTenant": + if u.MeshTenant != nil { + return json.MarshalEncode(enc, struct { + Discriminator string `json:"kind"` + *MeshBuildingBlockTargetTenantRef `json:",embed"` + }{u.Kind, u.MeshTenant}) + } + case "meshWorkspace": + if u.MeshWorkspace != nil { + return json.MarshalEncode(enc, struct { + Discriminator string `json:"kind"` + *MeshBuildingBlockTargetWorkspaceRef `json:",embed"` + }{u.Kind, u.MeshWorkspace}) + } + } + 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 *MeshBuildingBlockTargetRef) UnmarshalJSONFrom(dec *jsontext.Decoder) error { + raw, err := dec.ReadValue() + if err != nil { + return err + } + var discriminator struct { + Value string `json:"kind"` + } + if err := json.Unmarshal(raw, &discriminator); err != nil { + return err + } + *u = MeshBuildingBlockTargetRef{Kind: discriminator.Value} + switch discriminator.Value { + case "meshTenant": + u.MeshTenant = new(MeshBuildingBlockTargetTenantRef) + return json.Unmarshal(raw, u.MeshTenant) + case "meshWorkspace": + u.MeshWorkspace = new(MeshBuildingBlockTargetWorkspaceRef) + return json.Unmarshal(raw, u.MeshWorkspace) + } + return nil +} + +type MeshBuildingBlockTargetTenantRef struct { + // The UUID of the meshTenant. + Uuid uuid.UUID `json:"uuid"` +} + +type MeshBuildingBlockTargetWorkspaceRef struct { + // The identifier of the meshWorkspace. + Name string `json:"name"` +} + +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"` + // The type of the author. + // + // Deprecated: Use `type`, which carries the same value. + 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/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)) + }) +} diff --git a/client/meshworkspace/v2/generated.go b/client/meshworkspace/v2/generated.go new file mode 100644 index 00000000..8ce48727 --- /dev/null +++ b/client/meshworkspace/v2/generated.go @@ -0,0 +1,142 @@ +// 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/meshapi" + "github.com/meshcloud/meshstack-cli/client/meshobject" + "github.com/meshcloud/meshstack-cli/client/types/enum" +) + +const ( + Kind = "meshWorkspace" + ApiVersion = "v2" + MediaType = "application/vnd.meshcloud.api.meshworkspace.v2.hal+json" +) + +type Client struct { + api meshapi.Api +} + +func NewClient(httpClient meshapi.HttpClient) Client { + return Client{meshapi.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() meshapi.Api { + return c.api +} + +// Create workspace +func (c Client) Create(ctx context.Context, body Request) (Response, error) { + return c.api.Do[Response](ctx, meshapi.Call{Method: "POST", Path: "/api/meshobjects/meshworkspaces", SendsMeshObject: true, AnswersMeshObject: true}, body) +} + +// Delete workspace +// +// 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 +// +// 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 +func (c Client) List(ctx context.Context, filter ListFilter) iter.Seq2[Response, error] { + return c.api.List[Response](ctx, meshapi.Call{Method: "GET", Path: "/api/meshobjects/meshworkspaces", Query: filter, AnswersMeshObject: true}) +} + +// Create or update workspace +// +// 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 { + 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"` + // 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"` +} 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/generator/generated.go.tmpl b/client/openapi/generator/generated.go.tmpl new file mode 100644 index 00000000..3ab00049 --- /dev/null +++ b/client/openapi/generator/generated.go.tmpl @@ -0,0 +1,134 @@ +{{.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 {{$.Runtime}}.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}}, + 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() {{$.Runtime}}.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}} +{{- if .Doc}} +{{comment .Doc}} +{{- else}} +{{end}} {{.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..6a3fca9c --- /dev/null +++ b/client/openapi/generator/generator.go @@ -0,0 +1,118 @@ +// 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 + // 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. +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" + 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. +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..30f1adf1 --- /dev/null +++ b/client/openapi/generator/model.go @@ -0,0 +1,529 @@ +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 + lifecycle *openapi.Lifecycle + 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 + // pathArgs fill the parameters of pathTemplate, in its order. + pathArgs [][]argPart + deprecation string + queryType string + query []param + body *typeRef + sendsMeshObject bool + result *typeRef + answersMeshObject bool + list bool +} + +type param struct { + name, goName, goType, doc string + ref typeRef +} + +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{ + 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 + 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: doc, + }, 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..8fc8d586 --- /dev/null +++ b/client/openapi/generator/operations.go @@ -0,0 +1,371 @@ +package generator + +import ( + "cmp" + "errors" + "fmt" + "go/token" + "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 + } + if lifecycle := schemas.Lifecycle(p.mediaType); lifecycle != nil { + p.lifecycle = lifecycle + } + 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 = "" + } + 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(p *pkg, 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": + key := parameter.PathKey.Key + if byVersion, ok := parameter.PathKey.ByVersion[p.ApiVersion]; ok { + key = byVersion + } + o.addPathParam(parameter, key, resolved.Format == "uuid") + case "query": + if parameter.Name == "page" || parameter.Name == "size" { + return nil + } + 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) + } + 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 +} + +// 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) + } + } +} + +// 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 + } + 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 +// 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..171739e4 --- /dev/null +++ b/client/openapi/generator/render.go @@ -0,0 +1,343 @@ +package generator + +import ( + "bytes" + "cmp" + _ "embed" + "fmt" + "go/format" + "maps" + "path" + "slices" + "strconv" + "strings" + "text/template" + + "github.com/meshcloud/meshstack-cli/client/openapi" +) + +//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 + Runtime 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, + Runtime: path.Base(g.cfg.Runtime), + Kind: p.Kind, + ApiVersion: p.ApiVersion, + MediaType: p.mediaType, + Collection: p.collection, + EmbeddedRel: p.embeddedRel, + } + if p.Kind != "" { + 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)) + 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"), Doc: q.doc}) + } + data.Queries = append(data.Queries, query) + } + } + if len(p.operations) > 0 { + r.imports["context"] = true + r.imports[g.cfg.Runtime] = 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 { + runtime := path.Base(r.g.cfg.Runtime) + params := []string{"ctx context.Context"} + for _, p := range o.pathParams { + params = append(params, p.goName+" "+p.goType) + if p.goType == "uuid.UUID" { + r.imports["uuid"] = true + } + } + 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)} + if len(o.pathParams) > 0 { + call = append(call, "Path: "+runtime+".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) + } + 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" + } + 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 = runtime + ".Call{" + strings.Join(call, ", ") + "}" + return data +} + +func (r renderer) typeData(d *decl) typeData { + t := d.t + 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] + } + 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" +} + +// 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) +} + +// 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 { + 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(ofVersion, openapi.NewerVersionAvailable) +} + +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..3038102b --- /dev/null +++ b/client/openapi/generator/testdata/golden/meshother/v1/generated.go.golden @@ -0,0 +1,54 @@ +// 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: removed after 2025-07-01. +package meshother + +import ( + "context" + + "example.com/client/meshobject" + "github.com/meshcloud/meshstack-cli/client/meshapi" +) + +const ( + Kind = "meshOther" + ApiVersion = "v1" + MediaType = "application/vnd.meshcloud.api.meshother.v1.hal+json" +) + +type Client struct { + api meshapi.Api +} + +func NewClient(httpClient meshapi.HttpClient) Client { + return Client{meshapi.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() meshapi.Api { + return c.api +} + +// Get other +// +// thing and other: The thing and the other, separated by '.'. +// +// 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: 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 new file mode 100644 index 00000000..54cd7a30 --- /dev/null +++ b/client/openapi/generator/testdata/golden/meshthing/v2/generated.go.golden @@ -0,0 +1,195 @@ +// 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/meshapi" + "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 meshapi.Api +} + +func NewClient(httpClient meshapi.HttpClient) Client { + return Client{meshapi.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() meshapi.Api { + return c.api +} + +// Create thing +func (c Client) Create(ctx context.Context, body Request) (Response, error) { + return c.api.Do[Response](ctx, meshapi.Call{Method: "POST", Path: "/api/meshobjects/meshthings", SendsMeshObject: true, AnswersMeshObject: true}, body) +} + +// Delete thing +// +// 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 +// +// 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 +func (c Client) List(ctx context.Context, filter ListFilter) iter.Seq2[Response, error] { + return c.api.List[Response](ctx, meshapi.Call{Method: "GET", Path: "/api/meshobjects/meshthings", Query: filter, AnswersMeshObject: true}) +} + +// Paint thing +// +// uuid is the UUID of the meshThing. +// +// 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) +} + +type ListFilter struct { + 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"` +} + +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 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"` + DeletedOn *time.Time `json:"deletedOn,omitzero"` + Owner *meshobject.Owner `json:"owner,omitzero"` +} + +// Deprecated: a newer version is available. +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"` + // 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"` + 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"` + // 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"` + 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..34157cab --- /dev/null +++ b/client/openapi/generator/testdata/spec.json @@ -0,0 +1,194 @@ +{ + "openapi": "3.0.1", + "paths": { + "/api/meshobjects/meshthings": { + "get": { + "summary": "List things", + "operationId": "meshThingList", + "parameters": [ + {"$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", "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"}}}} + } + }, + "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", + "deprecated": true, + "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, "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": "Deprecated", "retiredAfter": "2025-07-01", "latest": "meshOther.v2", "notice": "Please update to `meshOther.v2`."}}}} + } + } + } + }, + "components": { + "parameters": { + "page": {"name": "page", "in": "query", "schema": {"type": "integer", "format": "int32"}}, + "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"}, "x-meshstack-lifecycle": {"meshObject": "meshThing.v2", "state": "GA"}}}}, + "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, "deprecated": true}, + "color": {"type": "string", "enum": ["DEEP_RED", "green"]}, + "value": {"nullable": true, "oneOf": [{"type": "string"}, {"type": "boolean"}]}, + "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"}, + "secret": {"$ref": "#/components/schemas/SecretBox"}, + "extra": {"type": "object", "additionalProperties": true} + } + }, + "MeshThingStatusV2": { + "type": "object", + "required": ["config"], + "properties": { + "config": {"$ref": "#/components/schemas/MeshThingConfigV2"} + } + }, + "MeshThingPaintRequest": { + "type": "object", + "deprecated": true, + "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", + "deprecated": true, + "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"]}, + "apiVersion": {"type": "string", "enum": ["v1"]}, + "owner": {"$ref": "#/components/schemas/Owner"} + } + } + } + } +} diff --git a/client/openapi/operation.go b/client/openapi/operation.go new file mode 100644 index 00000000..7a9e5a9c --- /dev/null +++ b/client/openapi/operation.go @@ -0,0 +1,229 @@ +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 + Deprecated 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 { + switch { + case l.State != "Outdated" && l.State != "Deprecated" && l.State != "Retired": + return "" + case l.RetiredAfter != "": + return "removed after " + l.RetiredAfter + "." + } + 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) + 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 { + 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"` + Deprecated bool `json:"deprecated"` + Schema *Schema `json:"schema"` + Description string `json:"description"` + PathKey PathKey `json:",embed"` + } + 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"` + 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, mediaType.Lifecycle}) + } + 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..61d98e9c --- /dev/null +++ b/client/openapi/schema.go @@ -0,0 +1,137 @@ +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"` + Deprecated bool `json:"deprecated"` + Versions []Lifecycle `json:"x-meshstack-versions"` +} + +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..1634d94d 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. @@ -33,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 } @@ -60,13 +62,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,12 +98,15 @@ 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"` } var parsed struct { OperationId string `json:"operationId"` + Deprecated bool `json:"deprecated"` RequestBody content `json:"requestBody"` Responses map[string]content `json:"responses"` } @@ -99,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), 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)) } @@ -154,7 +173,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 +186,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) } 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/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 +} diff --git a/client/raw.go b/client/raw.go index ddc2e65f..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" ) @@ -84,6 +85,13 @@ 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 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 +} + // 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 { 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..bf789735 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 } @@ -77,29 +79,26 @@ 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 } // 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..82179b9f 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{ - LatestRunUuid: new(previousRun), + _, err = awaitRunStart(t.Context(), meshStack.Raw, buildingBlockUuid, meshbuildingblock.StatusResponse{ + LatestRunUuid: new(uuid.MustParse(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..f813c1a8 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}) } - 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} + 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/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") 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/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 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