mmv1/openapi_generate/parser.go (355 lines of code) (raw):
// Copyright 2024 Google Inc.
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Code generator for a library converting terraform state to gcp objects.
package openapi_generate
import (
"context"
"encoding/base64"
"fmt"
"os"
"path"
"path/filepath"
"regexp"
"slices"
"strings"
"log"
"github.com/GoogleCloudPlatform/magic-modules/mmv1/api"
"github.com/GoogleCloudPlatform/magic-modules/mmv1/api/product"
r "github.com/GoogleCloudPlatform/magic-modules/mmv1/api/resource"
"github.com/GoogleCloudPlatform/magic-modules/mmv1/google"
"github.com/getkin/kin-openapi/openapi3"
"gopkg.in/yaml.v2"
)
type Parser struct {
Folder string
Output string
}
func NewOpenapiParser(folder, output string) Parser {
wd, err := os.Getwd()
if err != nil {
log.Fatalf(err.Error())
}
parser := Parser{
Folder: path.Join(wd, folder),
Output: path.Join(wd, output),
}
return parser
}
func (parser Parser) Run() {
f, err := os.Open(parser.Folder)
if err != nil {
log.Fatalf(err.Error())
return
}
defer f.Close()
files, err := f.Readdirnames(0)
if err != nil {
log.Fatalf(err.Error())
}
// check if folder is empty
if len(files) == 0 {
log.Fatalf("No OpenAPI files found in %s", parser.Folder)
}
for _, file := range files {
parser.WriteYaml(path.Join(parser.Folder, file))
}
}
func (parser Parser) WriteYaml(filePath string) {
log.Printf("Reading from file path %s", filePath)
ctx := context.Background()
loader := &openapi3.Loader{Context: ctx, IsExternalRefsAllowed: true}
doc, _ := loader.LoadFromFile(filePath)
_ = doc.Validate(ctx)
header, err := os.ReadFile("openapi_generate/header.txt")
if err != nil {
log.Fatalf("error reading header %v", err)
}
resourcePaths := findResources(doc)
productPath := buildProduct(filePath, parser.Output, doc, header)
// Disables line wrap for long strings
yaml.FutureLineWrap()
log.Printf("Generated product %+v/product.yaml", productPath)
for _, pathArray := range resourcePaths {
resource := buildResource(filePath, pathArray[0], pathArray[1], doc)
// marshal method
resourceOutPathMarshal := filepath.Join(productPath, fmt.Sprintf("%s.yaml", resource.Name))
bytes, err := yaml.Marshal(resource)
if err != nil {
log.Fatalf("error marshalling yaml %v: %v", resourceOutPathMarshal, err)
}
f, err := os.Create(resourceOutPathMarshal)
if err != nil {
log.Fatalf("error creating resource file %v", err)
}
_, err = f.Write(header)
if err != nil {
log.Fatalf("error writing resource file header %v", err)
}
_, err = f.Write(bytes)
if err != nil {
log.Fatalf("error writing resource file %v", err)
}
err = f.Close()
if err != nil {
log.Fatalf("error closing resource file %v", err)
}
log.Printf("Generated resource %s", resourceOutPathMarshal)
}
}
func findResources(doc *openapi3.T) [][]string {
var resourcePaths [][]string
pathMap := doc.Paths.Map()
for key, pathValue := range pathMap {
if pathValue.Post == nil {
continue
}
// Not very clever way of identifying create resource methods
if strings.HasPrefix(pathValue.Post.OperationID, "Create") {
resourcePath := key
resourceName := strings.Replace(pathValue.Post.OperationID, "Create", "", 1)
resourcePaths = append(resourcePaths, []string{resourcePath, resourceName})
}
}
return resourcePaths
}
func buildProduct(filePath, output string, root *openapi3.T, header []byte) string {
version := root.Info.Version
server := root.Servers[0].URL
productName := strings.Split(filepath.Base(filePath), "_")[0]
productPath := filepath.Join(output, productName)
if err := os.MkdirAll(productPath, os.ModePerm); err != nil {
log.Fatalf("error creating product output directory %v: %v", productPath, err)
}
apiProduct := &api.Product{}
apiVersion := &product.Version{}
apiVersion.BaseUrl = fmt.Sprintf("%s/%s/", server, version)
// TODO(slevenick) figure out how to tell the API version
apiVersion.Name = "ga"
apiProduct.Versions = []*product.Version{apiVersion}
// Standard titling is "Service Name API"
displayName := strings.Replace(root.Info.Title, " API", "", 1)
apiProduct.Name = strings.ReplaceAll(displayName, " ", "")
apiProduct.DisplayName = displayName
//Scopes should be added soon to OpenAPI, until then use global scope
apiProduct.Scopes = []string{"https://www.googleapis.com/auth/cloud-platform"}
productOutPathMarshal := filepath.Join(output, fmt.Sprintf("/%s/product.yaml", productName))
// Default yaml marshaller
bytes, err := yaml.Marshal(apiProduct)
if err != nil {
log.Fatalf("error marshalling yaml %v: %v", productOutPathMarshal, err)
}
f, err := os.Create(productOutPathMarshal)
if err != nil {
log.Fatalf("error creating product file %v", err)
}
_, err = f.Write(header)
if err != nil {
log.Fatalf("error writing product file header %v", err)
}
_, err = f.Write(bytes)
if err != nil {
log.Fatalf("error writing product file %v", err)
}
err = f.Close()
if err != nil {
log.Fatalf("error closing product file %v", err)
}
return productPath
}
func baseUrl(resourcePath string) string {
base := strings.ReplaceAll(resourcePath, "{", "{{")
base = strings.ReplaceAll(base, "}", "}}")
// Some APIs use projectsId and locationsId, but we have standardized on these
base = strings.ReplaceAll(base, "projectsId", "project")
base = strings.ReplaceAll(base, "locationsId", "location")
base = stripVersion(base)
r := regexp.MustCompile(`\{\{(\w+)\}\}`)
matches := r.FindStringSubmatch(base)
for i := 0; i < len(matches); i++ {
match := matches[i]
base = strings.ReplaceAll(base, match, google.Underscore(match))
}
return base
}
// OpenAPI paths are prefixed with the version of the API, which already exists
// in the product. Strip it out here
func stripVersion(path string) string {
pattern := `^(/.*v\d[^/]*/)`
re := regexp.MustCompile(pattern)
return re.ReplaceAllString(path, "")
}
func buildResource(filePath, resourcePath, resourceName string, root *openapi3.T) api.Resource {
resource := api.Resource{}
parsedObjects := parseOpenApi(resourcePath, resourceName, root)
parameters := parsedObjects[0].([]*api.Type)
properties := parsedObjects[1].([]*api.Type)
queryParam := parsedObjects[2].(string)
baseUrl := baseUrl(resourcePath)
selfLink := fmt.Sprintf("%s/{{%s}}", baseUrl, google.Underscore(queryParam))
resource.Name = resourceName
resource.BaseUrl = baseUrl
resource.Parameters = parameters
resource.Properties = properties
resource.SelfLink = selfLink
resource.IdFormat = selfLink
resource.ImportFormat = []string{selfLink}
resource.CreateUrl = fmt.Sprintf("%s?%s={{%s}}", baseUrl, queryParam, google.Underscore(queryParam))
resource.Description = "Description"
resource.AutogenAsync = true
async := api.NewAsync()
async.Operation.BaseUrl = "{{op_id}}"
async.Result.ResourceInsideResponse = true
resource.Async = async
if hasUpdate(resourceName, root) {
resource.UpdateVerb = "PATCH"
resource.UpdateMask = true
} else {
resource.Immutable = true
}
example := r.Examples{}
example.Name = "name_of_example_file"
example.PrimaryResourceId = "example"
example.Vars = map[string]string{"resource_name": "test-resource"}
resource.Examples = []r.Examples{example}
resourceNameBytes := []byte(resourceName)
// Write the status as an encoded string to flag when a YAML file has been
// copy and pasted without actually using this tool
resource.AutogenStatus = base64.StdEncoding.EncodeToString(resourceNameBytes)
return resource
}
func hasUpdate(resourceName string, root *openapi3.T) bool {
// Create and Update have different paths in the OpenAPI spec, so look
// through all paths to find one that matches the expected operation name
for _, pathValue := range root.Paths.Map() {
if pathValue.Patch == nil {
continue
}
if pathValue.Patch.OperationID == fmt.Sprintf("Update%s", resourceName) {
return true
}
}
return false
}
func parseOpenApi(resourcePath, resourceName string, root *openapi3.T) []any {
returnArray := []any{}
path := root.Paths.Find(resourcePath)
parameters := []*api.Type{}
var idParam string
for _, param := range path.Post.Parameters {
if strings.Contains(strings.ToLower(param.Value.Name), strings.ToLower(resourceName)) {
idParam = param.Value.Name
}
paramObj := writeObject(param.Value.Name, param.Value.Schema, propType(param.Value.Schema), true)
description := param.Value.Description
if strings.TrimSpace(description) == "" {
description = "No description"
}
paramObj.Description = trimDescription(description)
if param.Value.Name == "requestId" || param.Value.Name == "validateOnly" || paramObj.Name == "" {
continue
}
// All parameters are immutable
paramObj.Immutable = true
parameters = append(parameters, ¶mObj)
}
properties := buildProperties(path.Post.RequestBody.Value.Content["application/json"].Schema.Value.Properties, path.Post.RequestBody.Value.Content["application/json"].Schema.Value.Required)
returnArray = append(returnArray, parameters)
returnArray = append(returnArray, properties)
returnArray = append(returnArray, idParam)
return returnArray
}
func propType(prop *openapi3.SchemaRef) openapi3.Types {
if len(prop.Value.AllOf) > 0 {
return *prop.Value.AllOf[0].Value.Type
} else {
return *prop.Value.Type
}
}
func writeObject(name string, obj *openapi3.SchemaRef, objType openapi3.Types, urlParam bool) api.Type {
var field api.Type
switch name {
case "projectsId", "project":
// projectsId and project are omitted in MMv1 as they are inferred from
// the presence of {{project}} in the URL
return field
case "locationsId":
name = "location"
}
additionalDescription := ""
if len(obj.Value.AllOf) > 0 {
obj = obj.Value.AllOf[0]
objType = *obj.Value.Type
}
field.Name = name
switch objType[0] {
case "string":
field.Type = "String"
if len(obj.Value.Enum) > 0 {
var enums []string
for _, enum := range obj.Value.Enum {
enums = append(enums, fmt.Sprintf("%v", enum))
}
additionalDescription = fmt.Sprintf("\n Possible values:\n %s", strings.Join(enums, "\n"))
}
case "integer":
field.Type = "Integer"
case "number":
field.Type = "Double"
case "boolean":
field.Type = "Boolean"
case "object":
if field.Name == "labels" {
// Standard labels implementation
field.Type = "KeyValueLabels"
break
}
if obj.Value.AdditionalProperties.Schema != nil && obj.Value.AdditionalProperties.Schema.Value.Type.Is("string") {
// AdditionalProperties with type string is a string -> string map
field.Type = "KeyValuePairs"
break
}
field.Type = "NestedObject"
field.Properties = buildProperties(obj.Value.Properties, obj.Value.Required)
case "array":
field.Type = "Array"
var subField api.Type
typ := *obj.Value.Items.Value.Type
switch typ[0] {
case "string":
subField.Type = "String"
case "integer":
subField.Type = "Integer"
case "number":
subField.Type = "Double"
case "boolean":
subField.Type = "Boolean"
case "object":
subField.Type = "NestedObject"
subField.Properties = buildProperties(obj.Value.Items.Value.Properties, obj.Value.Items.Value.Required)
}
field.ItemType = &subField
default:
panic(fmt.Sprintf("Failed to identify field type for %s %s", field.Name, objType[0]))
}
description := fmt.Sprintf("%s %s", obj.Value.Description, additionalDescription)
if strings.TrimSpace(description) == "" {
description = "No description"
}
field.Description = trimDescription(description)
if urlParam {
field.UrlParamOnly = true
field.Required = true
}
// These methods are only available when the field is set
if obj.Value.ReadOnly {
field.Output = true
}
// x-google-identifier fields are described by AIP 203 and are represented
// as output only in Terraform.
xGoogleId, err := obj.JSONLookup("x-google-identifier")
if err == nil && xGoogleId != nil {
field.Output = true
}
xGoogleImmutable, err := obj.JSONLookup("x-google-immutable")
if err == nil && xGoogleImmutable != nil {
field.Immutable = true
}
return field
}
func buildProperties(props openapi3.Schemas, required []string) []*api.Type {
properties := []*api.Type{}
for k, prop := range props {
propObj := writeObject(k, prop, propType(prop), false)
if slices.Contains(required, k) {
propObj.Required = true
}
properties = append(properties, &propObj)
}
return properties
}
// Trims whitespace from the ends of lines in a description to force multiline
// formatting for strings with newlines present
// Also trim "Output only." and "Required." from descriptions as this gets duplicated
func trimDescription(description string) string {
description, _ = strings.CutPrefix(description, "Optional. ")
description, _ = strings.CutPrefix(description, "Output only. ")
description, _ = strings.CutPrefix(description, "Required. ")
description, _ = strings.CutPrefix(description, "Immutable. ")
lines := strings.Split(description, "\n")
var trimmedDescription []string
for _, line := range lines {
trimmedDescription = append(trimmedDescription, strings.Trim(line, " "))
}
return strings.Join(trimmedDescription, "\n")
}