diff --git a/.changes/unreleased/Added-20240729-083340.yaml b/.changes/unreleased/Added-20240729-083340.yaml new file mode 100644 index 0000000..ae81ef2 --- /dev/null +++ b/.changes/unreleased/Added-20240729-083340.yaml @@ -0,0 +1,3 @@ +kind: Added +body: added wundergraph_federated_graph and wundergraph_federated_subgraph resources +time: 2024-07-29T08:33:40.812506446+02:00 diff --git a/README.md b/README.md index 557af02..4b8d638 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,6 @@ -# Terraform Provider Scaffolding (Terraform Plugin Framework) +# Terraform Provider Wundergraph -_This template repository is built on the [Terraform Plugin Framework](https://github.com/hashicorp/terraform-plugin-framework). The template repository built on the [Terraform Plugin SDK](https://github.com/hashicorp/terraform-plugin-sdk) can be found at [terraform-provider-scaffolding](https://github.com/hashicorp/terraform-provider-scaffolding). See [Which SDK Should I Use?](https://developer.hashicorp.com/terraform/plugin/framework-benefits) in the Terraform documentation for additional information._ - -This repository is a *template* for a [Terraform](https://www.terraform.io) provider. It is intended as a starting point for creating Terraform providers, containing: - -- A resource and a data source (`internal/provider/`), -- Examples (`examples/`) and generated documentation (`docs/`), -- Miscellaneous meta files. - -These files contain boilerplate code that you will need to edit to create your own Terraform provider. Tutorials for creating Terraform providers can be found on the [HashiCorp Developer](https://developer.hashicorp.com/terraform/tutorials/providers-plugin-framework) platform. _Terraform Plugin Framework specific guides are titled accordingly._ - -Please see the [GitHub template repository documentation](https://help.github.com/en/github/creating-cloning-and-archiving-repositories/creating-a-repository-from-a-template) for how to create a new repository from this template on GitHub. - -Once you've written your provider, you'll want to [publish it on the Terraform Registry](https://developer.hashicorp.com/terraform/registry/providers/publishing) so that others can use it. +This is the Terraform provider for [Wundergraph](https://wundergraph.com/). ## Requirements diff --git a/docs/resources/federated_graph.md b/docs/resources/federated_graph.md new file mode 100644 index 0000000..fd8edd8 --- /dev/null +++ b/docs/resources/federated_graph.md @@ -0,0 +1,54 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "wundergraph_federated_graph Resource - terraform-provider-wundergraph" +subcategory: "" +description: |- + Federated graph. +--- + +# wundergraph_federated_graph (Resource) + +Federated graph. + +## Example Usage + +```terraform +resource "wundergraph_federated_graph" "my-federated-graph" { + name = "my.federated.graph" + namespace = "default" + routing_url = "https://my-federated-graph.com" + label_matchers = [ + { + key = "some" + values = ["label"] + } + ] +} +``` + + +## Schema + +### Required + +- `name` (String) The name of the federated graph to create. It is usually in the format of . and is used to uniquely identify your federated graph. +- `routing_url` (String) The routing url of your router. This is the url that the router will be accessible at. + +### Optional + +- `admission_webhook_secret` (String, Sensitive) The admission webhook secret is used to sign requests to the webhook url. +- `admission_webhook_url` (String) The admission webhook url. This is the url that the controlplane will use to implement admission control for the federated graph. +- `label_matchers` (Attributes List) The label matcher is used to select the subgraphs to federate. (see [below for nested schema](#nestedatt--label_matchers)) +- `namespace` (String) The namespace name of the federated graph. + +### Read-Only + +- `id` (String) Identifier + + +### Nested Schema for `label_matchers` + +Required: + +- `key` (String) The key of the label matcher. +- `values` (List of String) The key of the label matcher. diff --git a/docs/resources/federated_subgraph.md b/docs/resources/federated_subgraph.md new file mode 100644 index 0000000..47d3dab --- /dev/null +++ b/docs/resources/federated_subgraph.md @@ -0,0 +1,52 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "wundergraph_federated_subgraph Resource - terraform-provider-wundergraph" +subcategory: "" +description: |- + federated subgraph resource. +--- + +# wundergraph_federated_subgraph (Resource) + +federated subgraph resource. + +## Example Usage + +```terraform +data "local_file" "schema" { + filename = "${path.module}/my-subgraph.graphql" +} + +resource "wundergraph_federated_subgraph" "my-subgraph" { + name = "my.subgraph" + namespace = "default" + schema = data.local_file.schema.content + routing_url = "https://my-subgraph.com" + labels = { + "some" = "label" + } +} +``` + + +## Schema + +### Required + +- `name` (String) The name of the subgraph to create. It is usually in the format of . and is used to uniquely identify your federated subgraph. +- `schema` (String) The schema to upload to the subgraph. This should be the full schema in SDL format. + +### Optional + +- `is_event_driven_graph` (Boolean) Set whether the subgraph is an Event-Driven Graph (EDG). Errors will be returned for the inclusion of most other parameters if the subgraph is an Event-Driven Graph. +- `is_feature_subgraph` (Boolean) Set whether the subgraph is a feature subgraph. +- `labels` (Map of String) The labels to apply to the subgraph. +- `namespace` (String) The namespace name of the subgraph. +- `routing_url` (String) The routing URL of your subgraph. This is the url at which the subgraph will be accessible. Required unless the event-driven-graph flag is set. Returns an error if the event-driven-graph flag is set. +- `subscription_protocol` (String) The protocol to use when subscribing to the subgraph. The supported protocols are ws, sse, and sse_post. +- `subscription_url` (String) The protocol to use when subscribing to the subgraph. The supported protocols are ws, sse, and sse_post. Returns an error if the event-driven-graph flag is set. +- `websocket_subprotocol` (String) The subprotocol to use when subscribing to the subgraph. The supported protocols are auto, graphql-ws, and graphql-transport-ws. Should be used only if the subscription protocol is ws. For more information see https://cosmo-docs.wundergraph.com/router/subscriptions/websocket-subprotocols. + +### Read-Only + +- `id` (String) Identifier diff --git a/examples/resources/wundergraph_federated_graph/resource.tf b/examples/resources/wundergraph_federated_graph/resource.tf new file mode 100644 index 0000000..3ac479e --- /dev/null +++ b/examples/resources/wundergraph_federated_graph/resource.tf @@ -0,0 +1,11 @@ +resource "wundergraph_federated_graph" "my-federated-graph" { + name = "my.federated.graph" + namespace = "default" + routing_url = "https://my-federated-graph.com" + label_matchers = [ + { + key = "some" + values = ["label"] + } + ] +} diff --git a/examples/resources/wundergraph_federated_subgraph/resource.tf b/examples/resources/wundergraph_federated_subgraph/resource.tf new file mode 100644 index 0000000..5fa0291 --- /dev/null +++ b/examples/resources/wundergraph_federated_subgraph/resource.tf @@ -0,0 +1,13 @@ +data "local_file" "schema" { + filename = "${path.module}/my-subgraph.graphql" +} + +resource "wundergraph_federated_subgraph" "my-subgraph" { + name = "my.subgraph" + namespace = "default" + schema = data.local_file.schema.content + routing_url = "https://my-subgraph.com" + labels = { + "some" = "label" + } +} diff --git a/go.mod b/go.mod index a0654c7..02a880a 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,9 @@ require ( connectrpc.com/connect v1.16.2 github.com/hashicorp/terraform-plugin-docs v0.19.4 github.com/hashicorp/terraform-plugin-framework v1.10.0 + github.com/hashicorp/terraform-plugin-framework-validators v0.13.0 github.com/hashicorp/terraform-plugin-go v0.23.0 + github.com/stretchr/testify v1.9.0 google.golang.org/protobuf v1.34.2 ) @@ -25,6 +27,7 @@ require ( github.com/bmatcuk/doublestar/v4 v4.6.1 // indirect github.com/bufbuild/protocompile v0.14.0 // indirect github.com/cloudflare/circl v1.3.9 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/fatih/color v1.17.0 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/google/uuid v1.6.0 // indirect @@ -54,11 +57,11 @@ require ( github.com/mitchellh/go-testing-interface v1.14.1 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/oklog/run v1.1.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/posener/complete v1.2.3 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/shopspring/decimal v1.4.0 // indirect github.com/spf13/cast v1.6.0 // indirect - github.com/stretchr/testify v1.9.0 // indirect github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect github.com/yuin/goldmark v1.7.4 // indirect @@ -71,7 +74,7 @@ require ( golang.org/x/net v0.27.0 // indirect golang.org/x/sys v0.22.0 // indirect golang.org/x/text v0.16.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20240722135656-d784300faade // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240725223205-93522f1f2a9f // indirect google.golang.org/grpc v1.65.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v2 v2.4.0 // indirect diff --git a/go.sum b/go.sum index 5f2535b..02960b7 100644 --- a/go.sum +++ b/go.sum @@ -88,6 +88,8 @@ github.com/hashicorp/terraform-plugin-docs v0.19.4 h1:G3Bgo7J22OMtegIgn8Cd/CaSey github.com/hashicorp/terraform-plugin-docs v0.19.4/go.mod h1:4pLASsatTmRynVzsjEhbXZ6s7xBlUw/2Kt0zfrq8HxA= github.com/hashicorp/terraform-plugin-framework v1.10.0 h1:xXhICE2Fns1RYZxEQebwkB2+kXouLC932Li9qelozrc= github.com/hashicorp/terraform-plugin-framework v1.10.0/go.mod h1:qBXLDn69kM97NNVi/MQ9qgd1uWWsVftGSnygYG1tImM= +github.com/hashicorp/terraform-plugin-framework-validators v0.13.0 h1:bxZfGo9DIUoLLtHMElsu+zwqI4IsMZQBRRy4iLzZJ8E= +github.com/hashicorp/terraform-plugin-framework-validators v0.13.0/go.mod h1:wGeI02gEhj9nPANU62F2jCaHjXulejm/X+af4PdZaNo= github.com/hashicorp/terraform-plugin-go v0.23.0 h1:AALVuU1gD1kPb48aPQUjug9Ir/125t+AAurhqphJ2Co= github.com/hashicorp/terraform-plugin-go v0.23.0/go.mod h1:1E3Cr9h2vMlahWMbsSEcNrOCxovCZhOOIXjFHbjc/lQ= github.com/hashicorp/terraform-plugin-log v0.9.0 h1:i7hOA+vdAItN1/7UrfBqBwvYPQ9TFvymaRGZED3FCV0= @@ -233,6 +235,8 @@ golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/genproto/googleapis/rpc v0.0.0-20240722135656-d784300faade h1:oCRSWfwGXQsqlVdErcyTt4A93Y8fo0/9D4b1gnI++qo= google.golang.org/genproto/googleapis/rpc v0.0.0-20240722135656-d784300faade/go.mod h1:Ue6ibwXGpU+dqIcODieyLOcgj7z8+IcskoNIgZxtrFY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240725223205-93522f1f2a9f h1:RARaIm8pxYuxyNPbBQf5igT7XdOyCNtat1qAT2ZxjU4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240725223205-93522f1f2a9f/go.mod h1:Ue6ibwXGpU+dqIcODieyLOcgj7z8+IcskoNIgZxtrFY= google.golang.org/grpc v1.65.0 h1:bs/cUb4lp1G5iImFFd3u5ixQzweKizoZJAwBNLR42lc= google.golang.org/grpc v1.65.0/go.mod h1:WgYC2ypjlB0EiQi6wdKixMqukr6lBc0Vo+oOgjrM5ZQ= google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 36e787b..b5583b9 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -100,6 +100,8 @@ func (p *WundergraphProvider) Configure(ctx context.Context, req provider.Config func (p *WundergraphProvider) Resources(ctx context.Context) []func() resource.Resource { return []func() resource.Resource{ resources.NewNamespaceResource, + resources.NewFederatedSubgraphResource, + resources.NewFederatedGraphResource, } } diff --git a/internal/resources/federated_graph.go b/internal/resources/federated_graph.go new file mode 100644 index 0000000..bb0d4bb --- /dev/null +++ b/internal/resources/federated_graph.go @@ -0,0 +1,385 @@ +package resources + +import ( + "connectrpc.com/connect" + "context" + "fmt" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/labd/terraform-provider-wundergraph/sdk/wg/cosmo/common" + platformv1 "github.com/labd/terraform-provider-wundergraph/sdk/wg/cosmo/platform/v1" + "github.com/labd/terraform-provider-wundergraph/sdk/wg/cosmo/platform/v1/platformv1connect" + "strings" +) + +// Ensure provider defined types fully satisfy framework interfaces. +var _ resource.Resource = &FederatedGraphResource{} +var _ resource.ResourceWithImportState = &FederatedGraphResource{} + +func NewFederatedGraphResource() resource.Resource { + return &FederatedGraphResource{} +} + +// FederatedGraphResource defines the resource implementation. +type FederatedGraphResource struct { + client platformv1connect.PlatformServiceClient +} + +// FederatedGraphModel describes the resource data model. +type FederatedGraphModel struct { + Id types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + Namespace types.String `tfsdk:"namespace"` + RoutingUrl types.String `tfsdk:"routing_url"` + LabelMatchers LabelMatchers `tfsdk:"label_matchers"` + AdmissionWebhookUrl types.String `tfsdk:"admission_webhook_url"` + AdmissionWebhookSecret types.String `tfsdk:"admission_webhook_secret"` +} + +type LabelMatcher struct { + Key types.String `tfsdk:"key"` + Values types.List `tfsdk:"values"` +} + +type LabelMatchers []LabelMatcher + +func (l *LabelMatchers) FindByKey(key string) *LabelMatcher { + for _, v := range *l { + if v.Key.ValueString() == key { + return &v + } + } + + return nil +} + +func (r *FederatedGraphResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_federated_graph" +} + +func (r *FederatedGraphResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: "Federated graph.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "Identifier", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "name": schema.StringAttribute{ + MarkdownDescription: "The name of the federated graph to create. It is usually in the format of . and is used to uniquely identify your federated graph.", + Required: true, + }, + "namespace": schema.StringAttribute{ + MarkdownDescription: "The namespace name of the federated graph. Defaults to `default`.", + Optional: true, + Computed: true, + Default: stringdefault.StaticString("default"), + }, + "routing_url": schema.StringAttribute{ + MarkdownDescription: "The routing url of your router. This is the url that the router will be accessible at.", + Required: true, + }, + "label_matchers": schema.ListNestedAttribute{ + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "key": schema.StringAttribute{ + Required: true, + MarkdownDescription: "The key of the label matcher.", + }, + "values": schema.ListAttribute{ + ElementType: types.StringType, + MarkdownDescription: "The key of the label matcher.", + Required: true, + }, + }, + }, + MarkdownDescription: "The label matcher is used to select the subgraphs to federate.", + Optional: true, + }, + "admission_webhook_url": schema.StringAttribute{ + MarkdownDescription: "The admission webhook url. This is the url that the controlplane will use to implement admission control for the federated graph.", + Optional: true, + }, + "admission_webhook_secret": schema.StringAttribute{ + MarkdownDescription: "The admission webhook secret is used to sign requests to the webhook url.", + Optional: true, + Sensitive: true, + }, + }, + } +} + +func (r *FederatedGraphResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(platformv1connect.PlatformServiceClient) + + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *http.Client, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + + return + } + + r.client = client +} + +func MapLabelMatchersToNative(ctx context.Context, labelMatchers []LabelMatcher) ([]string, diag.Diagnostics) { + var matchers []string + for _, v := range labelMatchers { + var values []string + diags := v.Values.ElementsAs(ctx, &values, false) + if diags.HasError() { + return nil, diags + } + + var tags []string + for _, tag := range values { + tags = append(tags, fmt.Sprintf("%s=%s", v.Key.ValueString(), tag)) + } + + matchers = append(matchers, strings.Join(tags, ",")) + } + + return matchers, nil +} + +func (r *FederatedGraphResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan *FederatedGraphModel + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + + var labels, d = MapLabelMatchersToNative(ctx, plan.LabelMatchers) + if diags.HasError() { + resp.Diagnostics.Append(d...) + return + } + + //We first create the FederatedGraph + rc, err := r.client.CreateFederatedGraph(ctx, &connect.Request[platformv1.CreateFederatedGraphRequest]{ + Msg: &platformv1.CreateFederatedGraphRequest{ + Name: plan.Name.ValueString(), + Namespace: plan.Namespace.ValueString(), + RoutingUrl: plan.RoutingUrl.ValueString(), + AdmissionWebhookURL: plan.AdmissionWebhookUrl.ValueString(), + AdmissionWebhookSecret: plan.AdmissionWebhookSecret.ValueStringPointer(), + LabelMatchers: labels, + }, + }) + if err != nil { + resp.Diagnostics.AddError("Error creating federated graph", err.Error()) + return + } + + if rc.Msg.GetResponse().Code != common.EnumStatusCode_OK { + for _, e := range rc.Msg.CompositionErrors { + resp.Diagnostics.AddWarning("Composition errors when creating graph", e.Message) + } + + for _, e := range rc.Msg.DeploymentErrors { + resp.Diagnostics.AddWarning("Deployment errors when creating graph", e.Message) + } + + resp.Diagnostics.AddError("Error creating federated graph", rc.Msg.GetResponse().GetDetails()) + } + + // We fetch the FederatedGraph list to get the requested FederatedGraph, as we don't have a direct read endpoint. + ns, err := r.client.GetFederatedGraphs(ctx, &connect.Request[platformv1.GetFederatedGraphsRequest]{ + Msg: &platformv1.GetFederatedGraphsRequest{ + Namespace: plan.Namespace.ValueString(), + }, + }) + if err != nil { + resp.Diagnostics.AddError("Error reading federated graph", err.Error()) + return + } + + var isFound bool + for _, n := range ns.Msg.Graphs { + if n.Name == plan.Name.ValueString() { + plan.Id = types.StringValue(n.Id) + isFound = true + continue + } + } + + if !isFound { + resp.Diagnostics.AddError("Error reading federated graph", "Federated graph not found") + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func MapLabelMatchersFromNative(ctx context.Context, labelMatchers []string) (LabelMatchers, diag.Diagnostics) { + var l LabelMatchers = make([]LabelMatcher, 0, len(labelMatchers)) + var d = diag.Diagnostics{} + for _, v := range labelMatchers { + elements := strings.Split(v, ",") + var key string + var values []string + + for _, e := range elements { + parts := strings.Split(e, "=") + if len(parts) != 2 { + d.AddError("Error parsing label matcher", "invalid label matcher") + return nil, d + } + + if key == "" { + key = parts[0] + } + + values = append(values, parts[1]) + } + + val, d := types.ListValueFrom(ctx, types.StringType, values) + if d.HasError() { + return nil, d + } + + l = append(l, LabelMatcher{ + Key: types.StringValue(key), + Values: val, + }) + } + + return l, nil +} + +func (r *FederatedGraphResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var data *FederatedGraphModel + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + // We fetch the FederatedGraph list to get the requested FederatedGraph, as we don't have a direct read endpoint. + ns, err := r.client.GetFederatedGraphs(ctx, &connect.Request[platformv1.GetFederatedGraphsRequest]{ + Msg: &platformv1.GetFederatedGraphsRequest{ + Namespace: data.Namespace.ValueString(), + }, + }) + if err != nil { + resp.Diagnostics.AddError("Error reading federated graph", err.Error()) + return + } + + if ns.Msg.GetResponse().Code != common.EnumStatusCode_OK { + resp.Diagnostics.AddError("Error fetching federated graph list", ns.Msg.GetResponse().GetDetails()) + return + } + + var current *FederatedGraphModel + for _, n := range ns.Msg.Graphs { + if n.Id == data.Id.ValueString() { + var admissionWebhookUrl *string + if n.AdmissionWebhookUrl != nil && *(n.AdmissionWebhookUrl) != "" { + admissionWebhookUrl = n.AdmissionWebhookUrl + } + + labelMatchers, d := MapLabelMatchersFromNative(ctx, n.LabelMatchers) + if d.HasError() { + resp.Diagnostics.Append(d...) + return + } + + current = &FederatedGraphModel{ + Id: types.StringValue(n.Id), + Name: types.StringValue(n.Name), + Namespace: types.StringValue(n.Namespace), + RoutingUrl: types.StringValue(n.RoutingURL), + AdmissionWebhookUrl: types.StringPointerValue(admissionWebhookUrl), + LabelMatchers: labelMatchers, + } + continue + } + } + + if current == nil { + resp.Diagnostics.AddError("Error reading federated graph", "federated graph not found") + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, ¤t)...) +} + +func (r *FederatedGraphResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan FederatedGraphModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + var state FederatedGraphModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + labels, d := MapLabelMatchersToNative(ctx, plan.LabelMatchers) + if d.HasError() { + resp.Diagnostics.Append(d...) + return + } + + ru, err := r.client.UpdateFederatedGraph(ctx, &connect.Request[platformv1.UpdateFederatedGraphRequest]{ + Msg: &platformv1.UpdateFederatedGraphRequest{ + Name: plan.Name.ValueString(), + Namespace: plan.Namespace.ValueString(), + RoutingUrl: plan.RoutingUrl.ValueString(), + AdmissionWebhookSecret: plan.AdmissionWebhookSecret.ValueStringPointer(), + AdmissionWebhookURL: plan.AdmissionWebhookUrl.ValueStringPointer(), + LabelMatchers: labels, + }, + }) + if err != nil { + resp.Diagnostics.AddError("Error updating federated graph", err.Error()) + return + } + + if ru.Msg.GetResponse().Code != common.EnumStatusCode_OK { + resp.Diagnostics.AddError("Error updating federated graph", ru.Msg.GetResponse().GetDetails()) + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *FederatedGraphResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var data *FederatedGraphModel + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + _, err := r.client.DeleteFederatedGraph(ctx, &connect.Request[platformv1.DeleteFederatedGraphRequest]{ + Msg: &platformv1.DeleteFederatedGraphRequest{ + Name: data.Name.ValueString(), + Namespace: data.Namespace.ValueString(), + }, + }) + + if err != nil { + resp.Diagnostics.AddError("Error deleting federated graph", err.Error()) + return + } +} + +func (r *FederatedGraphResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) +} diff --git a/internal/resources/federated_graph_test.go b/internal/resources/federated_graph_test.go new file mode 100644 index 0000000..71aebac --- /dev/null +++ b/internal/resources/federated_graph_test.go @@ -0,0 +1,64 @@ +package resources + +import ( + "context" + "github.com/hashicorp/terraform-plugin-framework/attr" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/stretchr/testify/assert" +) + +func TestMapLabelMatchersFromNative(t *testing.T) { + tests := []struct { + name string + labelMatchers []string + expected LabelMatchers + expectError bool + }{ + { + name: "ValidInput", + labelMatchers: []string{"key1=value1,key1=value2", "key2=value3,key2=value4"}, + expected: LabelMatchers{ + {Key: types.StringValue("key1"), Values: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("value1"), types.StringValue("value2")})}, + {Key: types.StringValue("key2"), Values: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("value3"), types.StringValue("value4")})}, + }, + expectError: false, + }, + { + name: "InvalidInput", + labelMatchers: []string{"key1=value1,value2", "invalid"}, + expected: nil, + expectError: true, + }, + { + name: "EmptyInput", + labelMatchers: []string{}, + expected: LabelMatchers{}, + expectError: false, + }, + { + name: "EmptyValues", + labelMatchers: []string{"key1="}, + expected: LabelMatchers{ + {Key: types.StringValue("key1"), Values: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("")})}, + }, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + result, diags := MapLabelMatchersFromNative(ctx, tt.labelMatchers) + + if tt.expectError { + assert.Nil(t, result) + assert.True(t, diags.HasError()) + } else { + assert.False(t, diags.HasError()) + assert.Equal(t, tt.expected, result) + } + }) + } +} diff --git a/internal/resources/federated_subgraph.go b/internal/resources/federated_subgraph.go new file mode 100644 index 0000000..fb2f121 --- /dev/null +++ b/internal/resources/federated_subgraph.go @@ -0,0 +1,508 @@ +package resources + +import ( + "connectrpc.com/connect" + "context" + "fmt" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/labd/terraform-provider-wundergraph/sdk/wg/cosmo/common" + platformv1 "github.com/labd/terraform-provider-wundergraph/sdk/wg/cosmo/platform/v1" + "github.com/labd/terraform-provider-wundergraph/sdk/wg/cosmo/platform/v1/platformv1connect" +) + +// Ensure provider defined types fully satisfy framework interfaces. +var _ resource.Resource = &FederatedSubgraphResource{} +var _ resource.ResourceWithImportState = &FederatedSubgraphResource{} + +func NewFederatedSubgraphResource() resource.Resource { + return &FederatedSubgraphResource{} +} + +// FederatedSubgraphResource defines the resource implementation. +type FederatedSubgraphResource struct { + client platformv1connect.PlatformServiceClient +} + +// FederatedSubgraphModel describes the resource data model. +type FederatedSubgraphModel struct { + Id types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + Namespace types.String `tfsdk:"namespace"` + RoutingUrl types.String `tfsdk:"routing_url"` + Schema types.String `tfsdk:"schema"` + SubscriptionUrl types.String `tfsdk:"subscription_url"` + SubscriptionProtocol types.String `tfsdk:"subscription_protocol"` + WebsocketSubprotocol types.String `tfsdk:"websocket_subprotocol"` + Labels types.Map `tfsdk:"labels"` + IsEventDrivenGraph types.Bool `tfsdk:"is_event_driven_graph"` + IsFeatureSubgraph types.Bool `tfsdk:"is_feature_subgraph"` +} + +func (r *FederatedSubgraphResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_federated_subgraph" +} + +func (r *FederatedSubgraphResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: "federated subgraph resource.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "Identifier", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "name": schema.StringAttribute{ + MarkdownDescription: "The name of the subgraph to create. It is usually in the format of . and is used to uniquely identify your federated subgraph.", + Required: true, + }, + "namespace": schema.StringAttribute{ + MarkdownDescription: "The namespace name of the subgraph. Defaults to default.", + Optional: true, + Computed: true, + Default: stringdefault.StaticString("default"), + }, + "routing_url": schema.StringAttribute{ + MarkdownDescription: "The routing URL of your subgraph. This is the url at which the subgraph will be accessible. Required unless the event-driven-graph flag is set. Returns an error if the event-driven-graph flag is set.", + Optional: true, + }, + "schema": schema.StringAttribute{ + MarkdownDescription: "The schema to upload to the subgraph. This should be the full schema in SDL format.", + Required: true, + }, + "subscription_url": schema.StringAttribute{ + MarkdownDescription: "The protocol to use when subscribing to the subgraph. The supported protocols are ws, sse, and sse_post. Returns an error if the event-driven-graph flag is set.", + Optional: true, + }, + "subscription_protocol": schema.StringAttribute{ + MarkdownDescription: "The protocol to use when subscribing to the subgraph. The supported protocols are ws, sse, and sse_post.", + Optional: true, + Computed: true, + Default: stringdefault.StaticString("ws"), + Validators: []validator.String{ + stringvalidator.OneOf("ws", "sse", "sse_post"), + }, + }, + "websocket_subprotocol": schema.StringAttribute{ + MarkdownDescription: "The subprotocol to use when subscribing to the subgraph. The supported protocols are auto, graphql-ws, and graphql-transport-ws. Should be used only if the subscription protocol is ws. For more information see https://cosmo-docs.wundergraph.com/router/subscriptions/websocket-subprotocols.", + Optional: true, + Computed: true, + Default: stringdefault.StaticString("auto"), + Validators: []validator.String{ + stringvalidator.OneOf("auto", "graphql-ws", "graphql-transport-ws"), + }, + }, + "labels": schema.MapAttribute{ + ElementType: types.StringType, + MarkdownDescription: "The labels to apply to the subgraph.", + Optional: true, + }, + //Readme cannot be implemented because the get endpoint does not return it, so we don't know the actual state + //"readme": schema.StringAttribute{ + // MarkdownDescription: "The markdown text which describes the subgraph", + // Optional: true, + //}, + "is_event_driven_graph": schema.BoolAttribute{ + MarkdownDescription: "Set whether the subgraph is an Event-Driven Graph (EDG). Errors will be returned for the inclusion of most other parameters if the subgraph is an Event-Driven Graph.", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + }, + "is_feature_subgraph": schema.BoolAttribute{ + MarkdownDescription: "Set whether the subgraph is a feature subgraph.", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + }, + }, + } +} + +func (r *FederatedSubgraphResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(platformv1connect.PlatformServiceClient) + + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *http.Client, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + + return + } + + r.client = client +} + +func MapSubscriptionProtocol(sp types.String) (*common.GraphQLSubscriptionProtocol, error) { + if sp.IsNull() || sp.IsUnknown() { + p := common.GraphQLSubscriptionProtocol_GRAPHQL_SUBSCRIPTION_PROTOCOL_WS + return &p, nil + } + + var p common.GraphQLSubscriptionProtocol + switch sp.ValueString() { + case "ws": + p = common.GraphQLSubscriptionProtocol_GRAPHQL_SUBSCRIPTION_PROTOCOL_WS + case "sse": + p = common.GraphQLSubscriptionProtocol_GRAPHQL_SUBSCRIPTION_PROTOCOL_SSE + case "sse_post": + p = common.GraphQLSubscriptionProtocol_GRAPHQL_SUBSCRIPTION_PROTOCOL_SSE_POST + default: + return nil, fmt.Errorf("unsupported protocol: %s", sp.ValueString()) + } + + return &p, nil +} + +func MapWebSocketSubprotocol(wsp types.String) (*common.GraphQLWebsocketSubprotocol, error) { + if wsp.IsNull() || wsp.IsUnknown() { + var p = common.GraphQLWebsocketSubprotocol_GRAPHQL_WEBSOCKET_SUBPROTOCOL_AUTO + return &p, nil + } + + var p common.GraphQLWebsocketSubprotocol + switch wsp.ValueString() { + case "auto": + p = common.GraphQLWebsocketSubprotocol_GRAPHQL_WEBSOCKET_SUBPROTOCOL_AUTO + case "graphql-ws": + p = common.GraphQLWebsocketSubprotocol_GRAPHQL_WEBSOCKET_SUBPROTOCOL_WS + case "graphql-transport-ws": + p = common.GraphQLWebsocketSubprotocol_GRAPHQL_WEBSOCKET_SUBPROTOCOL_TRANSPORT_WS + default: + return nil, fmt.Errorf("unsupported protocol: %s", wsp.ValueString()) + } + + return &p, nil +} + +func MapLabelsToNative(labels map[string]string) []*platformv1.Label { + var l []*platformv1.Label + for k, v := range labels { + l = append(l, &platformv1.Label{ + Key: k, + Value: v, + }) + } + + return l +} + +func (r *FederatedSubgraphResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan *FederatedSubgraphModel + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + + p, err := MapSubscriptionProtocol(plan.SubscriptionProtocol) + if err != nil { + resp.Diagnostics.AddError("Error creating subgraph", err.Error()) + return + } + + w, err := MapWebSocketSubprotocol(plan.WebsocketSubprotocol) + if err != nil { + resp.Diagnostics.AddError("Error creating subgraph", err.Error()) + return + } + + var labels map[string]string + diags = plan.Labels.ElementsAs(ctx, &labels, false) + if diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + + //We first create the subgraph + rc, err := r.client.CreateFederatedSubgraph(ctx, &connect.Request[platformv1.CreateFederatedSubgraphRequest]{ + Msg: &platformv1.CreateFederatedSubgraphRequest{ + Name: plan.Name.ValueString(), + Namespace: plan.Namespace.ValueString(), + RoutingUrl: plan.RoutingUrl.ValueStringPointer(), + SubscriptionUrl: plan.SubscriptionUrl.ValueStringPointer(), + SubscriptionProtocol: p, + WebsocketSubprotocol: w, + Labels: MapLabelsToNative(labels), + IsEventDrivenGraph: plan.IsEventDrivenGraph.ValueBoolPointer(), + IsFeatureSubgraph: plan.IsFeatureSubgraph.ValueBoolPointer(), + }, + }) + if err != nil { + resp.Diagnostics.AddError("Error creating subgraph", err.Error()) + return + } + + if rc.Msg.GetResponse().Code != common.EnumStatusCode_OK { + resp.Diagnostics.AddError("Error creating subgraph", rc.Msg.GetResponse().GetDetails()) + return + } + + // We fetch the subgraph list to get the requested subgraph, as we don't have a direct read endpoint. + ns, err := r.client.GetSubgraphs(ctx, &connect.Request[platformv1.GetSubgraphsRequest]{ + Msg: &platformv1.GetSubgraphsRequest{ + Namespace: plan.Namespace.ValueString(), + }, + }) + if err != nil { + resp.Diagnostics.AddError("Error reading subgraph", err.Error()) + return + } + + var isFound bool + for _, n := range ns.Msg.Graphs { + if n.Name == plan.Name.ValueString() { + plan.Id = types.StringValue(n.Id) + isFound = true + continue + } + } + + if !isFound { + resp.Diagnostics.AddError("Error reading subgraph", "subgraph not found") + return + } + + // We now publish the first subgraph + rp, err := r.client.PublishFederatedSubgraph(ctx, &connect.Request[platformv1.PublishFederatedSubgraphRequest]{ + Msg: &platformv1.PublishFederatedSubgraphRequest{ + Name: plan.Name.ValueString(), + Namespace: plan.Namespace.ValueString(), + Schema: plan.Schema.ValueString(), + }, + }) + if err != nil { + resp.Diagnostics.AddError("Error updating schema", err.Error()) + return + } + + if rp.Msg.GetResponse().Code != common.EnumStatusCode_OK { + resp.Diagnostics.AddError("Error creating subgraph", rp.Msg.GetResponse().GetDetails()) + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func MapLabelsFromNative(labels []*platformv1.Label) map[string]string { + l := make(map[string]string) + for _, v := range labels { + l[v.Key] = v.Value + } + + return l +} + +func (r *FederatedSubgraphResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var data *FederatedSubgraphModel + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + // We fetch the subgraph list to get the requested subgraph, as we don't have a direct read endpoint. + ns, err := r.client.GetSubgraphs(ctx, &connect.Request[platformv1.GetSubgraphsRequest]{ + Msg: &platformv1.GetSubgraphsRequest{ + Namespace: data.Namespace.ValueString(), + }, + }) + if err != nil { + resp.Diagnostics.AddError("Error reading subgraph", err.Error()) + return + } + + if ns.Msg.GetResponse().Code != common.EnumStatusCode_OK { + resp.Diagnostics.AddError("Error fetching subgraph list", ns.Msg.GetResponse().GetDetails()) + return + } + + var current *FederatedSubgraphModel + for _, n := range ns.Msg.Graphs { + if n.Id == data.Id.ValueString() { + // We need to check if the subscription url is empty, as it is optional. If an empty string is returned we assume it is nil. + var subscriptionUrl *string = nil + if n.SubscriptionUrl != "" { + subscriptionUrl = &n.SubscriptionUrl + } + + labels, diags := types.MapValueFrom(ctx, types.StringType, MapLabelsFromNative(n.Labels)) + if diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + + current = &FederatedSubgraphModel{ + Id: types.StringValue(n.Id), + Name: types.StringValue(n.Name), + Namespace: types.StringValue(n.Namespace), + RoutingUrl: types.StringValue(n.RoutingURL), + SubscriptionUrl: types.StringPointerValue(subscriptionUrl), + IsEventDrivenGraph: types.BoolValue(n.IsEventDrivenGraph), + IsFeatureSubgraph: types.BoolValue(n.IsFeatureSubgraph), + SubscriptionProtocol: types.StringValue(n.SubscriptionProtocol), + WebsocketSubprotocol: types.StringValue(n.WebsocketSubprotocol), + Labels: labels, + } + continue + } + } + + if current == nil { + resp.Diagnostics.AddError("Error reading federated subgraph", "federated subgraph not found") + return + } + + sdl, err := r.client.GetLatestSubgraphSDL(ctx, &connect.Request[platformv1.GetLatestSubgraphSDLRequest]{ + Msg: &platformv1.GetLatestSubgraphSDLRequest{ + Namespace: data.Namespace.ValueString(), + Name: data.Name.ValueString(), + }, + }) + if err != nil { + resp.Diagnostics.AddError("Error fetching sdl", err.Error()) + return + } + + if sdl.Msg.GetResponse().Code != common.EnumStatusCode_OK { + resp.Diagnostics.AddError("Error fetching SDL", sdl.Msg.GetResponse().GetDetails()) + return + } + + current.Schema = types.StringPointerValue(sdl.Msg.Sdl) + + resp.Diagnostics.Append(resp.State.Set(ctx, ¤t)...) +} + +func (r *FederatedSubgraphResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan FederatedSubgraphModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + var state FederatedSubgraphModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + //TODO: check if a rename is intended. If that is the case we delete the old subgraph and create a new one. + + //First we update the schema itself + if !plan.Schema.Equal(state.Schema) { + rp, err := r.client.PublishFederatedSubgraph(ctx, &connect.Request[platformv1.PublishFederatedSubgraphRequest]{ + Msg: &platformv1.PublishFederatedSubgraphRequest{ + Name: plan.Name.ValueString(), + Namespace: plan.Namespace.ValueString(), + Schema: plan.Schema.ValueString(), + }, + }) + if err != nil { + resp.Diagnostics.AddError("Error updating schema", err.Error()) + return + } + + if rp.Msg.GetResponse().Code != common.EnumStatusCode_OK { + resp.Diagnostics.AddError("Error updating subgraph", rp.Msg.GetResponse().GetDetails()) + return + } + } + + //Then we check if the namespace needs to be moved + if !plan.Namespace.Equal(state.Namespace) { + rm, err := r.client.MoveSubgraph(ctx, &connect.Request[platformv1.MoveGraphRequest]{ + Msg: &platformv1.MoveGraphRequest{ + Name: state.Name.ValueString(), + Namespace: state.Namespace.ValueString(), + NewNamespace: plan.Namespace.ValueString(), + }, + }) + if err != nil { + resp.Diagnostics.AddError("Error moving namespace", err.Error()) + return + } + + if rm.Msg.GetResponse().Code != common.EnumStatusCode_OK { + resp.Diagnostics.AddError("Error updating subgraph", rm.Msg.GetResponse().GetDetails()) + return + } + } + + p, err := MapSubscriptionProtocol(plan.SubscriptionProtocol) + if err != nil { + resp.Diagnostics.AddError("Error creating subgraph", err.Error()) + return + } + + w, err := MapWebSocketSubprotocol(plan.WebsocketSubprotocol) + if err != nil { + resp.Diagnostics.AddError("Error creating subgraph", err.Error()) + return + } + + var labels map[string]string + diags := plan.Labels.ElementsAs(ctx, &labels, false) + if diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + + ru, err := r.client.UpdateSubgraph(ctx, &connect.Request[platformv1.UpdateSubgraphRequest]{ + Msg: &platformv1.UpdateSubgraphRequest{ + Name: plan.Name.ValueString(), + Namespace: plan.Namespace.ValueString(), + RoutingUrl: plan.RoutingUrl.ValueStringPointer(), + SubscriptionUrl: plan.SubscriptionUrl.ValueStringPointer(), + Labels: MapLabelsToNative(labels), + SubscriptionProtocol: p, + WebsocketSubprotocol: w, + }, + }) + if err != nil { + resp.Diagnostics.AddError("Error reading subgraph", err.Error()) + return + } + + if ru.Msg.GetResponse().Code != common.EnumStatusCode_OK { + resp.Diagnostics.AddError("Error updating subgraph", ru.Msg.GetResponse().GetDetails()) + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *FederatedSubgraphResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var data *FederatedSubgraphModel + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + _, err := r.client.DeleteFederatedSubgraph(ctx, &connect.Request[platformv1.DeleteFederatedSubgraphRequest]{ + Msg: &platformv1.DeleteFederatedSubgraphRequest{ + SubgraphName: data.Name.ValueString(), + Namespace: data.Namespace.ValueString(), + }, + }) + + if err != nil { + resp.Diagnostics.AddError("Error deleting subgraph", err.Error()) + return + } +} + +func (r *FederatedSubgraphResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) +} diff --git a/internal/resources/federated_subgraph_test.go b/internal/resources/federated_subgraph_test.go new file mode 100644 index 0000000..39ef8d3 --- /dev/null +++ b/internal/resources/federated_subgraph_test.go @@ -0,0 +1,168 @@ +package resources + +import ( + "github.com/labd/terraform-provider-wundergraph/sdk/wg/cosmo/common" + platformv1 "github.com/labd/terraform-provider-wundergraph/sdk/wg/cosmo/platform/v1" + "testing" + + _ "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/stretchr/testify/assert" +) + +func TestMapSubscriptionProtocol(t *testing.T) { + tests := []struct { + name string + protocol types.String + expected common.GraphQLSubscriptionProtocol + expectError bool + }{ + { + name: "ValidInputWS", + protocol: types.StringValue("ws"), + expected: common.GraphQLSubscriptionProtocol_GRAPHQL_SUBSCRIPTION_PROTOCOL_WS, + expectError: false, + }, + { + name: "ValidInputSSE", + protocol: types.StringValue("sse"), + expected: common.GraphQLSubscriptionProtocol_GRAPHQL_SUBSCRIPTION_PROTOCOL_SSE, + expectError: false, + }, + { + name: "ValidInputSSEPost", + protocol: types.StringValue("sse_post"), + expected: common.GraphQLSubscriptionProtocol_GRAPHQL_SUBSCRIPTION_PROTOCOL_SSE_POST, + expectError: false, + }, + { + name: "InvalidInput", + protocol: types.StringValue("invalid"), + expectError: true, + }, + { + name: "NullInput", + protocol: types.StringNull(), + expected: common.GraphQLSubscriptionProtocol_GRAPHQL_SUBSCRIPTION_PROTOCOL_WS, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := MapSubscriptionProtocol(tt.protocol) + + if tt.expectError { + assert.Error(t, err) + assert.Nil(t, result) + } else { + assert.NoError(t, err) + assert.Equal(t, &tt.expected, result) + } + }) + } +} + +func TestMapWebSocketSubprotocol(t *testing.T) { + tests := []struct { + name string + subprotocol types.String + expected common.GraphQLWebsocketSubprotocol + expectError bool + }{ + { + name: "ValidInputAuto", + subprotocol: types.StringValue("auto"), + expected: common.GraphQLWebsocketSubprotocol_GRAPHQL_WEBSOCKET_SUBPROTOCOL_AUTO, + expectError: false, + }, + { + name: "ValidInputGraphQLWS", + subprotocol: types.StringValue("graphql-ws"), + expected: common.GraphQLWebsocketSubprotocol_GRAPHQL_WEBSOCKET_SUBPROTOCOL_WS, + expectError: false, + }, + { + name: "ValidInputGraphQLTransportWS", + subprotocol: types.StringValue("graphql-transport-ws"), + expected: common.GraphQLWebsocketSubprotocol_GRAPHQL_WEBSOCKET_SUBPROTOCOL_TRANSPORT_WS, + expectError: false, + }, + { + name: "InvalidInput", + subprotocol: types.StringValue("invalid"), + expectError: true, + }, + { + name: "NullInput", + subprotocol: types.StringNull(), + expected: common.GraphQLWebsocketSubprotocol_GRAPHQL_WEBSOCKET_SUBPROTOCOL_AUTO, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := MapWebSocketSubprotocol(tt.subprotocol) + + if tt.expectError { + assert.Error(t, err) + assert.Nil(t, result) + } else { + assert.NoError(t, err) + assert.Equal(t, &tt.expected, result) + } + }) + } +} + +func TestMapLabelsToNative(t *testing.T) { + tests := []struct { + name string + labels map[string]string + expected []*platformv1.Label + }{ + { + name: "ValidInput", + labels: map[string]string{"key1": "value1", "key2": "value2"}, + expected: []*platformv1.Label{{Key: "key1", Value: "value1"}, {Key: "key2", Value: "value2"}}, + }, + { + name: "EmptyInput", + labels: map[string]string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := MapLabelsToNative(tt.labels) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestMapLabelsFromNative(t *testing.T) { + tests := []struct { + name string + labels []*platformv1.Label + expected map[string]string + }{ + { + name: "ValidInput", + labels: []*platformv1.Label{{Key: "key1", Value: "value1"}, {Key: "key2", Value: "value2"}}, + expected: map[string]string{"key1": "value1", "key2": "value2"}, + }, + { + name: "EmptyInput", + labels: []*platformv1.Label{}, + expected: map[string]string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := MapLabelsFromNative(tt.labels) + assert.Equal(t, tt.expected, result) + }) + } +}