Unverified Commit 6ced5f30 authored by Fernando Takagi's avatar Fernando Takagi Committed by GitHub
Browse files

Create angular .ts client code generator from openapi/swagger.json file (#783)

Based on https://github.com/heroiclabs/nakama-js/tree/master/openapi-gen:

adjusted to angular projects, mirroring the previous protoc generator template
added fixes and new schema elements
generates missing interfaces from POST+PUT requests that use path and body params (as <function_name+"Request">)

Differences to previous generated .ts client:

already url encodes all non-path parameters
accepts prefixes to remove as a command line flag, because swagger adds the name of imports/the file itself as prefixes to types and functions
map types are generated as Map<>, not as type arrays
array types are generated as Array<>, not as Type[]
swagger does not reference the enum type on function arguments, so enums need to be passed as strings
parent 80662679
Loading
Loading
Loading
Loading
+1 −1
Original line number Diff line number Diff line
@@ -15,4 +15,4 @@
package console

//go:generate protoc -I. -I../vendor -I../vendor/github.com/heroiclabs/nakama-common -I../build/grpc-gateway-v2.3.0/third_party/googleapis -I../vendor/github.com/grpc-ecosystem/grpc-gateway/v2 --go_out=. --go_opt=paths=source_relative --go-grpc_out=. --go-grpc_opt=paths=source_relative --grpc-gateway_out=. --grpc-gateway_opt=paths=source_relative --grpc-gateway_opt=logtostderr=true --grpc-gateway_opt=generate_unbound_methods=true --openapiv2_out=. --openapiv2_opt=logtostderr=true console.proto
//go:generate sh -c "(cd protoc-gen-angular && go build && go install) && protoc -I. -I../vendor -I../vendor/github.com/heroiclabs/nakama-common -I../build/grpc-gateway-v2.3.0/third_party/googleapis -I../vendor/github.com/grpc-ecosystem/grpc-gateway/v2 --angular_out=filename=console.service.ts,service_name=ConsoleService:. console.proto && mv console.service.ts ui/src/app"
//go:generate sh -c "(cd openapi-gen-angular && go run . -i '../console.swagger.json' -o '../ui/src/app/console.service.ts' -rm_prefix='console,nakamaconsole,nakama,Console_')"
+1 −1
Original line number Diff line number Diff line
@@ -18,7 +18,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// 	protoc-gen-go v1.27.1
// 	protoc        v3.19.1
// 	protoc        v3.19.4
// source: console.proto

package console
+23 −0
Original line number Diff line number Diff line
Angular service code gen
=======

#### An utility tool to generate an angular REST client from the swagger/openapi spec service definitions generated by the protoc toolchain.

### Usage

#### Options

* `input`: The pathname for the swagger input.
* `output`: The pathname of the generated TypeScript service class.
* `rm_prefix`: Optional list of prefixes, delimited by `,`, to remove from types and function names.
#### Generate the Angular service
##### Example
```shell
go run . -i '../console.swagger.json' -o '../ui/src/app/console.service.ts' -rm_prefix='console,nakamaconsole,nakama,Console_'
```

The output file is: `console.service.ts`.

### Limitations

The code generator has __only__ been checked against a limited set of grpc-gateway service definitions YMMV.
+523 −0
Original line number Diff line number Diff line
// Copyright 2021 The Nakama Authors
//
// 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.

package main

import (
	"bufio"
	"encoding/json"
	"flag"
	"fmt"
	"io/ioutil"
	"os"
	"regexp"
	"strings"
	"text/template"
)

const codeTemplate string = `// tslint:disable
/* Code generated by openapi-gen-angular/main.go. DO NOT EDIT. */

import { Injectable, Optional } from '@angular/core';
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';

{{- range $classname, $definition := .Definitions}}
    {{- if isRefToEnum $classname }}
{{ $enumHeader := enumSummary $definition -}}
{{ if exists $enumHeader }}
/** {{ $enumHeader }} */
{{- else }}

{{- end}}
export enum {{ $classname | title }} {
    {{- range $idx, $enum := $definition.Enum }}
  {{ $enum }} = {{ $idx }},
    {{- end }}
}
    {{- else }}
{{ if exists $definition.Description }}
/** {{$definition.Description}} */
{{- else }}

{{- end}}
export interface {{$classname | title}} {
				{{- range $key, $property := $definition.Properties}}
  {{- $fieldname := camelToSnake $key }}
	{{- if exists $property.Description }}
  // {{ $property.Description | removeNewline }}
	{{- else if exists $property.Title }}
  // {{ $property.Title | removeNewline }}
	{{- end }}
	{{$fieldname}}?: {{- $property | convertType -}}
				{{- end}}
}
    {{- end}}
{{- end }}

const DEFAULT_HOST = 'http://127.0.0.1:7120';
const DEFAULT_TIMEOUT_MS = 5000;

export class ConfigParams {
  host: string
  timeoutMs: number
}

@Injectable({providedIn: 'root'})
export class {{(index .Tags 0).Name}}Service {
	private readonly config;

  constructor(private httpClient: HttpClient, @Optional() config: ConfigParams) {
    const defaultConfig: ConfigParams = {
      host: DEFAULT_HOST,
      timeoutMs: DEFAULT_TIMEOUT_MS,
    };
    this.config = config || defaultConfig;
  }

{{- range $url, $path := .Paths}}
  {{- range $method, $operation := $path}}

  /** {{$operation.Summary}} */
	{{- $authFunction := "" }}
  {{ $operation.OperationId | snakeToCamel }}(
	{{- $hasSecurity := true -}}
	{{- if $operation.Security }}
    {{- with (index $operation.Security 0) }}
        {{- range $key, $value := . }}
          {{- if eq $key "BasicAuth" -}}
    		username: string, password: string
				{{- $authFunction = "getBasicAuthHeaders(username, password)" -}}
          {{- else if eq $key "HttpKeyAuth" -}}
    		auth_token: string
				{{- $authFunction = "getTokenAuthHeaders(auth_token)" -}}
					{{- else -}}
				{{- $hasSecurity = false -}}
          {{- end }}
        {{- end }}
    {{- end }}
  {{- else -}}
    auth_token: string
		{{- $authFunction = "getTokenAuthHeaders(auth_token)" -}}
  {{- end }}
	{{- $body := false -}}
  {{- range $index, $parameter := $operation.Parameters}}
		{{- if and (eq $index 0) (eq $hasSecurity true) -}}{{- ", " -}}{{- else if ne $index 0 -}}{{- ", " -}}{{- end -}}
    {{- $parameter.Name | snakeToCamel }}{{- if not $parameter.Required }}?{{- end -}}{{": "}}
          {{- if eq $parameter.In "path" -}}
    {{ $parameter.Type }}
          {{- else if eq $parameter.In "body" -}}
				{{- $body = true -}}
        {{- if eq $parameter.Schema.Type "string" -}}
    {{ $parameter.Schema.Type }}
				{{- else if eq $parameter.Schema.Type "object" -}}
    {
					{{- $i := 0 -}}
					  {{- range $bodyField, $bodyProp := $parameter.Schema.Properties}}
					{{- if ne $i 0 -}}{{- ", " -}}{{ end -}}
					{{- $bodyField | camelToSnake -}}{{"?: "}}{{- $bodyProp | convertType -}}
				  {{- $i = inc $i -}}
					  {{- end -}}
		}
        {{- else -}}
    {{ $parameter.Schema.Ref | cleanRef }}
        {{- end }}
          {{- else if eq $parameter.Type "array" -}}
    Array<{{$parameter.Items.Type}}>
          {{- else if eq $parameter.Type "object" -}}
    Map<{{$parameter.AdditionalProperties.Type}}>
          {{- else if eq $parameter.Type "integer" -}}
    number
          {{- else -}}
    {{ $parameter.Type }}
        {{- end -}}
  {{- end -}}
      ): Observable<{{- if $operation.Responses.Ok.Schema.Ref -}} {{- $operation.Responses.Ok.Schema.Ref | cleanRef -}} {{- else -}} any {{- end}}> {

		    {{- range $parameter := $operation.Parameters}}
    {{- $snakeToCamel := $parameter.Name | snakeToCamel}}
      {{- if eq $parameter.In "path"}}
		{{ $parameter.Name }} = encodeURIComponent(String({{- $snakeToCamel}}))
      {{- end}}
        {{- end}}
		const urlPath = {{ $url | convertPathToJs -}};
    let params = new HttpParams();
      {{- range $argument := $operation.Parameters -}}
			  {{if eq $argument.In "query"}}
    if ({{$argument.Name}}) {
      {{if eq $argument.Type "array" -}}
      {{$argument.Name}}.forEach(e => params = params.append('{{$argument.Name}}', String(e)))
      {{- else -}}
      params = params.set('{{$argument.Name}}', {{if eq $argument.Type "string" -}} {{$argument.Name}}{{else}}String({{$argument.Name}}){{- end}});
      {{- end}}
    }{{ end }}
	{{- end }}
    return this.httpClient.{{ $method }}{{- if $operation.Responses.Ok.Schema.Ref }}<{{ $operation.Responses.Ok.Schema.Ref | cleanRef }}>{{- end}}(this.config.host + urlPath{{- if eq $body true}}, body{{- end}}, { params: params{{- if ne $authFunction "" }}, headers: this.{{$authFunction}}{{- end}} })
  }
  {{- end}}
{{- end}}

  private getTokenAuthHeaders(token: string): HttpHeaders {
    return new HttpHeaders().set('Authorization', 'Bearer ' + token);
  }

  private getBasicAuthHeaders(username: string, password: string): HttpHeaders {
    return new HttpHeaders().set('Authorization', 'Basic ' + btoa(username + ':' + password));
  }
}
`

func snakeToCamel(input string) (snakeToCamel string) {
	isToUpper := false
	for k, v := range input {
		if k == 0 {
			snakeToCamel = strings.ToLower(string(input[0]))
		} else {
			if isToUpper {
				snakeToCamel += strings.ToUpper(string(v))
				isToUpper = false
			} else {
				if v == '_' {
					isToUpper = true
				} else {
					snakeToCamel += string(v)
				}
			}
		}
	}
	return
}

func enumSummary(def Definition) string {
	// quirk of swagger generation: if enum doesn't have a title
	// then the title can be found as the first entry in the split description.
	if def.Title != "" {
		return def.Title
	}

	split := strings.Split(def.Description, "\n")

	if len(split) <= 0 {
		panic("No newlines in enum description found.")
	}

	return split[0]
}

func enumDescriptions(def Definition) (output []string) {
	return def.Enum
}

func convertRefToClassName(input string) (className string) {
	cleanRef := strings.TrimPrefix(input, "#/definitions/")
	className = strings.Title(cleanRef)
	return
}

func iscamelToSnake(input string) (output bool) {
	output = true
	for _, v := range input {
		vString := string(v)
		if vString != "_" && strings.ToUpper(vString) == vString {
			output = false
		}
	}

	return
}

// camelToPascal converts a string from camel case to Pascal case.
func camelToPascal(camelCase string) (pascalCase string) {

	if len(camelCase) <= 0 {
		return ""
	}

	pascalCase = strings.ToUpper(string(camelCase[0])) + camelCase[1:]
	return
}

func camelToSnake(input string) (output string) {
	output = ""
	if iscamelToSnake(input) {
		output = input
		return
	}
	for _, v := range input {
		vString := string(v)
		if vString == strings.ToUpper(vString) {
			output += "_" + strings.ToLower(vString)
		} else {
			output += vString
		}
	}
	return
}

// pascalToCamel converts a Pascal case string to a camel case string.
func pascalToCamel(input string) (camelCase string) {
	if input == "" {
		return ""
	}

	camelCase = strings.ToLower(string(input[0]))
	camelCase += string(input[1:])
	return camelCase
}

func main() {
	// Argument flags
	var input = flag.String("i", "", "The input openapi file path.")
	var output = flag.String("o", "", "The output path for generated code.")
	var rm_prefix = flag.String("rm_prefix", "", "The prefixes to remove from names.")
	flag.Parse()

	if *input == "" {
		fmt.Printf("No input file found: %s\n", *input)
		flag.PrintDefaults()
		return
	}

	prefixesToRemove := strings.Split(*rm_prefix, ",")

	var schema = &Swagger{}

	fmap := template.FuncMap{
		"enumDescriptions": enumDescriptions,
		"enumSummary":      enumSummary,
		"snakeToCamel":     snakeToCamel,
		"cleanRef":         convertRefToClassName,
		"isRefToEnum": func(ref string) bool {
			// swagger schema definition keys have inconsistent casing
			var camelOk bool
			var pascalOk bool
			var enums []string

			asCamel := pascalToCamel(ref)
			if _, camelOk = schema.Definitions[asCamel]; camelOk {
				enums = schema.Definitions[asCamel].Enum
			}

			asPascal := camelToPascal(ref)
			if _, pascalOk = schema.Definitions[asPascal]; pascalOk {
				enums = schema.Definitions[asPascal].Enum
			}

			if !pascalOk && !camelOk {
				fmt.Printf("no definition found: %v", ref)
				return false
			}

			return len(enums) > 0
		},
		"title":                strings.Title,
		"camelToSnake":         camelToSnake,
		"uppercase":            strings.ToUpper,
		"convertType": 					convertType,
		"convertPathToJs":			convertPathToJs,
		"inc": 									func(i int) int { return i + 1 },
		"removeNewline":				func(s string) string { return strings.Replace(s, "\n", " / ", -1) },
		"exists": 							func(s string) bool { return s != "" },
	}

	content, err := ioutil.ReadFile(*input)
	if err != nil {
		fmt.Printf("Unable to read file: %s\n", err)
		return
	}

	if err := json.Unmarshal(content, &schema); err != nil {
		fmt.Printf("Unable to decode input %s : %s\n", input, err)
		return
	}

	interfacesToRemove := []string{"googlerpcStatus", "protobufAny"}
	adjustSchemaData(schema, prefixesToRemove, interfacesToRemove)
	createBodyTypes(schema)

	tmpl, err := template.New(*input).Funcs(fmap).Parse(codeTemplate)
	if err != nil {
		fmt.Printf("Template parse error: %s\n", err)
		return
	}

	if len(*output) < 1 {
		err := tmpl.Execute(os.Stdout, &schema)
		if err != nil {
			fmt.Printf("Template execute error: %s\n", err)
			return
		}
		return
	}

	f, err := os.Create(*output)
	if err != nil {
		fmt.Printf("Unable to create file %s", err)
		return
	}
	defer f.Close()

	writer := bufio.NewWriter(f)
	tmpl.Execute(writer, schema)
	writer.Flush()
}

func convertType(prop Property) (tsType string) {
	switch prop.Type {
	case "string":
		return "string"
	case "integer":
		fallthrough
	case "number":
		return "number"
	case "boolean":
		return "boolean"
	case "array":
		switch prop.Items.Type {
		case "string":
			return "Array<string>"
		case "integer":
			fallthrough
		case "number":
			return "Array<number>"
		case "boolean":
			return "Array<boolean>"
		default:
			return "Array<"+convertRefToClassName(prop.Items.Ref)+">"
		}
	case "object":
		switch prop.AdditionalProperties.Type {
		case "string":
			return "Map<string, string>"
		case "integer":
			fallthrough
		case "number":
			return "Map<string, number>"
		case "boolean":
			return "Map<string, boolean>"
		default:
			return "Map<string, "+ convertRefToClassName(prop.AdditionalProperties.Type) +">"
		}
	default:
		return convertRefToClassName(prop.Ref)
	}
}

// Converts a path with params to a JS interpolated string
// E.g.: "/v1/builder/{name}/user/{user_id}" becomes `/v1/builder/${name}/user/${user_id}`
func convertPathToJs(path string) string {
	// Regex to identify variables within brackets e.g.: {foo}/baz/{bar}
	findBracketVarsReg := regexp.MustCompile("{(.+?)}")
	matches := findBracketVarsReg.FindAllStringSubmatch(path, -1)
	jsPath := fmt.Sprintf("`%s`", path)
	if len(matches) > 0 {
		for _, m := range matches {
			jsPath = strings.Replace(jsPath, m[0], fmt.Sprintf("$%s", m[0]), 1)
		}
	}
	return jsPath
}

func createBodyTypes(schema *Swagger) {
	//replace fields with new type
	for _, path := range schema.Paths {
		for _, operation := range path {
			for _, param := range operation.Parameters {
				if param.In == "body" && param.Schema.Type == "object" {
					newType := operation.OperationId + "Request"
					//copy it to main Definitions
					schema.Definitions[newType] = &Definition{
						Properties: param.Schema.Properties,
					}
					//replace it with new reference
					param.Schema = Schema {
						Ref: newType,
					}
				}
			}
		}
	}
}

func adjustSchemaData(schema *Swagger,  prefixesToRemove []string, interfacesToRemove []string) {
	adjustProps := func(props map[string]*Property) {
		for _, prop := range props {
			// check field array ref type
			for _, prefix := range prefixesToRemove {
				p := "#/definitions/"+prefix
				if strings.HasPrefix(prop.Items.Ref, p) {
					prop.Items.Ref = strings.TrimPrefix(prop.Items.Ref, p)
					break
				}
			}
			// check field ref type
			for _, prefix := range prefixesToRemove {
				p := "#/definitions/"+prefix
				if strings.HasPrefix(prop.Ref, p) {
					prop.Ref = strings.TrimPrefix(prop.Ref, p)
					break
				}
			}
		}
	}

	for name, def := range schema.Definitions {
		adjustProps(def.Properties)
		for _, prefix := range prefixesToRemove {
			// check interface/enum name
			if strings.HasPrefix(name, prefix) {
				delete(schema.Definitions, name)
				schema.Definitions[strings.TrimPrefix(name, prefix)] = def
				break
			}
		}
		for _, i := range interfacesToRemove {
			if name == i {
				delete(schema.Definitions, name)
				break
			}
		}
	}
	for _, path := range schema.Paths {
		for _, operation := range path {
			// check function names
			for _, prefix := range prefixesToRemove {
				if strings.HasPrefix(operation.OperationId, prefix) {
					operation.OperationId = strings.TrimPrefix(operation.OperationId, prefix)
					break
				}
			}
			// check function return types
			for _, prefix := range prefixesToRemove {
				p := "#/definitions/"+prefix
				if strings.HasPrefix(operation.Responses.Ok.Schema.Ref, p) {
					operation.Responses.Ok.Schema.Ref = strings.TrimPrefix(operation.Responses.Ok.Schema.Ref, p)
					break
				}
			}
			for _, prefix := range prefixesToRemove {
				p := "#/definitions/"+prefix
				for _, param := range operation.Parameters {
					// check properties on body
					adjustProps(param.Schema.Properties)
					// check $ref on body
					if strings.HasPrefix(param.Schema.Ref, p) {
						param.Schema.Ref = strings.TrimPrefix(param.Schema.Ref, p)
						break
					}
				}
			}
		}
	}
}
+62 −0
Original line number Diff line number Diff line
package main

type Swagger struct {
	Paths map[string]map[string]*struct {
		Summary     string
		OperationId string
		Responses   struct {
			Ok struct {
				Schema struct {
					Ref string `json:"$ref"`
				}
			} `json:"200"`
		}
		Parameters []*struct {
			Name     string
			In       string // path, query or body
			Required bool
			Type     string   // used with primitives
			Items    struct { // used with type "array"
				Type 	 string
			}
			Schema   Schema // used with http body
		}
		Security []map[string][]struct{}
	}
	Tags []struct {
		Name string
	}
	Definitions map[string]*Definition
}

// Schema is the parameters body schema
type Schema struct {
	Type string
	Ref  string `json:"$ref"`
	Properties map[string]*Property
}

// Definition is the schema for interfaces and enums
type Definition struct {
	Properties  map[string]*Property
	Enum        []string
	Description string
	// used only by enums
	Title string
}

// Property of the field
type Property struct {
	Type 				string
	Ref   			string   `json:"$ref"` // used with object
	Items struct { // used with type "array"
		Type string
		Ref  string `json:"$ref"`
	}
	AdditionalProperties struct { // used for dictionaries with string keys (Property.Type=object)
		Type 			string
	}
	Description string
	Title 			string
	Format      string // used with type "boolean"
}
Loading