From f73bd87767392b45f5dd0351343f9f47839ada0d Mon Sep 17 00:00:00 2001 From: Ricard Bejarano Date: Thu, 13 Feb 2025 18:35:40 +0100 Subject: [PATCH] `function/parse_duration`: Add duration parsing function (#350) * function/parse_duration: Add duration parsing function * Renamed parse_duration to duration_parse * Added link to Go duration strings in duration_parse function summary and description * Use static assertions in duration_parse tests instead * Added CHANGELOG entry * Removed unused import * Updated time.ParseDuration * Output of make generate --- .../unreleased/FEATURES-20250123-191529.yaml | 5 + docs/functions/duration_parse.md | 54 ++++++++ examples/functions/duration_parse/function.tf | 16 +++ internal/provider/function_duration_parse.go | 91 +++++++++++++ .../provider/function_duration_parse_test.go | 125 ++++++++++++++++++ internal/provider/provider.go | 1 + templates/functions/duration_parse.md.tmpl | 37 ++++++ 7 files changed, 329 insertions(+) create mode 100644 .changes/unreleased/FEATURES-20250123-191529.yaml create mode 100644 docs/functions/duration_parse.md create mode 100644 examples/functions/duration_parse/function.tf create mode 100644 internal/provider/function_duration_parse.go create mode 100644 internal/provider/function_duration_parse_test.go create mode 100644 templates/functions/duration_parse.md.tmpl diff --git a/.changes/unreleased/FEATURES-20250123-191529.yaml b/.changes/unreleased/FEATURES-20250123-191529.yaml new file mode 100644 index 00000000..37c44c65 --- /dev/null +++ b/.changes/unreleased/FEATURES-20250123-191529.yaml @@ -0,0 +1,5 @@ +kind: FEATURES +body: 'functions/duration_parse: Added a new `duration_parse` function that parses [Go duration strings](https://pkg.go.dev/time#ParseDuration).' +time: 2025-01-23T19:15:29.754845+01:00 +custom: + Issue: "350" diff --git a/docs/functions/duration_parse.md b/docs/functions/duration_parse.md new file mode 100644 index 00000000..bf36b789 --- /dev/null +++ b/docs/functions/duration_parse.md @@ -0,0 +1,54 @@ +--- +page_title: "duration_parse function - terraform-provider-time" +subcategory: "" +description: |- + Parse a Go duration string https://pkg.go.dev/time#ParseDuration into an object +--- + +# function: duration_parse + +Given a [Go duration string](https://pkg.go.dev/time#ParseDuration), will parse and return an object representation of that duration. + +## Example Usage + +```terraform +# Configuration using provider functions must include required_providers configuration. +terraform { + required_providers { + time = { + source = "hashicorp/time" + # Setting the provider version is a strongly recommended practice + # version = "..." + } + } + # Provider functions require Terraform 1.8 and later. + required_version = ">= 1.8.0" +} + +output "example_output" { + value = provider::time::duration_parse("1h") +} +``` + +## Signature + + +```text +duration_parse(duration string) object +``` + +## Arguments + + +1. `duration` (String) Go time package duration string to parse + + +## Return Type + +The `object` returned from `duration_parse` has the following attributes: +- `hours` (Number) The duration as a floating point number of hours. +- `minutes` (Number) The duration as a floating point number of minutes. +- `seconds` (Number) The duration as a floating point number of seconds. +- `milliseconds` (Number) The duration as an integer number of milliseconds. +- `microseconds` (Number) The duration as an integer number of microseconds. +- `nanoseconds` (Number) The duration as an integer number of nanoseconds. diff --git a/examples/functions/duration_parse/function.tf b/examples/functions/duration_parse/function.tf new file mode 100644 index 00000000..809a5f54 --- /dev/null +++ b/examples/functions/duration_parse/function.tf @@ -0,0 +1,16 @@ +# Configuration using provider functions must include required_providers configuration. +terraform { + required_providers { + time = { + source = "hashicorp/time" + # Setting the provider version is a strongly recommended practice + # version = "..." + } + } + # Provider functions require Terraform 1.8 and later. + required_version = ">= 1.8.0" +} + +output "example_output" { + value = provider::time::duration_parse("1h") +} diff --git a/internal/provider/function_duration_parse.go b/internal/provider/function_duration_parse.go new file mode 100644 index 00000000..6eee6167 --- /dev/null +++ b/internal/provider/function_duration_parse.go @@ -0,0 +1,91 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package provider + +import ( + "context" + "fmt" + "time" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/function" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" +) + +var durationParseReturnAttrTypes = map[string]attr.Type{ + "hours": types.Float64Type, + "minutes": types.Float64Type, + "seconds": types.Float64Type, + "milliseconds": types.Int64Type, + "microseconds": types.Int64Type, + "nanoseconds": types.Int64Type, +} + +var _ function.Function = &DurationParseFunction{} + +type DurationParseFunction struct{} + +func NewDurationParseFunction() function.Function { + return &DurationParseFunction{} +} + +func (f *DurationParseFunction) Metadata(ctx context.Context, req function.MetadataRequest, resp *function.MetadataResponse) { + resp.Name = "duration_parse" +} + +func (f *DurationParseFunction) Definition(ctx context.Context, req function.DefinitionRequest, resp *function.DefinitionResponse) { + resp.Definition = function.Definition{ + Summary: "Parse a [Go duration string](https://pkg.go.dev/time#ParseDuration) into an object", + Description: "Given a [Go duration string](https://pkg.go.dev/time#ParseDuration), will parse and return an object representation of that duration.", + + Parameters: []function.Parameter{ + function.StringParameter{ + Name: "duration", + Description: "Go time package duration string to parse", + }, + }, + Return: function.ObjectReturn{ + AttributeTypes: durationParseReturnAttrTypes, + }, + } +} + +func (f *DurationParseFunction) Run(ctx context.Context, req function.RunRequest, resp *function.RunResponse) { + var input string + + resp.Error = req.Arguments.Get(ctx, &input) + if resp.Error != nil { + return + } + + duration, err := time.ParseDuration(input) + if err != nil { + // Intentionally not including the Go parse error in the return diagnostic, as the message is based on a Go-specific + // reference time that may be unfamiliar to practitioners + tflog.Error(ctx, fmt.Sprintf("failed to parse duration string, underlying time.Duration error: %s", err.Error())) + + resp.Error = function.NewArgumentFuncError(0, fmt.Sprintf("Error parsing duration string: %q is not a valid duration string", input)) + return + } + + durationObj, diags := types.ObjectValue( + durationParseReturnAttrTypes, + map[string]attr.Value{ + "hours": types.Float64Value(duration.Hours()), + "minutes": types.Float64Value(duration.Minutes()), + "seconds": types.Float64Value(duration.Seconds()), + "milliseconds": types.Int64Value(duration.Milliseconds()), + "microseconds": types.Int64Value(duration.Microseconds()), + "nanoseconds": types.Int64Value(duration.Nanoseconds()), + }, + ) + + resp.Error = function.FuncErrorFromDiags(ctx, diags) + if resp.Error != nil { + return + } + + resp.Error = resp.Result.Set(ctx, &durationObj) +} diff --git a/internal/provider/function_duration_parse_test.go b/internal/provider/function_duration_parse_test.go new file mode 100644 index 00000000..2828fa8e --- /dev/null +++ b/internal/provider/function_duration_parse_test.go @@ -0,0 +1,125 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package provider + +import ( + "regexp" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/knownvalue" + "github.com/hashicorp/terraform-plugin-testing/plancheck" + "github.com/hashicorp/terraform-plugin-testing/tfversion" +) + +func TestDurationParse_valid(t *testing.T) { + resource.UnitTest(t, resource.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_8_0), + }, + ProtoV5ProviderFactories: protoV5ProviderFactories(), + Steps: []resource.TestStep{ + { + Config: ` + output "test" { + value = provider::time::duration_parse("1h") + } + `, + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectKnownOutputValue("test", knownvalue.ObjectExact( + map[string]knownvalue.Check{ + "hours": knownvalue.Float64Exact(1), + "minutes": knownvalue.Float64Exact(60), + "seconds": knownvalue.Float64Exact(3600), + "milliseconds": knownvalue.Int64Exact(3600000), + "microseconds": knownvalue.Int64Exact(3600000000), + "nanoseconds": knownvalue.Int64Exact(3600000000000), + }, + )), + }, + }, + }, + { + Config: ` + output "test" { + value = provider::time::duration_parse("60m") + } + `, + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectEmptyPlan(), + }, + }, + }, + { + Config: ` + output "test" { + value = provider::time::duration_parse("3600s") + } + `, + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectEmptyPlan(), + }, + }, + }, + { + Config: ` + output "test" { + value = provider::time::duration_parse("3600000ms") + } + `, + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectEmptyPlan(), + }, + }, + }, + { + Config: ` + output "test" { + value = provider::time::duration_parse("3600000000us") + } + `, + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectEmptyPlan(), + }, + }, + }, + { + Config: ` + output "test" { + value = provider::time::duration_parse("3600000000000ns") + } + `, + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectEmptyPlan(), + }, + }, + }, + }, + }) +} + +func TestDurationParse_invalid(t *testing.T) { + resource.UnitTest(t, resource.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_8_0), + }, + ProtoV5ProviderFactories: protoV5ProviderFactories(), + Steps: []resource.TestStep{ + { + Config: ` + output "test" { + value = provider::time::duration_parse("abcdef") + } + `, + ExpectError: regexp.MustCompile(`"abcdef" is not a valid duration string.`), + }, + }, + }) +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index fa1ddb9b..8e44e93e 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -60,6 +60,7 @@ func (p *timeProvider) Schema(context.Context, provider.SchemaRequest, *provider func (p *timeProvider) Functions(ctx context.Context) []func() function.Function { return []func() function.Function{ + NewDurationParseFunction, NewRFC3339ParseFunction, } } diff --git a/templates/functions/duration_parse.md.tmpl b/templates/functions/duration_parse.md.tmpl new file mode 100644 index 00000000..58a2b7fe --- /dev/null +++ b/templates/functions/duration_parse.md.tmpl @@ -0,0 +1,37 @@ +--- +page_title: "{{.Name}} {{.Type}} - {{.ProviderName}}" +subcategory: "" +description: |- +{{ .Summary | plainmarkdown | trimspace | prefixlines " " }} +--- + +# {{.Type}}: {{.Name}} + +{{ .Description | trimspace }} + +{{ if .HasExample -}} +## Example Usage + +{{tffile .ExampleFile }} +{{- end }} + +## Signature + +{{ .FunctionSignatureMarkdown }} + +## Arguments + +{{ .FunctionArgumentsMarkdown }} +{{ if .HasVariadic -}} +{{ .FunctionVariadicArgumentMarkdown }} +{{- end }} + +## Return Type + +The `object` returned from `duration_parse` has the following attributes: +- `hours` (Number) The duration as a floating point number of hours. +- `minutes` (Number) The duration as a floating point number of minutes. +- `seconds` (Number) The duration as a floating point number of seconds. +- `milliseconds` (Number) The duration as an integer number of milliseconds. +- `microseconds` (Number) The duration as an integer number of microseconds. +- `nanoseconds` (Number) The duration as an integer number of nanoseconds.