From 0f8b8f5baa58291dbea717091cf02113a6cdde5c Mon Sep 17 00:00:00 2001 From: Agustin Bettati Date: Thu, 24 Oct 2024 16:45:51 +0200 Subject: [PATCH] chore: Merging schema generation internal tool PoC into master branch (#2735) * feat: Adds initial schema and config models for PoC - Model generation (#2638) * update computability type (#2668) * chore: PoC - Model generation - support primitive types at root level (#2673) * chore: PoC - Schema code generation - Initial support of primitive types (#2682) * initial commit with schema generation function and test fixture * small changes wip * include specific type generators * handling element types and imports * remove unrelated file * extract template logic to separate file * small revert change * extract import to const * follow up adjustments from PR comments and sync with Aastha * chore: PoC - Schema code generation - Supporting nested attributes (#2689) * support nested attribute types * rebasing changes related to unit testing adjustment * chore: PoC - Model generation - Supporting nested schema (List, ListNested, Set & SetNested) (#2693) * chore: PoC - Model generation - Supporting nested schema (objects - Map, MapNested, SingleNested Attributes) (#2704) * chore: PoC - Schema code generation - Supporting generation of typed model (#2703) * support typed model generation inlcuding root and nested attributes * minor fix for type of types map * add clarifying comment * improve name of generated object types, refactor function naming for readability * fix list map and set nested attribute generation (#2708) * chore: PoC - Model generation - support config aliases, ignores, and description overrides (#2712) * chore: PoC - Define make command to call code generation logic (#2706) * wip * iterating over all resources * add config for search deployment * update golden file test with fix in package name * use xstring implementation for pascal case * simplify write file logic * merge fixes * chore: PoC - Support configuration of timeout in schema (#2717) * wip * rebase fixes * fix logic avoiding adding timeout in nested schemas * fix generation * fix enum processing * fix golden file in timeout test * comment out unsupported config properties * simplify interfaces for attribute generation leaving common code in a separate function * chore: PoC - handle merging computability in nested attributes (#2722) * adjusting contributing guide (#2732) --------- Co-authored-by: maastha <122359335+maastha@users.noreply.github.com> Co-authored-by: Aastha Mahendru --- .gitignore | 3 + GNUmakefile | 8 +- contributing/development-best-practices.md | 27 +- go.mod | 12 +- go.sum | 30 +- tools/codegen/codespec/api_spec.go | 93 ++++ tools/codegen/codespec/api_spec_schema.go | 59 +++ .../codespec/api_to_provider_spec_mapper.go | 222 +++++++++ .../api_to_provider_spec_mapper_test.go | 378 ++++++++++++++ tools/codegen/codespec/attribute.go | 272 ++++++++++ tools/codegen/codespec/computability.go | 10 + tools/codegen/codespec/config.go | 123 +++++ tools/codegen/codespec/constants.go | 21 + tools/codegen/codespec/element_type.go | 20 + tools/codegen/codespec/merge_attributes.go | 158 ++++++ tools/codegen/codespec/model.go | 119 +++++ tools/codegen/codespec/string_case.go | 21 + tools/codegen/codespec/terraform_helper.go | 28 ++ tools/codegen/codespec/testdata/api-spec.yml | 465 ++++++++++++++++++ .../config-nested-schema-overrides.yml | 28 ++ .../testdata/config-nested-schema.yml | 16 + .../testdata/config-no-schema-opts.yml | 14 + tools/codegen/codespec/testdata/config.yml | 36 ++ tools/codegen/config.yml | 45 ++ tools/codegen/config/config_model.go | 48 ++ tools/codegen/config/parser.go | 22 + tools/codegen/main.go | 54 ++ tools/codegen/openapi/parser.go | 58 +++ tools/codegen/schema/code_statement.go | 20 + .../schema/codetemplate/schema-file.go.tmpl | 18 + tools/codegen/schema/codetemplate/template.go | 32 ++ tools/codegen/schema/element_type_mapping.go | 25 + tools/codegen/schema/schema_attribute.go | 145 ++++++ .../codegen/schema/schema_attribute_nested.go | 65 +++ .../schema/schema_attribute_primitive.go | 75 +++ .../schema/schema_attribute_timeout.go | 33 ++ tools/codegen/schema/schema_file.go | 31 ++ tools/codegen/schema/schema_file_test.go | 178 +++++++ .../testdata/nested-attributes.golden.go | 126 +++++ .../testdata/primitive-attributes.golden.go | 62 +++ .../schema/testdata/timeouts.golden.go | 30 ++ tools/codegen/schema/typed_model.go | 114 +++++ 42 files changed, 3337 insertions(+), 7 deletions(-) create mode 100644 tools/codegen/codespec/api_spec.go create mode 100644 tools/codegen/codespec/api_spec_schema.go create mode 100644 tools/codegen/codespec/api_to_provider_spec_mapper.go create mode 100644 tools/codegen/codespec/api_to_provider_spec_mapper_test.go create mode 100644 tools/codegen/codespec/attribute.go create mode 100644 tools/codegen/codespec/computability.go create mode 100644 tools/codegen/codespec/config.go create mode 100644 tools/codegen/codespec/constants.go create mode 100644 tools/codegen/codespec/element_type.go create mode 100644 tools/codegen/codespec/merge_attributes.go create mode 100644 tools/codegen/codespec/model.go create mode 100644 tools/codegen/codespec/string_case.go create mode 100644 tools/codegen/codespec/terraform_helper.go create mode 100644 tools/codegen/codespec/testdata/api-spec.yml create mode 100644 tools/codegen/codespec/testdata/config-nested-schema-overrides.yml create mode 100644 tools/codegen/codespec/testdata/config-nested-schema.yml create mode 100644 tools/codegen/codespec/testdata/config-no-schema-opts.yml create mode 100644 tools/codegen/codespec/testdata/config.yml create mode 100644 tools/codegen/config.yml create mode 100644 tools/codegen/config/config_model.go create mode 100644 tools/codegen/config/parser.go create mode 100644 tools/codegen/main.go create mode 100644 tools/codegen/openapi/parser.go create mode 100644 tools/codegen/schema/code_statement.go create mode 100644 tools/codegen/schema/codetemplate/schema-file.go.tmpl create mode 100644 tools/codegen/schema/codetemplate/template.go create mode 100644 tools/codegen/schema/element_type_mapping.go create mode 100644 tools/codegen/schema/schema_attribute.go create mode 100644 tools/codegen/schema/schema_attribute_nested.go create mode 100644 tools/codegen/schema/schema_attribute_primitive.go create mode 100644 tools/codegen/schema/schema_attribute_timeout.go create mode 100644 tools/codegen/schema/schema_file.go create mode 100644 tools/codegen/schema/schema_file_test.go create mode 100644 tools/codegen/schema/testdata/nested-attributes.golden.go create mode 100644 tools/codegen/schema/testdata/primitive-attributes.golden.go create mode 100644 tools/codegen/schema/testdata/timeouts.golden.go create mode 100644 tools/codegen/schema/typed_model.go diff --git a/.gitignore b/.gitignore index 6366b3c11a..5e0f0deefb 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,6 @@ vendor/ *.winfile eol=crlf /.vs node_modules + +#used for schema code generation but is not commited to avoid constant updates +tools/codegen/open-api-spec.yml diff --git a/GNUmakefile b/GNUmakefile index ba3690b6b6..25e6b83d76 100644 --- a/GNUmakefile +++ b/GNUmakefile @@ -118,11 +118,17 @@ scaffold: @echo "Reminder: configure the new $(type) in provider.go" # e.g. run: make scaffold-schemas resource_name=streamInstance -# details on usage can be found in contributing/development-best-practices.md under "Scaffolding Schema and Model Definitions" +# details on usage can be found in contributing/development-best-practices.md under "Generating Schema and Model Definitions - Using schema generation HashiCorp tooling" .PHONY: scaffold-schemas scaffold-schemas: @scripts/schema-scaffold.sh $(resource_name) +# e.g. run: make generate-schema resource_name=search_deployment +# resource_name is optional, if not provided all configured resources will be generated +# details on usage can be found in contributing/development-best-practices.md under "Generating Schema and Model Definitions - Using internal tool" +.PHONY: generate-schema +generate-schema: + @go run ./tools/codegen/main.go $(resource_name) .PHONY: generate-doc # e.g. run: make generate-doc resource_name=search_deployment diff --git a/contributing/development-best-practices.md b/contributing/development-best-practices.md index 0f5a5574ba..159d812790 100644 --- a/contributing/development-best-practices.md +++ b/contributing/development-best-practices.md @@ -4,7 +4,7 @@ ## Table of Contents - [Creating New Resource and Data Sources](#creating-new-resources-and-data-sources) - [Scaffolding Initial Code and File Structure](#scaffolding-initial-code-and-file-structure) - - [Scaffolding Schema and Model Definitions](#scaffolding-schema-and-model-definitions) + - [Generating Schema and Model Definitions](#scaffolding-schema-and-model-definitions) - Each resource (and associated data sources) is in a package in `internal/service`. - There can be multiple helper files and they can also be used from other resources, e.g. `common_advanced_cluster.go` defines functions that are also used from other resources using `advancedcluster.FunctionName`. @@ -26,11 +26,30 @@ This will generate resource/data source files and accompanying test files needed As a follow up step, use [Scaffolding Schema and Model Definitions](#scaffolding-schema-and-model-definitions) to autogenerate the schema via the Open API specification. This will require making adjustments to the generated `./internal/service//tfplugingen/generator_config.yml` file. -#### Scaffolding Schema and Model Definitions +#### Generating Schema and Model Definitions + +##### (Recommended) Using internal tool + +The generation command makes use of a configuration file defined under [`./tools/codegen/config.yml`](../tools/codegen/config.yml). The structure of this configuration file can be found under [`./tools/codegen/config/config_model.go`](../tools/codegen/config/config_model.go). + +The generation command takes a single optional argument `resource_name`. If not provided, all resources defined in the configuration are generated. + +```bash +make generate-schema resource_name=search_deployment +``` + +As a result, content of schemas will be written into the corresponding resource packages: +`./internal/service//resource_schema.go` + +**Note**: Data source schema generation is currently not supported. + + +##### (Legacy) Using schema generation HashiCorp tooling + Complementary to the `scaffold` command, there is a command which generates the initial Terraform schema definition and associated Go types for a resource or data source. This processes leverages [Code Generation Tools](https://developer.hashicorp.com/terraform/plugin/code-generation) developed by HashiCorp, which in turn make use of the [Atlas Admin API](https://www.mongodb.com/docs/atlas/reference/api-resources-spec/v2/) OpenAPI Specification. -##### Running the command +###### Running the command Both `tfplugingen-openapi` and `tfplugingen-framework` must be installed. This can be done by running `make tools`. @@ -49,7 +68,7 @@ Note: if the resulting file paths already exist the content will be stored in fi Note: you can override the Open API description of a field with a custom description via the [overrides](https://developer.hashicorp.com/terraform/plugin/code-generation/openapi-generator#overriding-attribute-descriptions) param. See this [example](internal/service/searchdeployment/tfplugingen/generator_config.yml). -##### Considerations over generated schema and types +###### Considerations over generated schema and types - Generated Go type should include a TF prefix to follow the convention in our codebase, this will not be present in generated code. - Some attribute names may need to be adjusted if there is a difference in how they are named in Terraform vs the API. An examples of this is `group_id` → `project_id`. diff --git a/go.mod b/go.mod index f98101a916..eb95523f89 100644 --- a/go.mod +++ b/go.mod @@ -19,6 +19,8 @@ require ( github.com/hashicorp/terraform-plugin-sdk/v2 v2.34.0 github.com/hashicorp/terraform-plugin-testing v1.9.0 github.com/mongodb-forks/digest v1.1.0 + github.com/sebdah/goldie/v2 v2.5.5 + github.com/pb33f/libopenapi v0.18.1 github.com/spf13/cast v1.6.0 github.com/stretchr/testify v1.9.0 github.com/zclconf/go-cty v1.15.0 @@ -26,6 +28,7 @@ require ( go.mongodb.org/atlas-sdk/v20240530005 v20240530005.0.0 go.mongodb.org/atlas-sdk/v20240805005 v20240805005.0.0 go.mongodb.org/realm v0.1.0 + gopkg.in/yaml.v3 v3.0.1 ) require ( @@ -43,11 +46,14 @@ require ( github.com/apparentlymart/go-cidr v1.1.0 // indirect github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect github.com/armon/go-radix v1.0.0 // indirect + github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d // indirect github.com/bgentry/speakeasy v0.1.0 // indirect + github.com/buger/jsonparser v1.1.1 // indirect github.com/cloudflare/circl v1.3.7 // indirect github.com/cyphar/filepath-securejoin v0.2.4 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dprotaso/go-yit v0.0.0-20240618133044-5a0af90af097 // indirect github.com/emirpasic/gods v1.18.1 // indirect github.com/fatih/color v1.16.0 // indirect github.com/fatih/structs v1.1.0 // indirect @@ -89,6 +95,7 @@ require ( github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/kevinburke/ssh_config v1.2.0 // indirect github.com/klauspost/compress v1.15.11 // indirect + github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mitchellh/cli v1.1.5 // indirect @@ -102,6 +109,7 @@ require ( github.com/pjbgf/sha1cd v0.3.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/posener/complete v1.2.3 // indirect + github.com/rogpeppe/go-internal v1.12.0 // indirect github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect github.com/shopspring/decimal v1.2.0 // indirect github.com/skeema/knownhosts v1.2.2 // indirect @@ -112,6 +120,8 @@ require ( github.com/vmihailenco/msgpack v4.0.4+incompatible // indirect github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect + github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect + github.com/wk8/go-ordered-map/v2 v2.1.9-0.20240815153524-6ea36470d1bd // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/zclconf/go-cty-yaml v1.0.2 // indirect go.opencensus.io v0.24.0 // indirect @@ -137,7 +147,7 @@ require ( google.golang.org/grpc v1.66.2 // indirect google.golang.org/protobuf v1.34.2 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect + ) exclude github.com/denis-tingajkin/go-header v0.4.2 diff --git a/go.sum b/go.sum index 0a39999bd8..395935e231 100644 --- a/go.sum +++ b/go.sum @@ -243,12 +243,16 @@ github.com/aws/aws-sdk-go v1.37.0/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zK github.com/aws/aws-sdk-go v1.44.122/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo= github.com/aws/aws-sdk-go v1.55.5 h1:KKUZBfBoyqy5d3swXyiC7Q76ic40rYcbqH7qjh59kzU= github.com/aws/aws-sdk-go v1.55.5/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d h1:xDfNPAt8lFiC1UJrqV3uuy861HCTo708pDMbjHHdCas= github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d/go.mod h1:6QX/PXZ00z/TKoufEY6K/a0k6AhaJrQKdFe6OfVXsa4= github.com/bgentry/speakeasy v0.1.0 h1:ByYyxL9InA1OWqxJqqp2A5pYHUrCiAL6K3J+LKSsQkY= github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= github.com/bufbuild/protocompile v0.4.0 h1:LbFKd2XowZvQ/kajzguUp2DC9UEIQhIq77fZZlaQsNA= github.com/bufbuild/protocompile v0.4.0/go.mod h1:3v93+mbWn/v3xzN+31nwkJfrEpAUwp+BagBSZWx+TP8= +github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= +github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= @@ -278,6 +282,9 @@ github.com/cyphar/filepath-securejoin v0.2.4/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxG github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58= +github.com/dprotaso/go-yit v0.0.0-20240618133044-5a0af90af097 h1:f5nA5Ys8RXqFXtKc0XofVRiuwNTuJzPIwTmbjLz9vj8= +github.com/dprotaso/go-yit v0.0.0-20240618133044-5a0af90af097/go.mod h1:FTAVyH6t+SlS97rv6EXRVuBDLkQqcIe/xQw9f4IFUI4= github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a h1:mATvB/9r/3gvcejNsXKSkQ6lcIaNec2nyfOdlTBR2lU= github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM= github.com/elazarl/goproxy/ext v0.0.0-20190711103511-473e67f1d7d2/go.mod h1:gNh8nYJoAm43RfaxurUnxr+N1PwuFV3ZMl/efxlIlY8= @@ -308,6 +315,7 @@ github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/gliderlabs/ssh v0.2.2/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0= @@ -566,6 +574,7 @@ github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9Y github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= github.com/juju/ansiterm v0.0.0-20180109212912-720a0952cc2a/go.mod h1:UJSiEoRfvx3hP73CvoARgeLjaIOjybY9vj8PUPPFGeU= @@ -590,6 +599,8 @@ github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348/go.mod h1:B69LEHPfb2qLo0BaaOLcbitczOKLWTsrBG9LczfCD4k= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lunixbochs/vtclean v0.0.0-20180621232353-2d01aacdc34a/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/manifoldco/promptui v0.8.0/go.mod h1:n4zTdgP0vr0S3w7/O/g98U+e0gwLScEXGwov2nIKuGQ= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ= @@ -637,11 +648,14 @@ github.com/mongodb-forks/digest v1.1.0 h1:7eUdsR1BtqLv0mdNm4OXs6ddWvR4X2/OsLwdKk github.com/mongodb-forks/digest v1.1.0/go.mod h1:rb+EX8zotClD5Dj4NdgxnJXG9nwrlx3NWKJ8xttz1Dg= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= +github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= github.com/oklog/run v1.0.0 h1:Ru7dDtJNOyC66gQ5dQmaCa0qIsAUFY3sFpK1Xk8igrw= github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= +github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc= github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= github.com/onsi/ginkgo/v2 v2.1.3/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c= github.com/onsi/ginkgo/v2 v2.1.4/go.mod h1:um6tUpWM/cxCK3/FK8BXqEiUMUwRgSM4JXG47RKZmLU= @@ -657,6 +671,7 @@ github.com/onsi/ginkgo/v2 v2.9.2/go.mod h1:WHcJJG2dIlcCqVfBAwUCrJxSPFb6v4azBwgxe github.com/onsi/ginkgo/v2 v2.9.5/go.mod h1:tvAoo1QUJwNEU2ITftXTpR7R1RbCzoZUOs3RonqW57k= github.com/onsi/ginkgo/v2 v2.9.7/go.mod h1:cxrmXWykAwTwhQsJOPfdIDiJ+l2RYq7U8hFU+M/1uw0= github.com/onsi/ginkgo/v2 v2.11.0/go.mod h1:ZhrRA5XmEE3x3rhlzamx/JJvujdZoJ2uvgI7kR0iZvM= +github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= @@ -677,6 +692,8 @@ github.com/onsi/gomega v1.27.10 h1:naR28SdDFlqrG6kScpT8VWpu1xWY5nJRCF3XaYyBjhI= github.com/onsi/gomega v1.27.10/go.mod h1:RsS8tutOdbdgzbPtzzATp12yT7kM5I5aElG3evPbQ0M= github.com/openlyinc/pointy v1.1.2 h1:LywVV2BWC5Sp5v7FoP4bUD+2Yn5k0VNeRbU5vq9jUMY= github.com/openlyinc/pointy v1.1.2/go.mod h1:w2Sytx+0FVuMKn37xpXIAyBNhFNBIJGR/v2m7ik1WtM= +github.com/pb33f/libopenapi v0.18.1 h1:Z2lWZ7A7G1u8gwx8A7IwuLwSToP8m55ZlzAagcLHh2U= +github.com/pb33f/libopenapi v0.18.1/go.mod h1:9ap4lXBHgxGyFwxtOfa+B1C3IQ0rvnqteqjJvJ11oiQ= github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4= github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI= @@ -696,8 +713,11 @@ github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6L github.com/rogpeppe/go-charset v0.0.0-20180617210344-2471d30d28b4/go.mod h1:qgYeAmZ5ZIpBWTGllZSQnw97Dj+woV0toclVaRGI8pc= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= -github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= +github.com/sebdah/goldie/v2 v2.5.5 h1:rx1mwF95RxZ3/83sdS4Yp7t2C5TCokvWP4TBRbAyEWY= +github.com/sebdah/goldie/v2 v2.5.5/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= @@ -753,6 +773,10 @@ github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21 github.com/vmihailenco/tagparser v0.1.1/go.mod h1:OeAg3pn3UbLjkWt+rN9oFYB6u/cQgqMEUPoW2WPyhdI= github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= +github.com/vmware-labs/yaml-jsonpath v0.3.2 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9NP674f9Hobk= +github.com/vmware-labs/yaml-jsonpath v0.3.2/go.mod h1:U6whw1z03QyqgWdgXxvVnQ90zN1BWz5V+51Ewf8k+rQ= +github.com/wk8/go-ordered-map/v2 v2.1.9-0.20240815153524-6ea36470d1bd h1:dLuIF2kX9c+KknGJUdJi1Il1SDiTSK158/BB9kdgAew= +github.com/wk8/go-ordered-map/v2 v2.1.9-0.20240815153524-6ea36470d1bd/go.mod h1:DbzwytT4g/odXquuOCqroKvtxxldI4nb3nuesHF/Exo= github.com/xanzy/ssh-agent v0.3.0/go.mod h1:3s9xbODqPuuhK9JV1R321M/FlMZSBvE5aY6eAcqrDh0= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= @@ -1442,9 +1466,11 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV gopkg.in/cheggaaa/pb.v1 v1.0.27/go.mod h1:V/YB90LKu/1FcN3WVnfiiE5oMCibMjukxqG/qStrOgw= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= @@ -1452,7 +1478,9 @@ gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20191026110619-0b21df46bc1d/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/tools/codegen/codespec/api_spec.go b/tools/codegen/codespec/api_spec.go new file mode 100644 index 0000000000..cba8c2bdb7 --- /dev/null +++ b/tools/codegen/codespec/api_spec.go @@ -0,0 +1,93 @@ +package codespec + +import ( + "context" + "fmt" + "strconv" + + "github.com/pb33f/libopenapi/datamodel/high/base" + high "github.com/pb33f/libopenapi/datamodel/high/v3" + "github.com/pb33f/libopenapi/orderedmap" +) + +var ( + errSchemaNotFound = fmt.Errorf("schema not found") +) + +// This function only builds the schema from a proxy and returns the basic type and format without handling oneOf, anyOf, allOf, or nullable types. +func BuildSchema(proxy *base.SchemaProxy) (*APISpecSchema, error) { + resp := &APISpecSchema{} + + schema, err := proxy.BuildSchema() + if err != nil { + return nil, fmt.Errorf("failed to build schema from proxy: %w", err) + } + + if len(schema.Type) == 0 { + return nil, fmt.Errorf("invalid schema. no values for schema.Type found") + } + + resp.Type = schema.Type[0] + resp.Schema = schema + + return resp, nil +} + +func getSchemaFromMediaType(mediaTypes *orderedmap.Map[string, *high.MediaType]) (*APISpecSchema, error) { + if mediaTypes == nil { + return nil, errSchemaNotFound + } + + sortedMediaTypes := orderedmap.SortAlpha(mediaTypes) + for pair := range orderedmap.Iterate(context.Background(), sortedMediaTypes) { + mediaType := pair.Value() + if mediaType.Schema != nil { + s, err := BuildSchema(mediaType.Schema) + if err != nil { + return nil, err + } + return s, nil + } + } + + return nil, errSchemaNotFound +} + +func buildSchemaFromRequest(op *high.Operation) (*APISpecSchema, error) { + if op == nil || op.RequestBody == nil || op.RequestBody.Content == nil || op.RequestBody.Content.Len() == 0 { + return nil, errSchemaNotFound + } + + return getSchemaFromMediaType(op.RequestBody.Content) +} + +func buildSchemaFromResponse(op *high.Operation) (*APISpecSchema, error) { + if op == nil || op.Responses == nil || op.Responses.Codes == nil || op.Responses.Codes.Len() == 0 { + return nil, errSchemaNotFound + } + + okResponse, ok := op.Responses.Codes.Get(OASResponseCodeOK) + if ok { + return getSchemaFromMediaType(okResponse.Content) + } + + createdResponse, ok := op.Responses.Codes.Get(OASResponseCodeCreated) + if ok { + return getSchemaFromMediaType(createdResponse.Content) + } + + sortedCodes := orderedmap.SortAlpha(op.Responses.Codes) + for pair := range orderedmap.Iterate(context.Background(), sortedCodes) { + responseCode := pair.Value() + statusCode, err := strconv.Atoi(pair.Key()) + if err != nil { + continue + } + + if statusCode >= 200 && statusCode <= 299 { + return getSchemaFromMediaType(responseCode.Content) + } + } + + return nil, errSchemaNotFound +} diff --git a/tools/codegen/codespec/api_spec_schema.go b/tools/codegen/codespec/api_spec_schema.go new file mode 100644 index 0000000000..c8d0c44f87 --- /dev/null +++ b/tools/codegen/codespec/api_spec_schema.go @@ -0,0 +1,59 @@ +package codespec + +import ( + "slices" + + "github.com/pb33f/libopenapi/datamodel/high/base" + high "github.com/pb33f/libopenapi/datamodel/high/v3" +) + +type APISpecSchema struct { + Schema *base.Schema + Type string +} + +type APISpecResource struct { + Description *string + DeprecationMessage *string + CreateOp *high.Operation + ReadOp *high.Operation + UpdateOp *high.Operation + DeleteOp *high.Operation + CommonParameters []*high.Parameter +} + +func (s *APISpecSchema) GetComputability(name string) ComputedOptionalRequired { + if slices.Contains(s.Schema.Required, name) { + return Required + } + + return Optional +} + +func (s *APISpecSchema) GetDeprecationMessage() *string { + if s.Schema.Deprecated == nil || !(*s.Schema.Deprecated) { + return nil + } + + deprecationMessage := "This attribute has been deprecated" + + return &deprecationMessage +} + +func (s *APISpecSchema) GetDescription() *string { + if s.Schema.Description == "" { + return nil + } + + return &s.Schema.Description +} + +func (s *APISpecSchema) IsSensitive() *bool { + isSensitive := s.Schema.Format == OASFormatPassword + + if !isSensitive { + return nil + } + + return &isSensitive +} diff --git a/tools/codegen/codespec/api_to_provider_spec_mapper.go b/tools/codegen/codespec/api_to_provider_spec_mapper.go new file mode 100644 index 0000000000..12ec3f9e76 --- /dev/null +++ b/tools/codegen/codespec/api_to_provider_spec_mapper.go @@ -0,0 +1,222 @@ +package codespec + +import ( + "errors" + "fmt" + "log" + "strings" + + high "github.com/pb33f/libopenapi/datamodel/high/v3" + low "github.com/pb33f/libopenapi/datamodel/low/v3" + + "github.com/mongodb/terraform-provider-mongodbatlas/internal/common/conversion" + "github.com/mongodb/terraform-provider-mongodbatlas/tools/codegen/config" + "github.com/mongodb/terraform-provider-mongodbatlas/tools/codegen/openapi" +) + +func ToCodeSpecModel(atlasAdminAPISpecFilePath, configPath string, resourceName *string) (*Model, error) { + apiSpec, err := openapi.ParseAtlasAdminAPI(atlasAdminAPISpecFilePath) + if err != nil { + return nil, fmt.Errorf("unable to parse Atlas Admin API: %v", err) + } + + configModel, err := config.ParseGenConfigYAML(configPath) + if err != nil { + return nil, fmt.Errorf("unable to parse config file: %v", err) + } + + resourceConfigsToIterate := configModel.Resources + if resourceName != nil { // only generate a specific resource + resourceConfigsToIterate = map[string]config.Resource{ + *resourceName: configModel.Resources[*resourceName], + } + } + + var results []Resource + for name, resourceConfig := range resourceConfigsToIterate { + log.Printf("Generating resource: %s", name) + // find resource operations, schemas, etc from OAS + oasResource, err := getAPISpecResource(&apiSpec.Model, &resourceConfig, SnakeCaseString(name)) + if err != nil { + return nil, fmt.Errorf("unable to get APISpecResource schema: %v", err) + } + // map OAS resource model to CodeSpecModel + results = append(results, *apiSpecResourceToCodeSpecModel(oasResource, &resourceConfig, SnakeCaseString(name))) + } + + return &Model{Resources: results}, nil +} + +func apiSpecResourceToCodeSpecModel(oasResource APISpecResource, resourceConfig *config.Resource, name SnakeCaseString) *Resource { + createOp := oasResource.CreateOp + readOp := oasResource.ReadOp + + pathParamAttributes := pathParamsToAttributes(createOp) + createRequestAttributes := opRequestToAttributes(createOp) + createResponseAttributes := opResponseToAttributes(createOp) + readResponseAttributes := opResponseToAttributes(readOp) + + attributes := mergeAttributes(pathParamAttributes, createRequestAttributes, createResponseAttributes, readResponseAttributes) + + schema := &Schema{ + Description: oasResource.Description, + DeprecationMessage: oasResource.DeprecationMessage, + Attributes: attributes, + } + + resource := &Resource{ + Name: name, + Schema: schema, + } + + applyConfigSchemaOptions(resourceConfig, resource) + + return resource +} + +func pathParamsToAttributes(createOp *high.Operation) Attributes { + pathParams := createOp.Parameters + + pathAttributes := Attributes{} + for _, param := range pathParams { + if param.In != OASPathParam { + continue + } + + s, err := BuildSchema(param.Schema) + if err != nil { + continue + } + + paramName := param.Name + s.Schema.Description = param.Description + parameterAttribute, err := s.buildResourceAttr(paramName, Required) + if err != nil { + log.Printf("[WARN] Path param %s could not be mapped: %s", paramName, err) + continue + } + pathAttributes = append(pathAttributes, *parameterAttribute) + } + return pathAttributes +} + +func opRequestToAttributes(op *high.Operation) Attributes { + var requestAttributes Attributes + requestSchema, err := buildSchemaFromRequest(op) + if err != nil { + log.Printf("[WARN] Request schema could not be mapped (OperationId: %s): %s", op.OperationId, err) + return nil + } + + requestAttributes, err = buildResourceAttrs(requestSchema) + if err != nil { + log.Printf("[WARN] Request attributes could not be mapped (OperationId: %s): %s", op.OperationId, err) + return nil + } + + return requestAttributes +} + +func opResponseToAttributes(op *high.Operation) Attributes { + var responseAttributes Attributes + responseSchema, err := buildSchemaFromResponse(op) + if err != nil { + if errors.Is(err, errSchemaNotFound) { + log.Printf("[INFO] Operation response body schema not found (OperationId: %s)", op.OperationId) + } else { + log.Printf("[WARN] Operation response body schema could not be mapped (OperationId: %s): %s", op.OperationId, err) + } + } else { + responseAttributes, err = buildResourceAttrs(responseSchema) + if err != nil { + log.Printf("[WARN] Operation response body schema could not be mapped (OperationId: %s): %s", op.OperationId, err) + } + } + return responseAttributes +} + +func getAPISpecResource(spec *high.Document, resourceConfig *config.Resource, name SnakeCaseString) (APISpecResource, error) { + var errResult error + var resourceDeprecationMsg *string + + createOp, err := extractOp(spec.Paths, resourceConfig.Create) + if err != nil { + errResult = errors.Join(errResult, fmt.Errorf("unable to extract '%s.create' operation: %w", name, err)) + } + readOp, err := extractOp(spec.Paths, resourceConfig.Read) + if err != nil { + errResult = errors.Join(errResult, fmt.Errorf("unable to extract '%s.read' operation: %w", name, err)) + } + updateOp, err := extractOp(spec.Paths, resourceConfig.Update) + if err != nil { + errResult = errors.Join(errResult, fmt.Errorf("unable to extract '%s.update' operation: %w", name, err)) + } + deleteOp, err := extractOp(spec.Paths, resourceConfig.Delete) + if err != nil { + errResult = errors.Join(errResult, fmt.Errorf("unable to extract '%s.delete' operation: %w", name, err)) + } + + commonParameters, err := extractCommonParameters(spec.Paths, resourceConfig.Read.Path) + if err != nil { + errResult = errors.Join(errResult, fmt.Errorf("unable to extract '%s' common parameters: %w", name, err)) + } + + if readOp.Deprecated != nil && *readOp.Deprecated { + resourceDeprecationMsg = conversion.StringPtr(DefaultDeprecationMsg) + } + + return APISpecResource{ + Description: &createOp.Description, + DeprecationMessage: resourceDeprecationMsg, + CreateOp: createOp, + ReadOp: readOp, + UpdateOp: updateOp, + DeleteOp: deleteOp, + CommonParameters: commonParameters, + }, errResult +} + +func extractOp(paths *high.Paths, apiOp *config.APIOperation) (*high.Operation, error) { + if apiOp == nil { + return nil, nil + } + + if paths == nil || paths.PathItems == nil || paths.PathItems.GetOrZero(apiOp.Path) == nil { + return nil, fmt.Errorf("path '%s' not found in OpenAPI spec", apiOp.Path) + } + + pathItem, _ := paths.PathItems.Get(apiOp.Path) + + return extractOpFromPathItem(pathItem, apiOp) +} + +func extractOpFromPathItem(pathItem *high.PathItem, apiOp *config.APIOperation) (*high.Operation, error) { + if pathItem == nil || apiOp == nil { + return nil, fmt.Errorf("pathItem or apiOp cannot be nil") + } + + switch strings.ToLower(apiOp.Method) { + case low.PostLabel: + return pathItem.Post, nil + case low.GetLabel: + return pathItem.Get, nil + case low.PutLabel: + return pathItem.Put, nil + case low.DeleteLabel: + return pathItem.Delete, nil + case low.PatchLabel: + return pathItem.Patch, nil + default: + return nil, fmt.Errorf("method '%s' not found at OpenAPI path '%s'", apiOp.Method, apiOp.Path) + } +} + +func extractCommonParameters(paths *high.Paths, path string) ([]*high.Parameter, error) { + if paths.PathItems.GetOrZero(path) == nil { + return nil, fmt.Errorf("path '%s' not found in OpenAPI spec", path) + } + + pathItem, _ := paths.PathItems.Get(path) + + return pathItem.Parameters, nil +} diff --git a/tools/codegen/codespec/api_to_provider_spec_mapper_test.go b/tools/codegen/codespec/api_to_provider_spec_mapper_test.go new file mode 100644 index 0000000000..6eb7129d87 --- /dev/null +++ b/tools/codegen/codespec/api_to_provider_spec_mapper_test.go @@ -0,0 +1,378 @@ +package codespec_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/mongodb/terraform-provider-mongodbatlas/internal/common/conversion" + "github.com/mongodb/terraform-provider-mongodbatlas/tools/codegen/codespec" +) + +const ( + testFieldDesc = "Test field description" + testResourceDesc = "POST API description" + testPathParamDesc = "Path param test description" +) + +type convertToSpecTestCase struct { + expectedResult *codespec.Model + inputOpenAPISpecPath string + inputConfigPath string + inputResourceName string +} + +func TestConvertToProviderSpec(t *testing.T) { + testCases := map[string]convertToSpecTestCase{ + "Valid input": { + inputOpenAPISpecPath: "testdata/api-spec.yml", + inputConfigPath: "testdata/config-no-schema-opts.yml", + inputResourceName: "test_resource", + + expectedResult: &codespec.Model{ + Resources: []codespec.Resource{{ + Schema: &codespec.Schema{ + Description: conversion.StringPtr(testResourceDesc), + Attributes: codespec.Attributes{ + { + Name: "bool_default_attr", + ComputedOptionalRequired: codespec.ComputedOptional, + Bool: &codespec.BoolAttribute{Default: conversion.Pointer(false)}, + }, + { + Name: "count", + ComputedOptionalRequired: codespec.Optional, + Int64: &codespec.Int64Attribute{}, + Description: conversion.StringPtr(testFieldDesc), + }, + { + Name: "create_date", + String: &codespec.StringAttribute{}, + ComputedOptionalRequired: codespec.Computed, + Description: conversion.StringPtr(testFieldDesc), + }, + { + Name: "group_id", + ComputedOptionalRequired: codespec.Required, + String: &codespec.StringAttribute{}, + Description: conversion.StringPtr(testPathParamDesc), + }, + { + Name: "num_double_default_attr", + Float64: &codespec.Float64Attribute{Default: conversion.Pointer(2.0)}, + ComputedOptionalRequired: codespec.ComputedOptional, + }, + { + Name: "str_computed_attr", + ComputedOptionalRequired: codespec.Computed, + String: &codespec.StringAttribute{}, + Description: conversion.StringPtr(testFieldDesc), + }, + { + Name: "str_req_attr1", + ComputedOptionalRequired: codespec.Required, + String: &codespec.StringAttribute{}, + Description: conversion.StringPtr(testFieldDesc), + }, + { + Name: "str_req_attr2", + ComputedOptionalRequired: codespec.Required, + String: &codespec.StringAttribute{}, + Description: conversion.StringPtr(testFieldDesc), + }, + { + Name: "str_req_attr3", + String: &codespec.StringAttribute{}, + ComputedOptionalRequired: codespec.Required, + Description: conversion.StringPtr(testFieldDesc), + }, + }, + }, + Name: "test_resource", + }, + }, + }, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + result, err := codespec.ToCodeSpecModel(tc.inputOpenAPISpecPath, tc.inputConfigPath, &tc.inputResourceName) + require.NoError(t, err) + assert.Equal(t, tc.expectedResult, result, "Expected result to match the specified structure") + }) + } +} + +func TestConvertToProviderSpec_nested(t *testing.T) { + testCases := map[string]convertToSpecTestCase{ + "Valid input": { + inputOpenAPISpecPath: "testdata/api-spec.yml", + inputConfigPath: "testdata/config-nested-schema.yml", + inputResourceName: "test_resource_with_nested_attr", + + expectedResult: &codespec.Model{ + Resources: []codespec.Resource{{ + Schema: &codespec.Schema{ + Description: conversion.StringPtr(testResourceDesc), + Attributes: codespec.Attributes{ + { + Name: "cluster_name", + ComputedOptionalRequired: codespec.Required, + String: &codespec.StringAttribute{}, + Description: conversion.StringPtr(testPathParamDesc), + }, + { + Name: "group_id", + ComputedOptionalRequired: codespec.Required, + String: &codespec.StringAttribute{}, + Description: conversion.StringPtr(testPathParamDesc), + }, + { + Name: "list_primitive_string_attr", + ComputedOptionalRequired: codespec.Computed, + List: &codespec.ListAttribute{ + ElementType: codespec.String, + }, + Description: conversion.StringPtr(testFieldDesc), + }, + { + Name: "nested_list_array_attr", + ComputedOptionalRequired: codespec.Required, + ListNested: &codespec.ListNestedAttribute{ + NestedObject: codespec.NestedAttributeObject{ + Attributes: codespec.Attributes{ + { + Name: "inner_num_attr", + ComputedOptionalRequired: codespec.Required, + Int64: &codespec.Int64Attribute{}, + Description: conversion.StringPtr(testFieldDesc), + }, + { + Name: "list_primitive_string_attr", + ComputedOptionalRequired: codespec.Optional, + List: &codespec.ListAttribute{ + ElementType: codespec.String, + }, + Description: conversion.StringPtr(testFieldDesc), + }, + { + Name: "list_primitive_string_computed_attr", + ComputedOptionalRequired: codespec.Computed, + List: &codespec.ListAttribute{ + ElementType: codespec.String, + }, + Description: conversion.StringPtr(testFieldDesc), + }, + }, + }, + }, + Description: conversion.StringPtr(testFieldDesc), + }, + { + Name: "nested_map_object_attr", + ComputedOptionalRequired: codespec.Computed, + MapNested: &codespec.MapNestedAttribute{ + NestedObject: codespec.NestedAttributeObject{ + Attributes: codespec.Attributes{ + { + Name: "attr", + ComputedOptionalRequired: codespec.Computed, + String: &codespec.StringAttribute{}, + }, + }, + }, + }, + }, + { + Name: "nested_set_array_attr", + ComputedOptionalRequired: codespec.Computed, + SetNested: &codespec.SetNestedAttribute{ + NestedObject: codespec.NestedAttributeObject{ + Attributes: codespec.Attributes{ + { + Name: "inner_num_attr", + ComputedOptionalRequired: codespec.Computed, + Int64: &codespec.Int64Attribute{}, + Description: conversion.StringPtr(testFieldDesc), + }, + { + Name: "list_primitive_string_attr", + ComputedOptionalRequired: codespec.Computed, + List: &codespec.ListAttribute{ + ElementType: codespec.String, + }, + Description: conversion.StringPtr(testFieldDesc), + }, + }, + }, + }, + Description: conversion.StringPtr(testFieldDesc), + }, + { + Name: "set_primitive_string_attr", + ComputedOptionalRequired: codespec.Computed, + Set: &codespec.SetAttribute{ + ElementType: codespec.String, + }, + Description: conversion.StringPtr(testFieldDesc), + }, + { + Name: "single_nested_attr", + ComputedOptionalRequired: codespec.Computed, + SingleNested: &codespec.SingleNestedAttribute{ + NestedObject: codespec.NestedAttributeObject{ + Attributes: codespec.Attributes{ + { + Name: "inner_int_attr", + ComputedOptionalRequired: codespec.Computed, + Int64: &codespec.Int64Attribute{}, + Description: conversion.StringPtr(testFieldDesc), + }, + { + Name: "inner_str_attr", + ComputedOptionalRequired: codespec.Computed, + String: &codespec.StringAttribute{}, + Description: conversion.StringPtr(testFieldDesc), + }, + }, + }, + }, + Description: conversion.StringPtr(testFieldDesc), + }, + { + Name: "single_nested_attr_with_nested_maps", + ComputedOptionalRequired: codespec.Computed, + SingleNested: &codespec.SingleNestedAttribute{ + NestedObject: codespec.NestedAttributeObject{ + Attributes: codespec.Attributes{ + { + Name: "map_attr1", + ComputedOptionalRequired: codespec.Computed, + Map: &codespec.MapAttribute{ + ElementType: codespec.String, + }, + }, + { + Name: "map_attr2", + ComputedOptionalRequired: codespec.Computed, + Map: &codespec.MapAttribute{ + ElementType: codespec.String, + }, + }, + }, + }, + }, + Description: conversion.StringPtr(testFieldDesc), + }, + }, + }, + Name: "test_resource_with_nested_attr", + }, + }, + }, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + result, err := codespec.ToCodeSpecModel(tc.inputOpenAPISpecPath, tc.inputConfigPath, &tc.inputResourceName) + require.NoError(t, err) + assert.Equal(t, tc.expectedResult, result, "Expected result to match the specified structure") + }) + } +} + +func TestConvertToProviderSpec_nested_schemaOverrides(t *testing.T) { + testCases := map[string]convertToSpecTestCase{ + "Valid input": { + inputOpenAPISpecPath: "testdata/api-spec.yml", + inputConfigPath: "testdata/config-nested-schema-overrides.yml", + inputResourceName: "test_resource_with_nested_attr_overrides", + + expectedResult: &codespec.Model{ + Resources: []codespec.Resource{{ + Schema: &codespec.Schema{ + Description: conversion.StringPtr(testResourceDesc), + Attributes: codespec.Attributes{ + { + Name: "project_id", + ComputedOptionalRequired: codespec.Required, + String: &codespec.StringAttribute{}, + Description: conversion.StringPtr(testPathParamDesc), + }, + { + Name: "nested_list_array_attr", + ComputedOptionalRequired: codespec.Required, + ListNested: &codespec.ListNestedAttribute{ + NestedObject: codespec.NestedAttributeObject{ + Attributes: codespec.Attributes{ + { + Name: "inner_num_attr_alias", + ComputedOptionalRequired: codespec.Required, + Int64: &codespec.Int64Attribute{}, + Description: conversion.StringPtr("Overridden inner_num_attr_alias description"), + }, + { + Name: "list_primitive_string_computed_attr", + ComputedOptionalRequired: codespec.Computed, + List: &codespec.ListAttribute{ + ElementType: codespec.String, + }, + Description: conversion.StringPtr(testFieldDesc), + }, + }, + }, + }, + Description: conversion.StringPtr(testFieldDesc), + }, + { + Name: "outer_object", + ComputedOptionalRequired: codespec.Computed, + SingleNested: &codespec.SingleNestedAttribute{ + NestedObject: codespec.NestedAttributeObject{ + Attributes: codespec.Attributes{ + { + Name: "nested_level1", + ComputedOptionalRequired: codespec.Computed, + SingleNested: &codespec.SingleNestedAttribute{ + NestedObject: codespec.NestedAttributeObject{ + Attributes: codespec.Attributes{ + { + Name: "level_field1_alias", + ComputedOptionalRequired: codespec.Computed, + String: &codespec.StringAttribute{}, + Description: conversion.StringPtr("Overridden level_field1_alias description"), + }, + }, + }, + }, + }, + }, + }, + }, + }, + { + Name: "timeouts", + Timeouts: &codespec.TimeoutsAttribute{ + ConfigurableTimeouts: []codespec.Operation{codespec.Create, codespec.Read, codespec.Update, codespec.Delete}, + }, + }, + }, + }, + Name: "test_resource_with_nested_attr_overrides", + }, + }, + }, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + result, err := codespec.ToCodeSpecModel(tc.inputOpenAPISpecPath, tc.inputConfigPath, &tc.inputResourceName) + require.NoError(t, err) + assert.Equal(t, tc.expectedResult, result, "Expected result to match the specified structure") + }) + } +} diff --git a/tools/codegen/codespec/attribute.go b/tools/codegen/codespec/attribute.go new file mode 100644 index 0000000000..c20b8fbacb --- /dev/null +++ b/tools/codegen/codespec/attribute.go @@ -0,0 +1,272 @@ +package codespec + +import ( + "context" + "fmt" + + "github.com/pb33f/libopenapi/orderedmap" +) + +func buildResourceAttrs(s *APISpecSchema) (Attributes, error) { + objectAttributes := Attributes{} + + sortedProperties := orderedmap.SortAlpha(s.Schema.Properties) + for pair := range orderedmap.Iterate(context.Background(), sortedProperties) { + name := pair.Key() + proxy := pair.Value() + + schema, err := BuildSchema(proxy) + if err != nil { + return nil, err + } + + attribute, err := schema.buildResourceAttr(name, s.GetComputability(name)) + if err != nil { + return nil, err + } + + if attribute != nil { + objectAttributes = append(objectAttributes, *attribute) + } + } + + return objectAttributes, nil +} + +func (s *APISpecSchema) buildResourceAttr(name string, computability ComputedOptionalRequired) (*Attribute, error) { + switch s.Type { + case OASTypeString: + return s.buildStringAttr(name, computability) + case OASTypeInteger: + return s.buildIntegerAttr(name, computability) + case OASTypeNumber: + return s.buildNumberAttr(name, computability) + case OASTypeBoolean: + return s.buildBoolAttr(name, computability) + case OASTypeArray: + return s.buildArrayAttr(name, computability) + case OASTypeObject: + if s.Schema.AdditionalProperties != nil && s.Schema.AdditionalProperties.IsA() { + return s.buildMapAttr(name, computability) + } + return s.buildSingleNestedAttr(name, computability) + default: + return nil, fmt.Errorf("invalid schema type '%s'", s.Type) + } +} + +func (s *APISpecSchema) buildStringAttr(name string, computability ComputedOptionalRequired) (*Attribute, error) { + result := &Attribute{ + Name: terraformAttrName(name), + ComputedOptionalRequired: computability, + DeprecationMessage: s.GetDeprecationMessage(), + Description: s.GetDescription(), + Sensitive: s.IsSensitive(), + String: &StringAttribute{}, + } + + if s.Schema.Default != nil { + var staticDefault string + if err := s.Schema.Default.Decode(&staticDefault); err == nil { + result.ComputedOptionalRequired = ComputedOptional + + result.String.Default = &staticDefault + } + } + + return result, nil +} + +func (s *APISpecSchema) buildIntegerAttr(name string, computability ComputedOptionalRequired) (*Attribute, error) { + result := &Attribute{ + Name: terraformAttrName(name), + ComputedOptionalRequired: computability, + DeprecationMessage: s.GetDeprecationMessage(), + Description: s.GetDescription(), + Int64: &Int64Attribute{}, + } + + if s.Schema.Default != nil { + var staticDefault int64 + if err := s.Schema.Default.Decode(&staticDefault); err == nil { + result.ComputedOptionalRequired = ComputedOptional + + result.Int64.Default = &staticDefault + } + } + + return result, nil +} + +func (s *APISpecSchema) buildNumberAttr(name string, computability ComputedOptionalRequired) (*Attribute, error) { + if s.Schema.Format == OASFormatDouble || s.Schema.Format == OASFormatFloat { + result := &Attribute{ + Name: terraformAttrName(name), + ComputedOptionalRequired: computability, + DeprecationMessage: s.GetDeprecationMessage(), + Description: s.GetDescription(), + Float64: &Float64Attribute{}, + } + + if s.Schema.Default != nil { + var staticDefault float64 + if err := s.Schema.Default.Decode(&staticDefault); err == nil { + result.ComputedOptionalRequired = ComputedOptional + + result.Float64.Default = &staticDefault + } + } + + return result, nil + } + + return &Attribute{ + Name: terraformAttrName(name), + ComputedOptionalRequired: computability, + DeprecationMessage: s.GetDeprecationMessage(), + Description: s.GetDescription(), + Number: &NumberAttribute{}, + }, nil +} + +func (s *APISpecSchema) buildBoolAttr(name string, computability ComputedOptionalRequired) (*Attribute, error) { + result := &Attribute{ + Name: terraformAttrName(name), + ComputedOptionalRequired: computability, + DeprecationMessage: s.GetDeprecationMessage(), + Description: s.GetDescription(), + Bool: &BoolAttribute{}, + } + + if s.Schema.Default != nil { + var staticDefault bool + if err := s.Schema.Default.Decode(&staticDefault); err == nil { + result.ComputedOptionalRequired = ComputedOptional + result.Bool.Default = &staticDefault + } + } + + return result, nil +} + +func (s *APISpecSchema) buildArrayAttr(name string, computability ComputedOptionalRequired) (*Attribute, error) { + if !s.Schema.Items.IsA() { + return nil, fmt.Errorf("invalid array items property, schema doesn't exist: %s", name) + } + + itemSchema, err := BuildSchema(s.Schema.Items.A) + if err != nil { + return nil, fmt.Errorf("error while building nested schema: %s", name) + } + + isSet := s.Schema.Format == OASFormatSet || (s.Schema.UniqueItems != nil && *s.Schema.UniqueItems) + + createAttribute := func(nestedObject *NestedAttributeObject, elemType ElemType) *Attribute { + attr := &Attribute{ + Name: terraformAttrName(name), + ComputedOptionalRequired: computability, + DeprecationMessage: s.GetDeprecationMessage(), + Description: s.GetDescription(), + } + + if nestedObject != nil { + if isSet { + attr.SetNested = &SetNestedAttribute{NestedObject: *nestedObject} + } else { + attr.ListNested = &ListNestedAttribute{NestedObject: *nestedObject} + } + } else { + if isSet { + attr.Set = &SetAttribute{ElementType: elemType} + } else { + attr.List = &ListAttribute{ElementType: elemType} + } + } + + return attr + } + + if itemSchema.Type == OASTypeObject { + objectAttributes, err := buildResourceAttrs(itemSchema) + if err != nil { + return nil, fmt.Errorf("error while building nested schema: %s", name) + } + nestedObject := &NestedAttributeObject{Attributes: objectAttributes} + + return createAttribute(nestedObject, Unknown), nil // Using Unknown ElemType as a placeholder for no ElemType + } + + elemType, err := itemSchema.buildElementType() + if err != nil { + return nil, fmt.Errorf("error while building nested schema: %s", name) + } + + result := createAttribute(nil, elemType) + + if s.Schema.Default != nil { + var staticDefault bool + if err := s.Schema.Default.Decode(&staticDefault); err == nil { + result.ComputedOptionalRequired = ComputedOptional + result.Bool.Default = &staticDefault + } + } + + return result, nil +} + +func (s *APISpecSchema) buildMapAttr(name string, computability ComputedOptionalRequired) (*Attribute, error) { + mapSchema, err := BuildSchema(s.Schema.AdditionalProperties.A) + if err != nil { + return nil, err + } + + result := &Attribute{ + Name: terraformAttrName(name), + ComputedOptionalRequired: computability, + DeprecationMessage: s.GetDeprecationMessage(), + Description: s.GetDescription(), + } + + if mapSchema.Type == OASTypeObject { + mapAttributes, err := buildResourceAttrs(mapSchema) + if err != nil { + return nil, err + } + + result.MapNested = &MapNestedAttribute{ + NestedObject: NestedAttributeObject{ + Attributes: mapAttributes, + }, + } + } else { + elemType, err := mapSchema.buildElementType() + if err != nil { + return nil, err + } + + result.Map = &MapAttribute{ + ElementType: elemType, + } + } + + return result, nil +} + +func (s *APISpecSchema) buildSingleNestedAttr(name string, computability ComputedOptionalRequired) (*Attribute, error) { + objectAttributes, err := buildResourceAttrs(s) + if err != nil { + return nil, err + } + + return &Attribute{ + Name: terraformAttrName(name), + ComputedOptionalRequired: computability, + DeprecationMessage: s.GetDeprecationMessage(), + Description: s.GetDescription(), + SingleNested: &SingleNestedAttribute{ + NestedObject: NestedAttributeObject{ + Attributes: objectAttributes, + }, + }, + }, nil +} diff --git a/tools/codegen/codespec/computability.go b/tools/codegen/codespec/computability.go new file mode 100644 index 0000000000..394d411f82 --- /dev/null +++ b/tools/codegen/codespec/computability.go @@ -0,0 +1,10 @@ +package codespec + +const ( + Computed ComputedOptionalRequired = "computed" + ComputedOptional ComputedOptionalRequired = "computed_optional" + Optional ComputedOptionalRequired = "optional" + Required ComputedOptionalRequired = "required" +) + +type ComputedOptionalRequired string diff --git a/tools/codegen/codespec/config.go b/tools/codegen/codespec/config.go new file mode 100644 index 0000000000..f935472bad --- /dev/null +++ b/tools/codegen/codespec/config.go @@ -0,0 +1,123 @@ +package codespec + +import ( + "log" + "strings" + + "github.com/mongodb/terraform-provider-mongodbatlas/tools/codegen/config" +) + +func applyConfigSchemaOptions(resourceConfig *config.Resource, resource *Resource) { + applySchemaOptions(resourceConfig.SchemaOptions, &resource.Schema.Attributes, "") +} + +func applySchemaOptions(schemaOptions config.SchemaOptions, attributes *Attributes, parentName string) { + ignoredAttrs := getIgnoredAttributesMap(schemaOptions.Ignores) + + var finalAttributes Attributes + + for i := range *attributes { + attr := &(*attributes)[i] + attrPathName := getAttributePathName(string(attr.Name), parentName) + + if shouldIgnoreAttribute(attrPathName, ignoredAttrs) { + continue + } + + // the config is expected to use alias name for defining any subsequent overrides (description, etc) + applyAlias(attr, &attrPathName, schemaOptions) + + applyOverrides(attr, attrPathName, schemaOptions) + + processNestedAttributes(attr, schemaOptions, attrPathName) + + finalAttributes = append(finalAttributes, *attr) + } + + if timeoutAttr := applyTimeoutConfig(schemaOptions); parentName == "" && timeoutAttr != nil { // will not run for nested attributes + finalAttributes = append(finalAttributes, *timeoutAttr) + } + + *attributes = finalAttributes +} + +func getAttributePathName(attrName, parentName string) string { + if parentName == "" { + return attrName + } + return parentName + "." + attrName +} + +func getIgnoredAttributesMap(ignores []string) map[string]bool { + ignoredAttrs := make(map[string]bool) + for _, ignoredAttr := range ignores { + ignoredAttrs[ignoredAttr] = true + } + return ignoredAttrs +} + +func shouldIgnoreAttribute(attrName string, ignoredAttrs map[string]bool) bool { + return ignoredAttrs[attrName] +} + +func applyAlias(attr *Attribute, attrPathName *string, schemaOptions config.SchemaOptions) { + parts := strings.Split(*attrPathName, ".") + + for i := range parts { + currentPath := strings.Join(parts[:i+1], ".") + + if newName, ok := schemaOptions.Aliases[currentPath]; ok { + parts[i] = newName + + if i == len(parts)-1 { + attr.Name = SnakeCaseString(newName) + } + } + } + + *attrPathName = strings.Join(parts, ".") +} + +func applyOverrides(attr *Attribute, attrPathName string, schemaOptions config.SchemaOptions) { + if override, ok := schemaOptions.Overrides[attrPathName]; ok { + attr.Description = &override.Description + } +} + +func processNestedAttributes(attr *Attribute, schemaOptions config.SchemaOptions, attrPathName string) { + switch { + case attr.ListNested != nil: + applySchemaOptions(schemaOptions, &attr.ListNested.NestedObject.Attributes, attrPathName) + case attr.SingleNested != nil: + applySchemaOptions(schemaOptions, &attr.SingleNested.NestedObject.Attributes, attrPathName) + case attr.SetNested != nil: + applySchemaOptions(schemaOptions, &attr.SetNested.NestedObject.Attributes, attrPathName) + case attr.MapNested != nil: + applySchemaOptions(schemaOptions, &attr.MapNested.NestedObject.Attributes, attrPathName) + } +} + +func applyTimeoutConfig(options config.SchemaOptions) *Attribute { + var result []Operation + for _, op := range options.Timeouts { + switch op { + case "create": + result = append(result, Create) + case "read": + result = append(result, Read) + case "delete": + result = append(result, Delete) + case "update": + result = append(result, Update) + default: + log.Printf("[WARN] Unknown operation type defined in timeout configuration: %s", op) + } + } + if result != nil { + return &Attribute{ + Name: "timeouts", + Timeouts: &TimeoutsAttribute{ConfigurableTimeouts: result}, + } + } + return nil +} diff --git a/tools/codegen/codespec/constants.go b/tools/codegen/codespec/constants.go new file mode 100644 index 0000000000..cd41c1b8c0 --- /dev/null +++ b/tools/codegen/codespec/constants.go @@ -0,0 +1,21 @@ +package codespec + +const ( + OASTypeString = "string" + OASTypeInteger = "integer" + OASTypeNumber = "number" + OASTypeBoolean = "boolean" + OASTypeArray = "array" + OASTypeObject = "object" + OASFormatDouble = "double" + OASFormatFloat = "float" + OASFormatPassword = "password" + OASFormatSet = "set" + + OASResponseCodeOK = "200" + OASResponseCodeCreated = "201" + + OASPathParam = "path" + + DefaultDeprecationMsg = "This resource has been deprecated" +) diff --git a/tools/codegen/codespec/element_type.go b/tools/codegen/codespec/element_type.go new file mode 100644 index 0000000000..1546786211 --- /dev/null +++ b/tools/codegen/codespec/element_type.go @@ -0,0 +1,20 @@ +package codespec + +import "fmt" + +func (s *APISpecSchema) buildElementType() (ElemType, error) { + switch s.Type { + case OASTypeString: + return String, nil + case OASTypeBoolean: + return Bool, nil + case OASTypeInteger: + return Int64, nil + case OASTypeNumber: + return Number, nil + case OASTypeArray, OASTypeObject: + return String, nil // complex element types are unsupported so this defaults to string for now to provide best effort generation + default: + return Unknown, fmt.Errorf("invalid schema type '%s'", s.Type) + } +} diff --git a/tools/codegen/codespec/merge_attributes.go b/tools/codegen/codespec/merge_attributes.go new file mode 100644 index 0000000000..7d129eada3 --- /dev/null +++ b/tools/codegen/codespec/merge_attributes.go @@ -0,0 +1,158 @@ +package codespec + +import ( + "sort" +) + +// mergeNestedAttributes recursively merges nested attributes +func mergeNestedAttributes(existingAttrs *Attributes, newAttrs Attributes, computability ComputedOptionalRequired, isFromResponse bool) { + mergedMap := make(map[string]*Attribute) + if existingAttrs != nil { + for i := range *existingAttrs { + mergedMap[(*existingAttrs)[i].Name.SnakeCase()] = &(*existingAttrs)[i] + } + } + + // add new attributes and merge when necessary + for i := range newAttrs { + newAttr := &newAttrs[i] + + if _, exists := mergedMap[newAttr.Name.SnakeCase()]; exists { + addOrUpdate(newAttr, computability, mergedMap, isFromResponse) + } else { + newAttr.ComputedOptionalRequired = computability + mergedMap[newAttr.Name.SnakeCase()] = newAttr + } + } + + // update original existingAttrs with the merged result + *existingAttrs = make(Attributes, 0, len(mergedMap)) + for _, attr := range mergedMap { + *existingAttrs = append(*existingAttrs, *attr) + } + + sortAttributes(*existingAttrs) +} + +// addOrUpdate adds or updates an attribute in the merged map, including nested attributes +func addOrUpdate(attr *Attribute, computability ComputedOptionalRequired, merged map[string]*Attribute, isFromResponse bool) { + if existingAttr, exists := merged[attr.Name.SnakeCase()]; exists { + if existingAttr.Description == nil || *existingAttr.Description == "" { + existingAttr.Description = attr.Description + } + + // retain computability if already set from request + if !isFromResponse && existingAttr.ComputedOptionalRequired != Required { + existingAttr.ComputedOptionalRequired = computability + } + + // handle nested attributes + if existingAttr.ListNested != nil && attr.ListNested != nil { + mergeNestedAttributes(&existingAttr.ListNested.NestedObject.Attributes, attr.ListNested.NestedObject.Attributes, computability, isFromResponse) + } else if attr.ListNested != nil { + existingAttr.ListNested = attr.ListNested + } + + if existingAttr.SingleNested != nil && attr.SingleNested != nil { + mergeNestedAttributes(&existingAttr.SingleNested.NestedObject.Attributes, attr.SingleNested.NestedObject.Attributes, computability, isFromResponse) + } else if attr.SingleNested != nil { + existingAttr.SingleNested = attr.SingleNested + } + + if existingAttr.SetNested != nil && attr.SetNested != nil { + mergeNestedAttributes(&existingAttr.SetNested.NestedObject.Attributes, attr.SetNested.NestedObject.Attributes, computability, isFromResponse) + } else if attr.SetNested != nil { + existingAttr.SetNested = attr.SetNested + } + + if existingAttr.MapNested != nil && attr.MapNested != nil { + mergeNestedAttributes(&existingAttr.MapNested.NestedObject.Attributes, attr.MapNested.NestedObject.Attributes, computability, isFromResponse) + } else if attr.MapNested != nil { + existingAttr.MapNested = attr.MapNested + } + } else { + // add new attribute with the given computability + newAttr := *attr + newAttr.ComputedOptionalRequired = computability + merged[attr.Name.SnakeCase()] = &newAttr + } +} + +func mergeAttributes(pathParams, createRequest, createResponse, readResponse Attributes) Attributes { + merged := make(map[string]*Attribute) + + // Path parameters: all attributes will be "required" + for i := range pathParams { + addOrUpdate(&pathParams[i], Required, merged, false) + } + + // POST request body: optional/required is as defined + for i := range createRequest { + addOrUpdate(&createRequest[i], createRequest[i].ComputedOptionalRequired, merged, false) + } + + // POST/GET response body: properties not in the request body are "computed" or "computed_optional" (if a default is present) + for i := range createResponse { + if hasDefault(&createResponse[i]) { + addOrUpdate(&createResponse[i], ComputedOptional, merged, true) + } else { + addOrUpdate(&createResponse[i], Computed, merged, true) + } + } + + for i := range readResponse { + if hasDefault(&readResponse[i]) { + addOrUpdate(&readResponse[i], ComputedOptional, merged, true) + } else { + addOrUpdate(&readResponse[i], Computed, merged, true) + } + } + + resourceAttributes := make(Attributes, 0, len(merged)) + for _, attr := range merged { + resourceAttributes = append(resourceAttributes, *attr) + } + + sortAttributes(resourceAttributes) + + updateNestedComputability(&resourceAttributes, Optional) + + return resourceAttributes +} + +func updateNestedComputability(attrs *Attributes, parentComputability ComputedOptionalRequired) { + for i := range *attrs { + attr := &(*attrs)[i] + + if parentComputability == Computed { + attr.ComputedOptionalRequired = Computed + } + + if attr.ListNested != nil { + updateNestedComputability(&attr.ListNested.NestedObject.Attributes, attr.ComputedOptionalRequired) + } + if attr.SingleNested != nil { + updateNestedComputability(&attr.SingleNested.NestedObject.Attributes, attr.ComputedOptionalRequired) + } + if attr.SetNested != nil { + updateNestedComputability(&attr.SetNested.NestedObject.Attributes, attr.ComputedOptionalRequired) + } + if attr.MapNested != nil { + updateNestedComputability(&attr.MapNested.NestedObject.Attributes, attr.ComputedOptionalRequired) + } + } +} + +func hasDefault(attr *Attribute) bool { + return (attr.Bool != nil && attr.Bool.Default != nil) || + (attr.Int64 != nil && attr.Int64.Default != nil) || + (attr.String != nil && attr.String.Default != nil) || + (attr.Float64 != nil && attr.Float64.Default != nil) || + (attr.Number != nil && attr.Number.Default != nil) +} + +func sortAttributes(attrs Attributes) { + sort.Slice(attrs, func(i, j int) bool { + return attrs[i].Name < attrs[j].Name + }) +} diff --git a/tools/codegen/codespec/model.go b/tools/codegen/codespec/model.go new file mode 100644 index 0000000000..ac86367ca1 --- /dev/null +++ b/tools/codegen/codespec/model.go @@ -0,0 +1,119 @@ +package codespec + +type ElemType int + +const ( + Bool ElemType = iota + Float64 + Int64 + Number + String + Unknown +) + +type Model struct { + Resources []Resource +} + +type Resource struct { + Schema *Schema + Name SnakeCaseString +} + +type Schema struct { + Description *string + DeprecationMessage *string + + Attributes Attributes +} + +type Attributes []Attribute + +type Attribute struct { + List *ListAttribute + SetNested *SetNestedAttribute + + Float64 *Float64Attribute + String *StringAttribute + + Bool *BoolAttribute + ListNested *ListNestedAttribute + Map *MapAttribute + MapNested *MapNestedAttribute + Number *NumberAttribute + Set *SetAttribute + Int64 *Int64Attribute + SingleNested *SingleNestedAttribute + Timeouts *TimeoutsAttribute + + Description *string + Name SnakeCaseString + DeprecationMessage *string + Sensitive *bool + ComputedOptionalRequired ComputedOptionalRequired +} + +type BoolAttribute struct { + Default *bool +} +type Float64Attribute struct { + Default *float64 +} +type Int64Attribute struct { + Default *int64 +} +type MapAttribute struct { + Default *CustomDefault + ElementType ElemType +} +type MapNestedAttribute struct { + Default *CustomDefault + NestedObject NestedAttributeObject +} +type NumberAttribute struct { + Default *CustomDefault +} +type SetAttribute struct { + Default *CustomDefault + ElementType ElemType +} +type SetNestedAttribute struct { + Default *CustomDefault + NestedObject NestedAttributeObject +} +type SingleNestedAttribute struct { + Default *CustomDefault + NestedObject NestedAttributeObject +} +type StringAttribute struct { + Default *string +} +type ListAttribute struct { + Default *CustomDefault + ElementType ElemType +} +type ListNestedAttribute struct { + Default *CustomDefault + NestedObject NestedAttributeObject +} +type NestedAttributeObject struct { + Attributes Attributes +} + +type TimeoutsAttribute struct { + ConfigurableTimeouts []Operation +} + +type Operation int + +const ( + Create Operation = iota + Update + Read + Delete +) + +type CustomDefault struct { + Definition string + Imports []string +} diff --git a/tools/codegen/codespec/string_case.go b/tools/codegen/codespec/string_case.go new file mode 100644 index 0000000000..d079e2d485 --- /dev/null +++ b/tools/codegen/codespec/string_case.go @@ -0,0 +1,21 @@ +package codespec + +import ( + "strings" + + "github.com/huandu/xstrings" +) + +type SnakeCaseString string + +func (snake SnakeCaseString) SnakeCase() string { + return string(snake) +} + +func (snake SnakeCaseString) PascalCase() string { + return xstrings.ToCamelCase(string(snake)) // in xstrings v1.15.0 we can switch to using ToPascalCase for same functionality +} + +func (snake SnakeCaseString) LowerCaseNoUnderscore() string { + return strings.ReplaceAll(string(snake), "_", "") +} diff --git a/tools/codegen/codespec/terraform_helper.go b/tools/codegen/codespec/terraform_helper.go new file mode 100644 index 0000000000..84e764f82c --- /dev/null +++ b/tools/codegen/codespec/terraform_helper.go @@ -0,0 +1,28 @@ +package codespec + +import ( + "fmt" + "regexp" + "strings" +) + +var ( + camelCase = regexp.MustCompile(`([a-z])[A-Z]`) + unsupportedCharacters = regexp.MustCompile(`[^a-zA-Z0-9_]+`) +) + +func terraformAttrName(attrName string) SnakeCaseString { + if attrName == "" { + return SnakeCaseString(attrName) + } + + removedUnsupported := unsupportedCharacters.ReplaceAllString(attrName, "") + + insertedUnderscores := camelCase.ReplaceAllStringFunc(removedUnsupported, func(s string) string { + firstChar := s[0] + restOfString := s[1:] + return fmt.Sprintf("%c_%s", firstChar, strings.ToLower(restOfString)) + }) + + return SnakeCaseString(strings.ToLower(insertedUnderscores)) +} diff --git a/tools/codegen/codespec/testdata/api-spec.yml b/tools/codegen/codespec/testdata/api-spec.yml new file mode 100644 index 0000000000..42eda820a7 --- /dev/null +++ b/tools/codegen/codespec/testdata/api-spec.yml @@ -0,0 +1,465 @@ +openapi: 3.0.1 +info: + description: >- + The MongoDB Atlas Administration API allows developers to manage all + components in MongoDB Atlas. + + + The Atlas Administration API uses HTTP Digest Authentication to authenticate requests. Provide a programmatic API public key and corresponding private key as the username and password when constructing the HTTP request. For example, to [return database access history](#tag/Access-Tracking/operation/listAccessLogsByClusterName) with [cURL](https://en.wikipedia.org/wiki/CURL), run the following command in the terminal: + + + ``` + + curl --user "{PUBLIC-KEY}:{PRIVATE-KEY}" \ + --digest \ + --header "Accept: application/vnd.atlas.2024-08-05+json" \ + -X GET "https://cloud.mongodb.com/api/atlas/v2/groups/{groupId}/dbAccessHistory/clusters/{clusterName}?pretty=true" + ``` + + + To learn more, see [Get Started with the Atlas Administration API](https://www.mongodb.com/docs/atlas/configure-api-access/). For support, see [MongoDB Support](https://www.mongodb.com/support/get-started). + + + You can also explore the various endpoints available through the Atlas Administration API in MongoDB's [Postman workspace](https://www.postman.com/mongodb-devrel/workspace/mongodb-atlas-administration-apis/). + license: + name: CC BY-NC-SA 3.0 US + url: https://creativecommons.org/licenses/by-nc-sa/3.0/us/ + termsOfService: https://www.mongodb.com/mongodb-management-service-terms-and-conditions + title: MongoDB Atlas Administration API + version: "2.0" + x-xgen-sha: 991036ecf95ec6855a39cd80bd2a15a90e012e7d +servers: + - url: https://cloud.mongodb.com +tags: + - description: Test Resource root description. + name: Test Resource +paths: + "/api/atlas/v2/groups/{groupId}/testResource": + delete: + description: DELETE API description + operationId: deleteTestResourceConfiguration + parameters: + - $ref: "#/components/parameters/groupId" + responses: + "200": + content: + application/vnd.atlas.2023-01-01+json: + x-xgen-version: 2023-01-01 + description: OK + security: + - DigestAuth: [] + summary: Disable the Test Resource feature for a project. + tags: + - Test Resource + get: + description: GET API description + operationId: getTestResourceConfiguration + parameters: + - $ref: "#/components/parameters/groupId" + responses: + "200": + content: + application/vnd.atlas.2023-01-01+json: + schema: + $ref: "#/components/schemas/TestResource" + x-xgen-version: 2023-01-01 + description: OK + security: + - DigestAuth: [] + summary: Get the Test Resource configuration for a project + tags: + - Test Resource + patch: + description: PATCH API description + operationId: updateTestResourceConfiguration + parameters: + - $ref: "#/components/parameters/groupId" + requestBody: + content: + application/vnd.atlas.2023-01-01+json: + schema: + $ref: "#/components/schemas/TestResource" + x-xgen-version: 2023-01-01 + description: Patch request description + required: true + responses: + "200": + content: + application/vnd.atlas.2023-01-01+json: + x-xgen-version: 2023-01-01 + description: OK + security: + - DigestAuth: [] + summary: Update the Test Resource feature for a project + tags: + - Test Resource + post: + description: POST API description + operationId: createTestResourceConfiguration + parameters: + - $ref: "#/components/parameters/groupId" + requestBody: + content: + application/vnd.atlas.2023-01-01+json: + schema: + $ref: "#/components/schemas/CreateTestResourceRequest" + x-xgen-version: 2023-01-01 + description: Create request description + required: true + responses: + "200": + content: + application/vnd.atlas.2023-01-01+json: + x-xgen-version: 2023-01-01 + description: OK + security: + - DigestAuth: [] + summary: Enable the Test Resource feature for a project + tags: + - Test Resource + "/api/atlas/v2/groups/{groupId}/clusters/{clusterName}/nestedTestResource": + delete: + description: DELETE API description + operationId: deleteNestedTestResource + parameters: + - $ref: "#/components/parameters/groupId" + - description: Path param test description + in: path + name: clusterName + required: true + schema: + type: string + maxLength: 64 + minLength: 1 + pattern: ^([a-zA-Z0-9][a-zA-Z0-9-]*)?[a-zA-Z0-9]+$ + responses: + "204": + content: + application/vnd.atlas.2024-05-30+json: + schema: + $ref: "#/components/schemas/NoBody" + x-xgen-version: 2024-05-30 + description: This endpoint does not return a response body. + security: + - DigestAuth: [] + summary: Delete Search Nodes + tags: + - Atlas Search + get: + description: GET API description + operationId: getNestedTestResource + parameters: + - $ref: "#/components/parameters/groupId" + - description: Path param test description + in: path + name: clusterName + required: true + schema: + type: string + maxLength: 64 + minLength: 1 + pattern: ^([a-zA-Z0-9][a-zA-Z0-9-]*)?[a-zA-Z0-9]+$ + responses: + "200": + content: + application/vnd.atlas.2024-05-30+json: + schema: + $ref: "#/components/schemas/NestedTestResourceResponse" + x-xgen-version: 2024-05-30 + description: OK + security: + - DigestAuth: [] + summary: Return Search Nodes + tags: + - Atlas Search + patch: + description: PATCH API description + operationId: updateNestedTestResource + parameters: + - $ref: "#/components/parameters/groupId" + - description: Path param test description + in: path + name: clusterName + required: true + schema: + type: string + maxLength: 64 + minLength: 1 + pattern: ^([a-zA-Z0-9][a-zA-Z0-9-]*)?[a-zA-Z0-9]+$ + requestBody: + content: + application/vnd.atlas.2024-05-30+json: + schema: + $ref: "#/components/schemas/NestedTestResourceRequest" + description: Updates the Search Nodes for the specified cluster. + required: true + responses: + "200": + content: + application/vnd.atlas.2024-05-30+json: + schema: + $ref: "#/components/schemas/NestedTestResourceResponse" + x-xgen-version: 2024-05-30 + description: OK + security: + - DigestAuth: [] + summary: Update Search Nodes + tags: + - Atlas Search + post: + description: POST API description + operationId: createNestedTestResource + parameters: + - $ref: "#/components/parameters/groupId" + - description: Path param test description + in: path + name: clusterName + required: true + schema: + type: string + maxLength: 64 + minLength: 1 + pattern: ^([a-zA-Z0-9][a-zA-Z0-9-]*)?[a-zA-Z0-9]+$ + requestBody: + content: + application/vnd.atlas.2024-05-30+json: + schema: + $ref: "#/components/schemas/NestedTestResourceRequest" + description: Creates Search Nodes for the specified cluster. + required: true + responses: + "201": + content: + application/vnd.atlas.2024-05-30+json: + schema: + $ref: "#/components/schemas/NestedTestResourceResponse" + x-xgen-version: 2024-05-30 + description: Created + security: + - DigestAuth: [] + summary: Create Nested Test Resource + tags: + - Atlas Search +components: + parameters: + groupId: + description: >- + Path param test description + in: path + name: groupId + required: true + schema: + type: string + example: 32b6e34b3d91647abb20e7b8 + maxLength: 24 + minLength: 24 + pattern: ^([a-f0-9]{24})$ + responses: + accepted: + description: Accepted. + schemas: + CreateTestResourceRequest: + type: object + properties: + strReqAttr1: + type: string + description: Test field description + strReqAttr2: + type: string + description: Test field description + strReqAttr3: + type: string + description: Test field description + boolDefaultAttr: + type: boolean + default: false + count: + type: integer + format: int32 + description: Test field description + numDoubleDefaultAttr: + type: number + format: double + default: 2.0 + required: + - strReqAttr1 + - strReqAttr2 + - strReqAttr3 + TestResource: + type: object + properties: + strReqAttr1: + type: string + description: Test field description + createDate: + type: string + format: date-time + description: Test field description + readOnly: true + strReqAttr2: + type: string + description: Test field description + strReqAttr3: + type: string + description: Test field description + strComputedAttr: + type: string + description: Test field description + readOnly: true + boolDefaultAttr: + type: boolean + default: false + count: + type: integer + format: int32 + description: Test field description + numDoubleDefaultAttr: + type: number + format: double + default: 2.0 + NestedTestResourceResponse: + type: object + properties: + groupId: + type: string + description: Path param test description + example: 32b6e34b3d91647abb20e7b8 + maxLength: 24 + minLength: 24 + pattern: ^([a-f0-9]{24})$ + readOnly: true + nestedListArrayAttr: + type: array + description: Test field description + items: + type: object + properties: + innerNumAttr: + type: integer + format: int32 + description: Test field description + example: 2 + maximum: 32 + minimum: 2 + listPrimitiveStringAttr: + type: array + description: Test field description + items: + type: string + listPrimitiveStringComputedAttr: + type: array + description: Test field description + items: + type: string + required: + - innerNumAttr + readOnly: true + nestedSetArrayAttr: + type: array + description: Test field description + items: + $ref: "#/components/schemas/NestedObjectAttr" + readOnly: true + uniqueItems: true + outerObject: + $ref: "#/components/schemas/OuterObject" + setPrimitiveStringAttr: + type: array + description: Test field description + items: + type: string + uniqueItems: true + listPrimitiveStringAttr: + type: array + description: Test field description + items: + type: string + singleNestedAttrWithNestedMaps: + $ref: "#/components/schemas/SingleNestedAttrWithNestedMaps" + singleNestedAttr: + $ref: "#/components/schemas/SingleNestedAttr" + nestedMapObjectAttr: + $ref: "#/components/schemas/NestedMapObjectAttr" + SingleNestedAttrWithNestedMaps: + type: object + description: Test field description + properties: + mapAttr1: + type: object + additionalProperties: + type: string + readOnly: true + readOnly: true + mapAttr2: + type: object + additionalProperties: + type: string + readOnly: true + readOnly: true + readOnly: true + title: Outbound Control Plane IP Addresses By Cloud Provider + SingleNestedAttr: + type: object + description: Test field description + properties: + innerIntAttr: + type: integer + description: Test field description + innerStrAttr: + $ref: "#/components/schemas/SimpleStringRefObject" + required: + - innerIntAttr + - innerStrAttr + NestedObjectAttr: + type: object + properties: + innerNumAttr: + type: integer + format: int32 + description: Test field description + example: 2 + maximum: 32 + minimum: 2 + listPrimitiveStringAttr: + type: array + description: Test field description + items: + type: string + required: + - innerNumAttr + NestedTestResourceRequest: + type: object + properties: + nestedListArrayAttr: + type: array + description: Test field description + items: + $ref: "#/components/schemas/NestedObjectAttr" + maxItems: 1 + minItems: 1 + required: + - nestedListArrayAttr + SimpleStringRefObject: + type: string + description: Test field description + NoBody: + type: object + description: Endpoint does not return a response body. + NestedMapObjectAttr: + type: object + additionalProperties: + type: object + properties: + attr: + type: string + OuterObject: + type: object + properties: + nestedLevel1: + $ref: '#/components/schemas/NestedLevel1Object' + NestedLevel1Object: + type: object + properties: + levelField1: + type: string + diff --git a/tools/codegen/codespec/testdata/config-nested-schema-overrides.yml b/tools/codegen/codespec/testdata/config-nested-schema-overrides.yml new file mode 100644 index 0000000000..95b2f7a9ee --- /dev/null +++ b/tools/codegen/codespec/testdata/config-nested-schema-overrides.yml @@ -0,0 +1,28 @@ +resources: + test_resource_with_nested_attr_overrides: + read: + path: /api/atlas/v2/groups/{groupId}/clusters/{clusterName}/nestedTestResource + method: GET + create: + path: /api/atlas/v2/groups/{groupId}/clusters/{clusterName}/nestedTestResource + method: POST + update: + path: /api/atlas/v2/groups/{groupId}/clusters/{clusterName}/nestedTestResource + method: PATCH + delete: + path: /api/atlas/v2/groups/{groupId}/clusters/{clusterName}/nestedTestResource + method: DELETE + schema: + aliases: + group_id: project_id + nested_list_array_attr.inner_num_attr: inner_num_attr_alias + outer_object.nested_level1.level_field1: level_field1_alias + + ignores: ["nested_list_array_attr.list_primitive_string_attr", "cluster_name", "list_primitive_string_attr", "nested_map_object_attr", "nested_set_array_attr","set_primitive_string_attr", "single_nested_attr", "single_nested_attr_with_nested_maps"] + + overrides: + nested_list_array_attr.inner_num_attr_alias: + description: "Overridden inner_num_attr_alias description" + outer_object.nested_level1.level_field1_alias: + description: "Overridden level_field1_alias description" + timeouts: ["create", "read", "update", "delete"] diff --git a/tools/codegen/codespec/testdata/config-nested-schema.yml b/tools/codegen/codespec/testdata/config-nested-schema.yml new file mode 100644 index 0000000000..54f4d80ee7 --- /dev/null +++ b/tools/codegen/codespec/testdata/config-nested-schema.yml @@ -0,0 +1,16 @@ +resources: + test_resource_with_nested_attr: + read: + path: /api/atlas/v2/groups/{groupId}/clusters/{clusterName}/nestedTestResource + method: GET + create: + path: /api/atlas/v2/groups/{groupId}/clusters/{clusterName}/nestedTestResource + method: POST + update: + path: /api/atlas/v2/groups/{groupId}/clusters/{clusterName}/nestedTestResource + method: PATCH + delete: + path: /api/atlas/v2/groups/{groupId}/clusters/{clusterName}/nestedTestResource + method: DELETE + schema: + ignores: ["outer_object"] \ No newline at end of file diff --git a/tools/codegen/codespec/testdata/config-no-schema-opts.yml b/tools/codegen/codespec/testdata/config-no-schema-opts.yml new file mode 100644 index 0000000000..5a9921e8d5 --- /dev/null +++ b/tools/codegen/codespec/testdata/config-no-schema-opts.yml @@ -0,0 +1,14 @@ +resources: + test_resource: + read: + path: /api/atlas/v2/groups/{groupId}/testResource + method: GET + create: + path: /api/atlas/v2/groups/{groupId}/testResource + method: POST + update: + path: /api/atlas/v2/groups/{groupId}/testResource + method: PATCH + delete: + path: /api/atlas/v2/groups/{groupId}/testResource + method: DELETE diff --git a/tools/codegen/codespec/testdata/config.yml b/tools/codegen/codespec/testdata/config.yml new file mode 100644 index 0000000000..661116eb9a --- /dev/null +++ b/tools/codegen/codespec/testdata/config.yml @@ -0,0 +1,36 @@ +resources: + test_resource: + read: + path: /api/atlas/v2/groups/{groupId}/testResource + method: GET + create: + path: /api/atlas/v2/groups/{groupId}/testResource + method: POST + schema: + aliases: + group_id: project_id + + ignores: ["links"] + + overrides: + project_id: + description: "Overridden project_id description" + default: "defaultProjectId" + plan_modifiers: [{ + imports: [ "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" ], + definition: "stringplanmodifier.RequiresReplace()" + }] + validators: [{ + imports: [ + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator", + "github.com/hashicorp/terraform-plugin-framework/path" + ], + definition: "stringvalidator.ConflictsWith(path.MatchRoot(\"name\"))" + }] + + prefix_path: + computability: + optional: true + computed: true + + timeouts: ["create", "read", "delete"] diff --git a/tools/codegen/config.yml b/tools/codegen/config.yml new file mode 100644 index 0000000000..fcd9a73079 --- /dev/null +++ b/tools/codegen/config.yml @@ -0,0 +1,45 @@ +resources: + push_based_log_export: + read: + path: /api/atlas/v2/groups/{groupId}/pushBasedLogExport + method: GET + create: + path: /api/atlas/v2/groups/{groupId}/pushBasedLogExport + method: POST + schema: + aliases: + group_id: project_id + ignores: ["links"] + timeouts: ["create", "update", "delete"] + + # overrides: + # project_id: + # plan_modifiers: [{ + # imports: [ "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" ], + # definition: "stringplanmodifier.RequiresReplace()" + # }] + # validators: [{ + # imports: [ + # "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator", + # "github.com/hashicorp/terraform-plugin-framework/path" + # ], + # definition: "stringvalidator.ConflictsWith(path.MatchRoot(\"name\"))" + # }] + + # prefix_path: + # computability: + # optional: true + # computed: true + + search_deployment: + read: + path: /api/atlas/v2/groups/{groupId}/clusters/{clusterName}/search/deployment + method: GET + create: + path: /api/atlas/v2/groups/{groupId}/clusters/{clusterName}/search/deployment + method: POST + schema: + aliases: + group_id: project_id + ignores: ["links"] + timeouts: ["create", "update", "delete"] diff --git a/tools/codegen/config/config_model.go b/tools/codegen/config/config_model.go new file mode 100644 index 0000000000..322fc44962 --- /dev/null +++ b/tools/codegen/config/config_model.go @@ -0,0 +1,48 @@ +package config + +type Config struct { + Resources map[string]Resource `yaml:"resources"` +} + +type Resource struct { + Create *APIOperation `yaml:"create"` + Read *APIOperation `yaml:"read"` + Update *APIOperation `yaml:"update"` + Delete *APIOperation `yaml:"delete"` + SchemaOptions SchemaOptions `yaml:"schema"` +} + +type APIOperation struct { + Path string `yaml:"path"` + Method string `yaml:"method"` +} + +type SchemaOptions struct { + Ignores []string `yaml:"ignores"` + Aliases map[string]string `yaml:"aliases"` + Overrides map[string]Override `yaml:"overrides"` + Timeouts []string `yaml:"timeouts"` +} + +type Override struct { + Description string `yaml:"description"` + PlanModifiers []PlanModifier `yaml:"plan_modifiers"` + Validators []Validator `yaml:"validators"` + Computability Computability `yaml:"computability"` +} + +type PlanModifier struct { + Definition string `yaml:"definition"` + Imports []string `yaml:"imports"` +} + +type Validator struct { + Definition string `yaml:"definition"` + Imports []string `yaml:"imports"` +} + +type Computability struct { + Optional bool `yaml:"optional"` + Computed bool `yaml:"computed"` + Required bool `yaml:"required"` +} diff --git a/tools/codegen/config/parser.go b/tools/codegen/config/parser.go new file mode 100644 index 0000000000..73495b10f9 --- /dev/null +++ b/tools/codegen/config/parser.go @@ -0,0 +1,22 @@ +package config + +import ( + "os" + + "gopkg.in/yaml.v3" +) + +func ParseGenConfigYAML(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + var config Config + err = yaml.Unmarshal(data, &config) + if err != nil { + return nil, err + } + + return &config, nil +} diff --git a/tools/codegen/main.go b/tools/codegen/main.go new file mode 100644 index 0000000000..f45e2e3c98 --- /dev/null +++ b/tools/codegen/main.go @@ -0,0 +1,54 @@ +package main + +import ( + "fmt" + "log" + "os" + + "github.com/mongodb/terraform-provider-mongodbatlas/tools/codegen/codespec" + "github.com/mongodb/terraform-provider-mongodbatlas/tools/codegen/openapi" + "github.com/mongodb/terraform-provider-mongodbatlas/tools/codegen/schema" +) + +const ( + atlasAdminAPISpecURL = "https://raw.githubusercontent.com/mongodb/atlas-sdk-go/main/openapi/atlas-api-transformed.yaml" + configPath = "tools/codegen/config.yml" + specFilePath = "tools/codegen/open-api-spec.yml" +) + +func main() { + resourceName := getOsArg() + + if err := openapi.DownloadOpenAPISpec(atlasAdminAPISpecURL, specFilePath); err != nil { + log.Fatalf("an error occurred when downloading Atlas Admin API spec: %v", err) + } + + model, err := codespec.ToCodeSpecModel(specFilePath, configPath, resourceName) + if err != nil { + log.Fatalf("an error occurred while generating codespec.Model: %v", err) + } + + for i := range model.Resources { + resourceModel := model.Resources[i] + schemaCode := schema.GenerateGoCode(resourceModel) + if err := writeToFile(fmt.Sprintf("internal/service/%s/resource_schema.go", resourceModel.Name.LowerCaseNoUnderscore()), schemaCode); err != nil { + log.Fatalf("an error occurred when writing content to file: %v", err) + } + } +} + +func getOsArg() *string { + if len(os.Args) < 2 { + return nil + } + return &os.Args[1] +} + +func writeToFile(fileName, content string) error { + // will override content if file exists + err := os.WriteFile(fileName, []byte(content), 0o600) + if err != nil { + return err + } + return nil +} diff --git a/tools/codegen/openapi/parser.go b/tools/codegen/openapi/parser.go new file mode 100644 index 0000000000..c6908de2d3 --- /dev/null +++ b/tools/codegen/openapi/parser.go @@ -0,0 +1,58 @@ +package openapi + +import ( + "context" + "fmt" + "io" + "net/http" + "os" + "time" + + "github.com/pb33f/libopenapi" + v3 "github.com/pb33f/libopenapi/datamodel/high/v3" +) + +func ParseAtlasAdminAPI(filePath string) (*libopenapi.DocumentModel[v3.Document], error) { + atlasAPISpec, _ := os.ReadFile(filePath) + document, err := libopenapi.NewDocument(atlasAPISpec) + if err != nil { + return nil, fmt.Errorf("cannot create new document: %e", err) + } + docModel, errors := document.BuildV3Model() + if len(errors) > 0 { + for i := range errors { + fmt.Printf("error: %e\n", errors[i]) + } + return nil, fmt.Errorf("cannot create v3 model from document: %d errors reported", len(errors)) + } + + return docModel, nil +} + +func DownloadOpenAPISpec(url, specFilePath string) (err error) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody) + if err != nil { + return err + } + + client := http.Client{} + res, getErr := client.Do(req) + if getErr != nil { + return getErr + } + + if res.Body != nil { + defer res.Body.Close() + } + + body, readErr := io.ReadAll(res.Body) + if readErr != nil { + return readErr + } + + err = os.WriteFile(specFilePath, body, 0o600) + return err +} diff --git a/tools/codegen/schema/code_statement.go b/tools/codegen/schema/code_statement.go new file mode 100644 index 0000000000..2c2b79f170 --- /dev/null +++ b/tools/codegen/schema/code_statement.go @@ -0,0 +1,20 @@ +package schema + +type CodeStatement struct { + Code string + Imports []string +} + +func GroupCodeStatements(stmts []CodeStatement, grouping func([]string) string) CodeStatement { + listOfCode := []string{} + imports := []string{} + for i := range stmts { + listOfCode = append(listOfCode, stmts[i].Code) + imports = append(imports, stmts[i].Imports...) + } + resultCode := grouping(listOfCode) + return CodeStatement{ + Code: resultCode, + Imports: imports, + } +} diff --git a/tools/codegen/schema/codetemplate/schema-file.go.tmpl b/tools/codegen/schema/codetemplate/schema-file.go.tmpl new file mode 100644 index 0000000000..d6197c929d --- /dev/null +++ b/tools/codegen/schema/codetemplate/schema-file.go.tmpl @@ -0,0 +1,18 @@ +package {{ .PackageName }} + +import ( + "context" + {{range .Imports }} + "{{ . }}" + {{- end }} +) + +func ResourceSchema(ctx context.Context) schema.Schema { + return schema.Schema{ + Attributes: map[string]schema.Attribute{ + {{ .SchemaAttributes }} + }, + } +} + +{{ .Models }} diff --git a/tools/codegen/schema/codetemplate/template.go b/tools/codegen/schema/codetemplate/template.go new file mode 100644 index 0000000000..7765b9e6c9 --- /dev/null +++ b/tools/codegen/schema/codetemplate/template.go @@ -0,0 +1,32 @@ +package codetemplate + +import ( + "bytes" + _ "embed" + "text/template" +) + +//go:embed schema-file.go.tmpl +var schemaFileTemplate string + +type SchemaFileInputs struct { + PackageName string + SchemaAttributes string + Models string + Imports []string +} + +func ApplySchemaFileTemplate(inputs SchemaFileInputs) bytes.Buffer { + t, err := template.New("template").Parse(schemaFileTemplate) + if err != nil { + panic(err) + } + + var buf bytes.Buffer + err = t.Execute(&buf, inputs) + if err != nil { + panic(err) + } + + return buf +} diff --git a/tools/codegen/schema/element_type_mapping.go b/tools/codegen/schema/element_type_mapping.go new file mode 100644 index 0000000000..34685b2381 --- /dev/null +++ b/tools/codegen/schema/element_type_mapping.go @@ -0,0 +1,25 @@ +package schema + +import ( + "fmt" + + "github.com/mongodb/terraform-provider-mongodbatlas/tools/codegen/codespec" +) + +var elementTypeToString = map[codespec.ElemType]string{ + codespec.Bool: "types.BoolType", + codespec.Float64: "types.Float64Type", + codespec.Int64: "types.Int64Type", + codespec.Number: "types.NumberType", + codespec.String: "types.StringType", +} + +const typesImportStatement = "github.com/hashicorp/terraform-plugin-framework/types" + +func ElementTypeProperty(elementType codespec.ElemType) CodeStatement { + result := elementTypeToString[elementType] + return CodeStatement{ + Code: fmt.Sprintf("ElementType: %s", result), + Imports: []string{typesImportStatement}, + } +} diff --git a/tools/codegen/schema/schema_attribute.go b/tools/codegen/schema/schema_attribute.go new file mode 100644 index 0000000000..a99b7012a1 --- /dev/null +++ b/tools/codegen/schema/schema_attribute.go @@ -0,0 +1,145 @@ +package schema + +import ( + "fmt" + "strings" + + "github.com/mongodb/terraform-provider-mongodbatlas/tools/codegen/codespec" +) + +func GenerateSchemaAttributes(attrs codespec.Attributes) CodeStatement { + attrsCode := []string{} + imports := []string{} + for i := range attrs { + result := generator(&attrs[i]).AttributeCode() + attrsCode = append(attrsCode, result.Code) + imports = append(imports, result.Imports...) + } + finalAttrs := strings.Join(attrsCode, ",\n") + "," + return CodeStatement{ + Code: finalAttrs, + Imports: imports, + } +} + +type attributeGenerator interface { + AttributeCode() CodeStatement +} + +func generator(attr *codespec.Attribute) attributeGenerator { + if attr.Int64 != nil { + return &Int64AttrGenerator{intModel: *attr.Int64, attr: *attr} + } + if attr.Float64 != nil { + return &Float64AttrGenerator{ + floatModel: *attr.Float64, + attr: *attr, + } + } + if attr.String != nil { + return &StringAttrGenerator{ + stringModel: *attr.String, + attr: *attr, + } + } + if attr.Bool != nil { + return &BoolAttrGenerator{ + boolModel: *attr.Bool, + attr: *attr, + } + } + if attr.List != nil { + return &ListAttrGenerator{ + listModel: *attr.List, + attr: *attr, + } + } + if attr.ListNested != nil { + return &ListNestedAttrGenerator{ + listNestedModel: *attr.ListNested, + attr: *attr, + } + } + if attr.Map != nil { + return &MapAttrGenerator{ + mapModel: *attr.Map, + attr: *attr, + } + } + if attr.MapNested != nil { + return &MapNestedAttrGenerator{ + mapNestedModel: *attr.MapNested, + attr: *attr, + } + } + if attr.Number != nil { + return &NumberAttrGenerator{ + numberModel: *attr.Number, + attr: *attr, + } + } + if attr.Set != nil { + return &SetAttrGenerator{ + setModel: *attr.Set, + attr: *attr, + } + } + if attr.SetNested != nil { + return &SetNestedGenerator{ + setNestedModel: *attr.SetNested, + attr: *attr, + } + } + if attr.SingleNested != nil { + return &SingleNestedAttrGenerator{ + singleNestedModel: *attr.SingleNested, + attr: *attr, + } + } + if attr.Timeouts != nil { + return &timeoutAttributeGenerator{ + timeouts: *attr.Timeouts, + } + } + panic("Attribute with unknown type defined when generating schema attribute") +} + +// generation of conventional attribute types which have common properties like MarkdownDescription, Computed/Optional/Required, Sensitive +func commonAttrStructure(attr *codespec.Attribute, typeDef string, specificProperties []CodeStatement) CodeStatement { + properties := commonProperties(attr) + imports := []string{} + for i := range specificProperties { + properties = append(properties, specificProperties[i].Code) + imports = append(imports, specificProperties[i].Imports...) + } + + name := attr.Name + propsResultString := strings.Join(properties, ",\n") + "," + code := fmt.Sprintf(`"%s": %s{ + %s + }`, name, typeDef, propsResultString) + return CodeStatement{ + Code: code, + Imports: imports, + } +} + +func commonProperties(attr *codespec.Attribute) []string { + var result []string + if attr.ComputedOptionalRequired == codespec.Required { + result = append(result, "Required: true") + } + if attr.ComputedOptionalRequired == codespec.Computed || attr.ComputedOptionalRequired == codespec.ComputedOptional { + result = append(result, "Computed: true") + } + if attr.ComputedOptionalRequired == codespec.Optional || attr.ComputedOptionalRequired == codespec.ComputedOptional { + result = append(result, "Optional: true") + } + if attr.Description != nil { + result = append(result, fmt.Sprintf("MarkdownDescription: %q", *attr.Description)) + } + if attr.Sensitive != nil && *attr.Sensitive { + result = append(result, "Sensitive: true") + } + return result +} diff --git a/tools/codegen/schema/schema_attribute_nested.go b/tools/codegen/schema/schema_attribute_nested.go new file mode 100644 index 0000000000..90a283c260 --- /dev/null +++ b/tools/codegen/schema/schema_attribute_nested.go @@ -0,0 +1,65 @@ +package schema + +import ( + "fmt" + + "github.com/mongodb/terraform-provider-mongodbatlas/tools/codegen/codespec" +) + +type ListNestedAttrGenerator struct { + attr codespec.Attribute + listNestedModel codespec.ListNestedAttribute +} + +func (l *ListNestedAttrGenerator) AttributeCode() CodeStatement { + return commonAttrStructure(&l.attr, "schema.ListNestedAttribute", []CodeStatement{nestedObjectProperty(l.listNestedModel.NestedObject)}) +} + +type SetNestedGenerator struct { + attr codespec.Attribute + setNestedModel codespec.SetNestedAttribute +} + +func (l *SetNestedGenerator) AttributeCode() CodeStatement { + return commonAttrStructure(&l.attr, "schema.SetNestedAttribute", []CodeStatement{nestedObjectProperty(l.setNestedModel.NestedObject)}) +} + +type MapNestedAttrGenerator struct { + attr codespec.Attribute + mapNestedModel codespec.MapNestedAttribute +} + +func (m *MapNestedAttrGenerator) AttributeCode() CodeStatement { + return commonAttrStructure(&m.attr, "schema.MapNestedAttribute", []CodeStatement{nestedObjectProperty(m.mapNestedModel.NestedObject)}) +} + +type SingleNestedAttrGenerator struct { + attr codespec.Attribute + singleNestedModel codespec.SingleNestedAttribute +} + +func (l *SingleNestedAttrGenerator) AttributeCode() CodeStatement { + return commonAttrStructure(&l.attr, "schema.SingleNestedAttribute", []CodeStatement{attributesProperty(l.singleNestedModel.NestedObject)}) +} + +func attributesProperty(nested codespec.NestedAttributeObject) CodeStatement { + attrs := GenerateSchemaAttributes(nested.Attributes) + attributeProperty := fmt.Sprintf(`Attributes: map[string]schema.Attribute{ + %s + }`, attrs.Code) + return CodeStatement{ + Code: attributeProperty, + Imports: attrs.Imports, + } +} + +func nestedObjectProperty(nested codespec.NestedAttributeObject) CodeStatement { + result := attributesProperty(nested) + nestedObj := fmt.Sprintf(`NestedObject: schema.NestedAttributeObject{ + %s, + }`, result.Code) + return CodeStatement{ + Code: nestedObj, + Imports: result.Imports, + } +} diff --git a/tools/codegen/schema/schema_attribute_primitive.go b/tools/codegen/schema/schema_attribute_primitive.go new file mode 100644 index 0000000000..79e44975c0 --- /dev/null +++ b/tools/codegen/schema/schema_attribute_primitive.go @@ -0,0 +1,75 @@ +package schema + +import "github.com/mongodb/terraform-provider-mongodbatlas/tools/codegen/codespec" + +type Int64AttrGenerator struct { + intModel codespec.Int64Attribute + attr codespec.Attribute +} + +func (i *Int64AttrGenerator) AttributeCode() CodeStatement { + return commonAttrStructure(&i.attr, "schema.Int64Attribute", []CodeStatement{}) +} + +type Float64AttrGenerator struct { + floatModel codespec.Float64Attribute + attr codespec.Attribute +} + +func (f *Float64AttrGenerator) AttributeCode() CodeStatement { + return commonAttrStructure(&f.attr, "schema.Float64Attribute", []CodeStatement{}) +} + +type StringAttrGenerator struct { + stringModel codespec.StringAttribute + attr codespec.Attribute +} + +func (s *StringAttrGenerator) AttributeCode() CodeStatement { + return commonAttrStructure(&s.attr, "schema.StringAttribute", []CodeStatement{}) +} + +type BoolAttrGenerator struct { + boolModel codespec.BoolAttribute + attr codespec.Attribute +} + +func (s *BoolAttrGenerator) AttributeCode() CodeStatement { + return commonAttrStructure(&s.attr, "schema.BoolAttribute", []CodeStatement{}) +} + +type NumberAttrGenerator struct { + numberModel codespec.NumberAttribute + attr codespec.Attribute +} + +func (s *NumberAttrGenerator) AttributeCode() CodeStatement { + return commonAttrStructure(&s.attr, "schema.NumberAttribute", []CodeStatement{}) +} + +type ListAttrGenerator struct { + listModel codespec.ListAttribute + attr codespec.Attribute +} + +func (l *ListAttrGenerator) AttributeCode() CodeStatement { + return commonAttrStructure(&l.attr, "schema.ListAttribute", []CodeStatement{ElementTypeProperty(l.listModel.ElementType)}) +} + +type MapAttrGenerator struct { + mapModel codespec.MapAttribute + attr codespec.Attribute +} + +func (m *MapAttrGenerator) AttributeCode() CodeStatement { + return commonAttrStructure(&m.attr, "schema.MapAttribute", []CodeStatement{ElementTypeProperty(m.mapModel.ElementType)}) +} + +type SetAttrGenerator struct { + setModel codespec.SetAttribute + attr codespec.Attribute +} + +func (s *SetAttrGenerator) AttributeCode() CodeStatement { + return commonAttrStructure(&s.attr, "schema.SetAttribute", []CodeStatement{ElementTypeProperty(s.setModel.ElementType)}) +} diff --git a/tools/codegen/schema/schema_attribute_timeout.go b/tools/codegen/schema/schema_attribute_timeout.go new file mode 100644 index 0000000000..4dc1928870 --- /dev/null +++ b/tools/codegen/schema/schema_attribute_timeout.go @@ -0,0 +1,33 @@ +package schema + +import ( + "fmt" + + "github.com/mongodb/terraform-provider-mongodbatlas/tools/codegen/codespec" +) + +type timeoutAttributeGenerator struct { + timeouts codespec.TimeoutsAttribute +} + +func (s *timeoutAttributeGenerator) AttributeCode() CodeStatement { + var optionProperties string + for _, op := range s.timeouts.ConfigurableTimeouts { + switch op { + case codespec.Create: + optionProperties += "Create: true,\n" + case codespec.Update: + optionProperties += "Update: true,\n" + case codespec.Delete: + optionProperties += "Delete: true,\n" + case codespec.Read: + optionProperties += "Read: true,\n" + } + } + return CodeStatement{ + Code: fmt.Sprintf(`"timeouts": timeouts.Attributes(ctx, timeouts.Opts{ + %s + })`, optionProperties), + Imports: []string{"github.com/hashicorp/terraform-plugin-framework-timeouts/resource/timeouts"}, + } +} diff --git a/tools/codegen/schema/schema_file.go b/tools/codegen/schema/schema_file.go new file mode 100644 index 0000000000..d05fe4ee74 --- /dev/null +++ b/tools/codegen/schema/schema_file.go @@ -0,0 +1,31 @@ +package schema + +import ( + "go/format" + + "github.com/mongodb/terraform-provider-mongodbatlas/tools/codegen/codespec" + "github.com/mongodb/terraform-provider-mongodbatlas/tools/codegen/schema/codetemplate" +) + +func GenerateGoCode(input codespec.Resource) string { + schemaAttrs := GenerateSchemaAttributes(input.Schema.Attributes) + models := GenerateTypedModels(input.Schema.Attributes) + + imports := []string{"github.com/hashicorp/terraform-plugin-framework/resource/schema"} + imports = append(imports, schemaAttrs.Imports...) + imports = append(imports, models.Imports...) + + tmplInputs := codetemplate.SchemaFileInputs{ + PackageName: input.Name.LowerCaseNoUnderscore(), + Imports: imports, + SchemaAttributes: schemaAttrs.Code, + Models: models.Code, + } + result := codetemplate.ApplySchemaFileTemplate(tmplInputs) + + formattedResult, err := format.Source(result.Bytes()) + if err != nil { + panic(err) + } + return string(formattedResult) +} diff --git a/tools/codegen/schema/schema_file_test.go b/tools/codegen/schema/schema_file_test.go new file mode 100644 index 0000000000..ca38fe1278 --- /dev/null +++ b/tools/codegen/schema/schema_file_test.go @@ -0,0 +1,178 @@ +package schema_test + +import ( + "testing" + + "github.com/mongodb/terraform-provider-mongodbatlas/tools/codegen/codespec" + "github.com/mongodb/terraform-provider-mongodbatlas/tools/codegen/schema" + "github.com/sebdah/goldie/v2" + "go.mongodb.org/atlas-sdk/v20240530005/admin" +) + +var stringAttr = codespec.Attribute{ + Name: "string_attr", + String: &codespec.StringAttribute{}, + Description: admin.PtrString("string attribute"), + ComputedOptionalRequired: codespec.Optional, +} + +var intAttr = codespec.Attribute{ + Name: "int_attr", + Int64: &codespec.Int64Attribute{}, + Description: admin.PtrString("int attribute"), + ComputedOptionalRequired: codespec.Required, +} + +type schemaGenerationTestCase struct { + inputModel codespec.Resource + goldenFileName string +} + +func TestSchemaGenerationFromCodeSpec(t *testing.T) { + testCases := map[string]schemaGenerationTestCase{ + "Primitive attributes": { + inputModel: codespec.Resource{ + Name: "test_name", + Schema: &codespec.Schema{ + Attributes: []codespec.Attribute{ + { + Name: "string_attr", + String: &codespec.StringAttribute{}, + Description: admin.PtrString("string description"), + ComputedOptionalRequired: codespec.Required, + }, + { + Name: "bool_attr", + Bool: &codespec.BoolAttribute{}, + Description: admin.PtrString("bool description"), + ComputedOptionalRequired: codespec.Optional, + }, + { + Name: "int_attr", + Int64: &codespec.Int64Attribute{}, + Description: admin.PtrString("int description"), + ComputedOptionalRequired: codespec.ComputedOptional, + }, + { + Name: "float_attr", + Float64: &codespec.Float64Attribute{}, + Description: admin.PtrString("float description"), + ComputedOptionalRequired: codespec.Optional, + }, + { + Name: "number_attr", + Number: &codespec.NumberAttribute{}, + Description: admin.PtrString("number description"), + ComputedOptionalRequired: codespec.Optional, + }, + { + Name: "simple_list_attr", + List: &codespec.ListAttribute{ + ElementType: codespec.String, + }, + Description: admin.PtrString("simple arr description"), + ComputedOptionalRequired: codespec.Optional, + }, + { + Name: "simple_set_attr", + Set: &codespec.SetAttribute{ + ElementType: codespec.Float64, + }, + Description: admin.PtrString("simple set description"), + ComputedOptionalRequired: codespec.Optional, + }, + { + Name: "simple_map_attr", + Map: &codespec.MapAttribute{ + ElementType: codespec.Bool, + }, + Description: admin.PtrString("simple map description"), + ComputedOptionalRequired: codespec.Optional, + }, + }, + }, + }, + goldenFileName: "primitive-attributes", + }, + "Nested attributes": { + inputModel: codespec.Resource{ + Name: "test_name", + Schema: &codespec.Schema{ + Attributes: []codespec.Attribute{ + { + Name: "nested_single_attr", + Description: admin.PtrString("nested single attribute"), + ComputedOptionalRequired: codespec.Required, + SingleNested: &codespec.SingleNestedAttribute{ + NestedObject: codespec.NestedAttributeObject{ + Attributes: []codespec.Attribute{stringAttr, intAttr}, + }, + }, + }, + { + Name: "nested_list_attr", + Description: admin.PtrString("nested list attribute"), + ComputedOptionalRequired: codespec.Optional, + ListNested: &codespec.ListNestedAttribute{ + NestedObject: codespec.NestedAttributeObject{ + Attributes: []codespec.Attribute{stringAttr, intAttr}, + }, + }, + }, + { + Name: "set_nested_attribute", + Description: admin.PtrString("set nested attribute"), + ComputedOptionalRequired: codespec.ComputedOptional, + SetNested: &codespec.SetNestedAttribute{ + NestedObject: codespec.NestedAttributeObject{ + Attributes: []codespec.Attribute{stringAttr, intAttr}, + }, + }, + }, + { + Name: "map_nested_attribute", + Description: admin.PtrString("map nested attribute"), + ComputedOptionalRequired: codespec.ComputedOptional, + MapNested: &codespec.MapNestedAttribute{ + NestedObject: codespec.NestedAttributeObject{ + Attributes: []codespec.Attribute{stringAttr, intAttr}, + }, + }, + }, + }, + }, + }, + goldenFileName: "nested-attributes", + }, + "timeout attribute": { + inputModel: codespec.Resource{ + Name: "test_name", + Schema: &codespec.Schema{ + Attributes: []codespec.Attribute{ + { + Name: "string_attr", + String: &codespec.StringAttribute{}, + Description: admin.PtrString("string description"), + ComputedOptionalRequired: codespec.Required, + }, + { + Name: "timeouts", + Timeouts: &codespec.TimeoutsAttribute{ + ConfigurableTimeouts: []codespec.Operation{codespec.Create, codespec.Update, codespec.Delete}, + }, + }, + }, + }, + }, + goldenFileName: "timeouts", + }, + } + + for testName, tc := range testCases { + t.Run(testName, func(t *testing.T) { + result := schema.GenerateGoCode(tc.inputModel) + g := goldie.New(t, goldie.WithNameSuffix(".golden.go")) + g.Assert(t, tc.goldenFileName, []byte(result)) + }) + } +} diff --git a/tools/codegen/schema/testdata/nested-attributes.golden.go b/tools/codegen/schema/testdata/nested-attributes.golden.go new file mode 100644 index 0000000000..b0915d9593 --- /dev/null +++ b/tools/codegen/schema/testdata/nested-attributes.golden.go @@ -0,0 +1,126 @@ +package testname + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func ResourceSchema(ctx context.Context) schema.Schema { + return schema.Schema{ + Attributes: map[string]schema.Attribute{ + "nested_single_attr": schema.SingleNestedAttribute{ + Required: true, + MarkdownDescription: "nested single attribute", + Attributes: map[string]schema.Attribute{ + "string_attr": schema.StringAttribute{ + Optional: true, + MarkdownDescription: "string attribute", + }, + "int_attr": schema.Int64Attribute{ + Required: true, + MarkdownDescription: "int attribute", + }, + }, + }, + "nested_list_attr": schema.ListNestedAttribute{ + Optional: true, + MarkdownDescription: "nested list attribute", + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "string_attr": schema.StringAttribute{ + Optional: true, + MarkdownDescription: "string attribute", + }, + "int_attr": schema.Int64Attribute{ + Required: true, + MarkdownDescription: "int attribute", + }, + }, + }, + }, + "set_nested_attribute": schema.SetNestedAttribute{ + Computed: true, + Optional: true, + MarkdownDescription: "set nested attribute", + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "string_attr": schema.StringAttribute{ + Optional: true, + MarkdownDescription: "string attribute", + }, + "int_attr": schema.Int64Attribute{ + Required: true, + MarkdownDescription: "int attribute", + }, + }, + }, + }, + "map_nested_attribute": schema.MapNestedAttribute{ + Computed: true, + Optional: true, + MarkdownDescription: "map nested attribute", + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "string_attr": schema.StringAttribute{ + Optional: true, + MarkdownDescription: "string attribute", + }, + "int_attr": schema.Int64Attribute{ + Required: true, + MarkdownDescription: "int attribute", + }, + }, + }, + }, + }, + } +} + +type TFModel struct { + NestedSingleAttr types.Object `tfsdk:"nested_single_attr"` + NestedListAttr types.List `tfsdk:"nested_list_attr"` + SetNestedAttribute types.Set `tfsdk:"set_nested_attribute"` + MapNestedAttribute types.Map `tfsdk:"map_nested_attribute"` +} +type TFNestedSingleAttrModel struct { + StringAttr types.String `tfsdk:"string_attr"` + IntAttr types.Int64 `tfsdk:"int_attr"` +} + +var NestedSingleAttrObjType = types.ObjectType{AttrTypes: map[string]attr.Type{ + "string_attr": types.StringType, + "int_attr": types.Int64Type, +}} + +type TFNestedListAttrModel struct { + StringAttr types.String `tfsdk:"string_attr"` + IntAttr types.Int64 `tfsdk:"int_attr"` +} + +var NestedListAttrObjType = types.ObjectType{AttrTypes: map[string]attr.Type{ + "string_attr": types.StringType, + "int_attr": types.Int64Type, +}} + +type TFSetNestedAttributeModel struct { + StringAttr types.String `tfsdk:"string_attr"` + IntAttr types.Int64 `tfsdk:"int_attr"` +} + +var SetNestedAttributeObjType = types.ObjectType{AttrTypes: map[string]attr.Type{ + "string_attr": types.StringType, + "int_attr": types.Int64Type, +}} + +type TFMapNestedAttributeModel struct { + StringAttr types.String `tfsdk:"string_attr"` + IntAttr types.Int64 `tfsdk:"int_attr"` +} + +var MapNestedAttributeObjType = types.ObjectType{AttrTypes: map[string]attr.Type{ + "string_attr": types.StringType, + "int_attr": types.Int64Type, +}} diff --git a/tools/codegen/schema/testdata/primitive-attributes.golden.go b/tools/codegen/schema/testdata/primitive-attributes.golden.go new file mode 100644 index 0000000000..1025675c4b --- /dev/null +++ b/tools/codegen/schema/testdata/primitive-attributes.golden.go @@ -0,0 +1,62 @@ +package testname + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func ResourceSchema(ctx context.Context) schema.Schema { + return schema.Schema{ + Attributes: map[string]schema.Attribute{ + "string_attr": schema.StringAttribute{ + Required: true, + MarkdownDescription: "string description", + }, + "bool_attr": schema.BoolAttribute{ + Optional: true, + MarkdownDescription: "bool description", + }, + "int_attr": schema.Int64Attribute{ + Computed: true, + Optional: true, + MarkdownDescription: "int description", + }, + "float_attr": schema.Float64Attribute{ + Optional: true, + MarkdownDescription: "float description", + }, + "number_attr": schema.NumberAttribute{ + Optional: true, + MarkdownDescription: "number description", + }, + "simple_list_attr": schema.ListAttribute{ + Optional: true, + MarkdownDescription: "simple arr description", + ElementType: types.StringType, + }, + "simple_set_attr": schema.SetAttribute{ + Optional: true, + MarkdownDescription: "simple set description", + ElementType: types.Float64Type, + }, + "simple_map_attr": schema.MapAttribute{ + Optional: true, + MarkdownDescription: "simple map description", + ElementType: types.BoolType, + }, + }, + } +} + +type TFModel struct { + StringAttr types.String `tfsdk:"string_attr"` + BoolAttr types.Bool `tfsdk:"bool_attr"` + IntAttr types.Int64 `tfsdk:"int_attr"` + FloatAttr types.Float64 `tfsdk:"float_attr"` + NumberAttr types.Number `tfsdk:"number_attr"` + SimpleListAttr types.List `tfsdk:"simple_list_attr"` + SimpleSetAttr types.Set `tfsdk:"simple_set_attr"` + SimpleMapAttr types.Map `tfsdk:"simple_map_attr"` +} diff --git a/tools/codegen/schema/testdata/timeouts.golden.go b/tools/codegen/schema/testdata/timeouts.golden.go new file mode 100644 index 0000000000..ca5213bd1e --- /dev/null +++ b/tools/codegen/schema/testdata/timeouts.golden.go @@ -0,0 +1,30 @@ +package testname + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework-timeouts/resource/timeouts" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func ResourceSchema(ctx context.Context) schema.Schema { + return schema.Schema{ + Attributes: map[string]schema.Attribute{ + "string_attr": schema.StringAttribute{ + Required: true, + MarkdownDescription: "string description", + }, + "timeouts": timeouts.Attributes(ctx, timeouts.Opts{ + Create: true, + Update: true, + Delete: true, + }), + }, + } +} + +type TFModel struct { + StringAttr types.String `tfsdk:"string_attr"` + Timeouts timeouts.Value `tfsdk:"timeouts"` +} diff --git a/tools/codegen/schema/typed_model.go b/tools/codegen/schema/typed_model.go new file mode 100644 index 0000000000..5a324af296 --- /dev/null +++ b/tools/codegen/schema/typed_model.go @@ -0,0 +1,114 @@ +package schema + +import ( + "fmt" + "strings" + + "github.com/mongodb/terraform-provider-mongodbatlas/tools/codegen/codespec" +) + +func GenerateTypedModels(attributes codespec.Attributes) CodeStatement { + return generateTypedModels(attributes, "", false) // empty string for root model, results in TFModel +} + +func generateTypedModels(attributes codespec.Attributes, name string, isNested bool) CodeStatement { + models := []CodeStatement{generateStructOfTypedModel(attributes, name)} + + if isNested { + models = append(models, generateModelObjType(attributes, name)) + } + + for i := range attributes { + additionalModel := getNestedModel(&attributes[i]) + if additionalModel != nil { + models = append(models, *additionalModel) + } + } + + return GroupCodeStatements(models, func(list []string) string { return strings.Join(list, "\n") }) +} + +func generateModelObjType(attrs codespec.Attributes, name string) CodeStatement { + structProperties := []string{} + for i := range attrs { + propType := attrModelType(&attrs[i]) + prop := fmt.Sprintf(`%q: %sType,`, attrs[i].Name.SnakeCase(), propType) + structProperties = append(structProperties, prop) + } + structPropsCode := strings.Join(structProperties, "\n") + return CodeStatement{ + Code: fmt.Sprintf(`var %sObjType = types.ObjectType{AttrTypes: map[string]attr.Type{ + %s +}}`, name, structPropsCode), + Imports: []string{"github.com/hashicorp/terraform-plugin-framework/types", "github.com/hashicorp/terraform-plugin-framework/attr"}, + } +} + +func getNestedModel(attribute *codespec.Attribute) *CodeStatement { + var nested *codespec.NestedAttributeObject + if attribute.ListNested != nil { + nested = &attribute.ListNested.NestedObject + } + if attribute.SingleNested != nil { + nested = &attribute.SingleNested.NestedObject + } + if attribute.MapNested != nil { + nested = &attribute.MapNested.NestedObject + } + if attribute.SetNested != nil { + nested = &attribute.SetNested.NestedObject + } + if nested == nil { + return nil + } + res := generateTypedModels(nested.Attributes, attribute.Name.PascalCase(), true) + return &res +} + +func generateStructOfTypedModel(attributes codespec.Attributes, name string) CodeStatement { + structProperties := []string{} + for i := range attributes { + structProperties = append(structProperties, typedModelProperty(&attributes[i])) + } + structPropsCode := strings.Join(structProperties, "\n") + return CodeStatement{ + Code: fmt.Sprintf(`type TF%sModel struct { + %s + }`, name, structPropsCode), + Imports: []string{"github.com/hashicorp/terraform-plugin-framework/types"}, + } +} + +func typedModelProperty(attr *codespec.Attribute) string { + namePascalCase := attr.Name.PascalCase() + propType := attrModelType(attr) + return fmt.Sprintf("%s %s", namePascalCase, propType) + " `" + fmt.Sprintf("tfsdk:%q", attr.Name.SnakeCase()) + "`" +} + +func attrModelType(attr *codespec.Attribute) string { + switch { + case attr.Float64 != nil: + return "types.Float64" + case attr.Bool != nil: + return "types.Bool" + case attr.String != nil: + return "types.String" + case attr.Number != nil: + return "types.Number" + case attr.Int64 != nil: + return "types.Int64" + case attr.Timeouts != nil: + return "timeouts.Value" + // For nested attributes the generic type is used, this is to ensure the model can store all possible values. e.g. nested computed only attributes need to be defined with TPF types to avoid error when getting the config. + case attr.List != nil || attr.ListNested != nil: + return "types.List" + case attr.Set != nil || attr.SetNested != nil: + return "types.Set" + case attr.Map != nil || attr.MapNested != nil: + return "types.Map" + case attr.SingleNested != nil: + return "types.Object" + default: + panic("Attribute with unknown type defined when generating typed model") + } +}