Files
masonry/vue-gen/vue-gen.go

619 lines
18 KiB
Go

package vue_gen
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"text/template"
)
// ----- Structures for swagger file parsing -----
//
// Note: In order to support list response scanning we add a Responses field
// to OperationObject and a new ResponseObject type.
type Swagger struct {
Definitions map[string]Definition `json:"definitions"`
Paths map[string]map[string]OperationObject `json:"paths"`
}
type Definition struct {
Type string `json:"type"`
Properties map[string]Property `json:"properties"`
Required []string `json:"required"`
}
type Property struct {
Type string `json:"type,omitempty"`
Format string `json:"format,omitempty"`
Description string `json:"description,omitempty"`
Minimum *float64 `json:"minimum,omitempty"`
Maximum *float64 `json:"maximum,omitempty"`
Enum []string `json:"enum,omitempty"`
Ref string `json:"$ref,omitempty"`
Items *Property `json:"items,omitempty"`
Properties map[string]Property `json:"properties,omitempty"` // For inline definitions.
}
type OperationObject struct {
OperationID string `json:"operationId"`
Parameters []Parameter `json:"parameters,omitempty"`
Responses map[string]ResponseObject `json:"responses,omitempty"`
}
type Parameter struct {
In string `json:"in"`
Name string `json:"name"`
Schema *SchemaObject `json:"schema,omitempty"`
}
type SchemaObject struct {
Ref string `json:"$ref,omitempty"`
}
type ResponseObject struct {
Schema *SchemaObject `json:"schema,omitempty"`
}
// OperationInfo is used when generating a form component.
type OperationInfo struct {
ServiceName string // e.g., "PeachService"
MethodName string // e.g., "peachListProducts"
HTTPMethod string // e.g., "post", "put", etc.
}
// ----- Structures for Vue form generation -----
type FormInput struct {
ID string // id in the Vue interface
Label string // label shown to the user
Type string // e.g., text, number, select, checkbox, etc.
DefaultValue string // optional default value (as string)
Placeholder string // optional placeholder
Required bool // required flag
Min string // string representation of minimum
Max string // string representation of maximum
Options []Option // for select fields
}
type Option struct {
Label string // option label
Value string // option value
}
type VueComponentData struct {
ComponentName string
DefaultFormTitle string
SubmitLabel string
FormInputs []FormInput
OperationFound bool
ServiceName string // e.g., "PeachService"
ServiceMethod string // e.g., "peachListProducts"
OperationHTTPMethod string // e.g., "post", "put", etc.
}
// ----- Structures for Vue Table generation -----
// TableColumn type maps to your supported TS interface.
type VueTableColumn struct {
ID string
Label string
Type string // e.g., "text", "email", "number", etc.
}
type VueTableData struct {
ComponentName string
DefaultTableTitle string
OperationFound bool
ServiceName string // e.g., "PeachService"
ServiceMethod string // e.g., "peachListProducts"
TableColumns []VueTableColumn
}
// ----- Main Generation Function -----
// GenVueFromSwagger reads the swagger spec and generates:
// - Form components for definitions used as request bodies,
// - Table components for list operations (matching the pattern Service_ListXYZ).
//
// For table components the generator dynamically creates the column definitions based on
// the response definition that is expected to have a "results" property referencing
// another definition.
func GenVueFromSwagger(swaggerPath string, outDir string) error {
// Read the swagger JSON file.
data, err := os.ReadFile(swaggerPath)
if err != nil {
return fmt.Errorf("error reading swagger file: %v", err)
}
// Unmarshal the swagger file.
var swagger Swagger
if err := json.Unmarshal(data, &swagger); err != nil {
return fmt.Errorf("Error parsing swagger JSON: %v\n", err)
}
// Build a mapping from definition name (used in a body parameter) to OperationInfo.
operationMap := buildOperationMap(swagger.Paths)
// Ensure output directory exists.
if err := os.MkdirAll(outDir, os.ModePerm); err != nil {
return fmt.Errorf("Error creating output directory: %v\n", err)
}
// ----- Generate Form Components for Request Payloads -----
for defName, def := range swagger.Definitions {
opInfo, ok := operationMap[defName]
if !ok {
continue
}
componentName := defName + "Form"
componentData := VueComponentData{
ComponentName: componentName,
DefaultFormTitle: defName,
SubmitLabel: "Submit",
FormInputs: []FormInput{},
OperationFound: true,
ServiceName: opInfo.ServiceName,
ServiceMethod: opInfo.MethodName,
OperationHTTPMethod: opInfo.HTTPMethod,
}
schemaForForm := getFormSchema(def, swagger.Definitions)
for propName, prop := range schemaForForm {
// Skip "id" for create requests (assuming POST).
if strings.ToLower(propName) == "id" && strings.ToLower(componentData.OperationHTTPMethod) == "post" {
continue
}
input := FormInput{
ID: propName,
Label: labelFromProperty(prop, propName),
Placeholder: "",
Required: isRequired(propName, def.Required),
}
if prop.Ref != "" {
refParts := strings.Split(prop.Ref, "/")
refName := refParts[len(refParts)-1]
input.Type = "text"
input.Placeholder = "Reference: " + refName
} else if len(prop.Enum) > 0 {
input.Type = "select"
for _, enumVal := range prop.Enum {
input.Options = append(input.Options, Option{
Label: enumVal,
Value: enumVal,
})
}
} else if prop.Type == "string" {
input.Type = "text"
if prop.Format == "date" {
input.Type = "date"
} else if prop.Format == "email" {
input.Type = "email"
}
} else if prop.Type == "number" || prop.Type == "integer" {
input.Type = "number"
if prop.Minimum != nil {
input.Min = strconv.FormatFloat(*prop.Minimum, 'f', -1, 64)
}
if prop.Maximum != nil {
input.Max = strconv.FormatFloat(*prop.Maximum, 'f', -1, 64)
}
} else if prop.Type == "boolean" {
input.Type = "checkbox"
} else if prop.Type == "array" && prop.Items != nil {
if len(prop.Items.Enum) > 0 {
input.Type = "select"
for _, enumVal := range prop.Items.Enum {
input.Options = append(input.Options, Option{
Label: enumVal,
Value: enumVal,
})
}
} else {
input.Type = "text"
input.Placeholder = "Comma separated values"
}
} else {
input.Type = "text"
}
componentData.FormInputs = append(componentData.FormInputs, input)
}
if err := renderVueComponent(componentData, outDir); err != nil {
return fmt.Errorf("Error generating component for %s: %v\n", defName, err)
} else {
fmt.Printf("Successfully generated request form component for %s\n", componentName)
}
}
// ----- Generate Table Components for List Operations -----
listOpsGenerated := make(map[string]bool)
for _, methods := range swagger.Paths {
for _, op := range methods {
if op.OperationID == "" {
continue
}
// Check for operations using the _List pattern.
if strings.Contains(op.OperationID, "_List") {
if listOpsGenerated[op.OperationID] {
continue
}
listOpsGenerated[op.OperationID] = true
parts := strings.Split(op.OperationID, "_")
if len(parts) != 2 || !strings.HasPrefix(parts[1], "List") {
continue
}
serviceName := parts[0] + "Service"
serviceMethod := lowerFirst(strings.ReplaceAll(op.OperationID, "_", ""))
resourceName := parts[1][len("List"):] // e.g., "Products"
tableTitle := resourceName + " List"
componentName := op.OperationID + "Table"
// Instead of using the resourceName or definition name, we build table columns by
// looking at the response. We expect the response to have a "results" property that
// either directly references or is an array of a definition.
tableColumns := getResponseTableColumns(op, swagger.Definitions)
tableData := VueTableData{
ComponentName: componentName,
DefaultTableTitle: tableTitle,
OperationFound: true,
ServiceName: serviceName,
ServiceMethod: serviceMethod,
TableColumns: tableColumns,
}
if err := renderVueTableComponent(tableData, outDir); err != nil {
return fmt.Errorf("Error generating table component for %s: %v\n", op.OperationID, err)
} else {
fmt.Printf("Successfully generated table component for %s\n", componentName)
}
}
}
}
return nil
}
// getFormSchema checks whether a "payload" property exists.
// If so, it will return its nested properties; otherwise, the top-level properties.
func getFormSchema(def Definition, allDefs map[string]Definition) map[string]Property {
if payloadProp, exists := def.Properties["payload"]; exists {
if len(payloadProp.Properties) > 0 {
return payloadProp.Properties
} else if payloadProp.Ref != "" {
refParts := strings.Split(payloadProp.Ref, "/")
refName := refParts[len(refParts)-1]
if refDef, ok := allDefs[refName]; ok {
return refDef.Properties
}
}
}
return def.Properties
}
// getResponseTableColumns builds the table columns based on the response schema for a list operation.
// It looks for the 200 response, then expects its schema to reference a definition that has a "results" property.
// That property should either directly reference another definition (or, if an array, have items referencing one).
func getResponseTableColumns(op OperationObject, allDefs map[string]Definition) []VueTableColumn {
resp, ok := op.Responses["200"]
if !ok || resp.Schema == nil {
return defaultColumns()
}
// Get the definition of the response.
respDef := getDefinitionFromSchema(resp.Schema, allDefs)
if respDef == nil {
return defaultColumns()
}
resultsProp, exists := respDef.Properties["results"]
if !exists {
return defaultColumns()
}
var targetDef *Definition
// Either the results property has a direct reference…
if resultsProp.Ref != "" {
if def, ok := allDefs[getRefName(resultsProp.Ref)]; ok {
targetDef = &def
}
// … or it is an array whose items reference a definition.
} else if resultsProp.Type == "array" && resultsProp.Items != nil && resultsProp.Items.Ref != "" {
if def, ok := allDefs[getRefName(resultsProp.Items.Ref)]; ok {
targetDef = &def
}
}
if targetDef == nil {
return defaultColumns()
}
// Build table columns from the target definition's properties.
var cols []VueTableColumn
for propName, prop := range targetDef.Properties {
colType := "text"
if prop.Type == "number" || prop.Type == "integer" {
colType = "number"
} else if prop.Type == "boolean" {
colType = "checkbox"
} else if prop.Type == "string" {
colType = "text"
if prop.Format == "date" {
colType = "date"
} else if prop.Format == "email" {
colType = "email"
}
}
cols = append(cols, VueTableColumn{
ID: propName,
Label: strings.Title(propName),
Type: colType,
})
}
if len(cols) == 0 {
return defaultColumns()
}
return cols
}
func defaultColumns() []VueTableColumn {
return []VueTableColumn{
{ID: "id", Label: "ID", Type: "text"},
}
}
// getDefinitionFromSchema retrieves a definition given a SchemaObject with a $ref.
func getDefinitionFromSchema(schema *SchemaObject, allDefs map[string]Definition) *Definition {
if schema == nil || schema.Ref == "" {
return nil
}
refName := getRefName(schema.Ref)
if def, ok := allDefs[refName]; ok {
return &def
}
return nil
}
// getRefName extracts the definition name from a reference, e.g., "#/definitions/PeachProduct" -> "PeachProduct".
func getRefName(ref string) string {
parts := strings.Split(ref, "/")
return parts[len(parts)-1]
}
// buildOperationMap scans swagger paths and returns a mapping from the definition referenced in a body parameter
// to an OperationInfo.
func buildOperationMap(paths map[string]map[string]OperationObject) map[string]OperationInfo {
opMap := make(map[string]OperationInfo)
for _, methods := range paths {
for httpMethod, op := range methods {
for _, param := range op.Parameters {
if strings.ToLower(param.In) == "body" && param.Schema != nil && param.Schema.Ref != "" {
refParts := strings.Split(param.Schema.Ref, "/")
defName := refParts[len(refParts)-1]
if op.OperationID != "" {
parts := strings.Split(op.OperationID, "_")
serviceName := ""
methodName := ""
if len(parts) >= 2 {
serviceName = parts[0] + "Service"
noUnderscore := strings.ReplaceAll(op.OperationID, "_", "")
methodName = lowerFirst(noUnderscore)
}
if serviceName != "" && methodName != "" {
opMap[defName] = OperationInfo{
ServiceName: serviceName,
MethodName: methodName,
HTTPMethod: strings.ToLower(httpMethod),
}
}
}
}
}
}
}
return opMap
}
func lowerFirst(s string) string {
if len(s) == 0 {
return s
}
return strings.ToLower(s[:1]) + s[1:]
}
func labelFromProperty(prop Property, propName string) string {
if prop.Description != "" {
return prop.Description
}
return strings.ToUpper(propName[:1]) + propName[1:]
}
func isRequired(propName string, required []string) bool {
for _, r := range required {
if r == propName {
return true
}
}
return false
}
// ----- Rendering Templates -----
func renderVueComponent(data VueComponentData, outDir string) error {
const vueTemplate = `<template>
<DynamicForm
class="space-y-2 p-6 bg-white shadow-lg rounded-lg max-w-md"
:formInputs="updatedFormInputs"
:formTitle="formTitleComputed"
submit-label="{{ .SubmitLabel }}"
@send="handleSubmit"
/>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { DynamicForm } from '@masonitestudios/dynamic-vue';
{{- if .OperationFound }}
import { {{ .ServiceName }} } from '@/generated';
{{- end }}
const props = defineProps<{
formTitle?: string,
initialValues?: Record<string, any>
}>();
const emit = defineEmits(['send']);
const formTitleComputed = computed(() => props.formTitle || '{{ .DefaultFormTitle }}');
const formInputs = [
{{- range $index, $input := .FormInputs }}
{
id: '{{ $input.ID }}',
label: '{{ $input.Label }}',
type: '{{ $input.Type }}',
placeholder: '{{ $input.Placeholder }}',
required: {{ $input.Required }},
defaultValue: '{{ $input.DefaultValue }}',
{{- if $input.Min }} min: '{{ $input.Min }}',{{ end }}
{{- if $input.Max }} max: '{{ $input.Max }}',{{ end }}
{{- if $input.Options }}
options: [
{{- range $i, $opt := $input.Options }}
{ label: '{{ $opt.Label }}', value: '{{ $opt.Value }}' }{{ if not (eq (add1 $i) (len $input.Options)) }},{{ end }}
{{- end }}
],
{{- end }}
},
{{- end }}
];
const updatedFormInputs = computed(() => {
return formInputs.map(input => {
return {
...input,
defaultValue: (props.initialValues && props.initialValues[input.id]) ?? input.defaultValue ?? ''
}
});
});
function handleSubmit(payload: any) {
const requestData = { payload: payload };
{{- if .OperationFound }}
{{ .ServiceName }}.{{ .ServiceMethod }}(requestData)
.then(response => {
emit('send', requestData.payload);
})
.catch(error => {
console.error('Service error:', error);
});
{{- else }}
emit('send', requestData.payload);
{{- end }}
}
</script>
`
tmpl := template.New("vueComponent").Funcs(template.FuncMap{
"add1": func(i int) int { return i + 1 },
})
tmpl, err := tmpl.Parse(vueTemplate)
if err != nil {
return err
}
filename := filepath.Join(outDir, data.ComponentName+".vue")
outFile, err := os.Create(filename)
if err != nil {
return err
}
defer outFile.Close()
return tmpl.Execute(outFile, data)
}
func renderVueTableComponent(data VueTableData, outDir string) error {
const vueTableTemplate = `<template>
<DynamicTable
class="bg-white shadow-lg rounded-lg p-6"
:tableTitle="tableTitleComputed"
:tableColumns="tableColumns"
:tableData="tableData"
@row-click="handleRowClick"
/>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { DynamicTable } from '@masonitestudios/dynamic-vue';
import type { TableColumn } from '@masonitestudios/dynamic-vue';
{{- if .OperationFound }}
import { {{ .ServiceName }} } from '@/generated';
{{- end }}
const props = defineProps<{
tableTitle?: string,
}>();
const emit = defineEmits(['row-click']);
const tableColumns = [
{{- range $index, $col := .TableColumns }}
{ id: '{{ $col.ID }}', label: '{{ $col.Label }}', type: '{{ $col.Type }}' }{{ if not (eq (add1 $index) (len $.TableColumns)) }},{{ end }}
{{- end }}
];
const tableData = ref<any[]>([]);
const tableTitleComputed = computed(() => '{{ .DefaultTableTitle }}');
{{- if .OperationFound }}
onMounted(() => {
fetchData();
});
function fetchData() {
{{ .ServiceName }}.{{ .ServiceMethod }}().then((res: any) => {
if (res.results) {
tableData.value = res.results;
} else {
console.error('Error fetching data:', res);
}
}).catch(error => {
console.error('Service error:', error);
});
}
{{- end }}
defineExpose({fetchData});
function handleRowClick(row: any) {
emit('row-click', row);
}
</script>
`
tmpl := template.New("vueTableComponent").Funcs(template.FuncMap{
"add1": func(i int) int { return i + 1 },
})
tmpl, err := tmpl.Parse(vueTableTemplate)
if err != nil {
return err
}
filename := filepath.Join(outDir, data.ComponentName+".vue")
outFile, err := os.Create(filename)
if err != nil {
return err
}
defer outFile.Close()
return tmpl.Execute(outFile, data)
}