go.ligato.io/vpp-agent/v3@v3.5.0/plugins/restapi/jsonschema/converter/converter.go (about)

     1  package converter
     2  
     3  import (
     4  	"encoding/json"
     5  	"fmt"
     6  	"io"
     7  	"path"
     8  	"regexp"
     9  	"strings"
    10  
    11  	"github.com/alecthomas/jsonschema"
    12  	"github.com/sirupsen/logrus"
    13  	"google.golang.org/protobuf/proto"
    14  	"google.golang.org/protobuf/types/descriptorpb"
    15  	"google.golang.org/protobuf/types/pluginpb"
    16  )
    17  
    18  const (
    19  	messageDelimiter = "+"
    20  )
    21  
    22  // Converter is everything you need to convert protos to JSONSchemas:
    23  type Converter struct {
    24  	AllFieldsRequired            bool
    25  	AllowNullValues              bool
    26  	DisallowAdditionalProperties bool
    27  	DisallowBigIntsAsStrings     bool
    28  	PrefixSchemaFilesWithPackage bool
    29  	UseJSONFieldnamesOnly        bool
    30  	UseProtoAndJSONFieldnames    bool
    31  	logger                       *logrus.Logger
    32  	sourceInfo                   *sourceCodeInfo
    33  	messageTargets               []string
    34  }
    35  
    36  // New returns a configured *Converter:
    37  func New(logger *logrus.Logger) *Converter {
    38  	return &Converter{
    39  		logger: logger,
    40  	}
    41  }
    42  
    43  // ConvertFrom tells the convert to work on the given input:
    44  func (c *Converter) ConvertFrom(rd io.Reader) (*pluginpb.CodeGeneratorResponse, error) {
    45  	c.logger.Debug("Reading code generation request")
    46  	input, err := io.ReadAll(rd)
    47  	if err != nil {
    48  		c.logger.WithError(err).Error("Failed to read request")
    49  		return nil, err
    50  	}
    51  
    52  	req := &pluginpb.CodeGeneratorRequest{}
    53  	err = proto.Unmarshal(input, req)
    54  	if err != nil {
    55  		c.logger.WithError(err).Error("Can't unmarshal input")
    56  		return nil, err
    57  	}
    58  
    59  	c.logger.Debug("Converting input")
    60  	return c.convert(req)
    61  }
    62  
    63  func (c *Converter) parseGeneratorParameters(parameters string) {
    64  	for _, parameter := range strings.Split(parameters, ",") {
    65  		switch parameter {
    66  		case "all_fields_required":
    67  			c.AllFieldsRequired = true
    68  		case "allow_null_values":
    69  			c.AllowNullValues = true
    70  		case "debug":
    71  			c.logger.SetLevel(logrus.DebugLevel)
    72  		case "disallow_additional_properties":
    73  			c.DisallowAdditionalProperties = true
    74  		case "disallow_bigints_as_strings":
    75  			c.DisallowBigIntsAsStrings = true
    76  		case "json_fieldnames":
    77  			c.UseJSONFieldnamesOnly = true
    78  		case "prefix_schema_files_with_package":
    79  			c.PrefixSchemaFilesWithPackage = true
    80  		case "proto_and_json_fieldnames":
    81  			c.UseProtoAndJSONFieldnames = true
    82  		}
    83  
    84  		// look for specific message targets
    85  		// message types are separated by messageDelimiter "+"
    86  		// examples:
    87  		// 		messages=[foo+bar]
    88  		// 		messages=[foo]
    89  		rx := regexp.MustCompile(`messages=\[([^\]]+)\]`)
    90  		if matches := rx.FindStringSubmatch(parameter); len(matches) == 2 {
    91  			c.messageTargets = strings.Split(matches[1], messageDelimiter)
    92  		}
    93  	}
    94  }
    95  
    96  // Converts a proto "ENUM" into a JSON-Schema:
    97  func (c *Converter) convertEnumType(enum *descriptorpb.EnumDescriptorProto) (jsonschema.Type, error) {
    98  
    99  	// Prepare a new jsonschema.Type for our eventual return value:
   100  	jsonSchemaType := jsonschema.Type{
   101  		Version: jsonschema.Version,
   102  	}
   103  
   104  	// Generate a description from src comments (if available)
   105  	if src := c.sourceInfo.GetEnum(enum); src != nil {
   106  		jsonSchemaType.Description = formatDescription(src)
   107  	}
   108  
   109  	// Note: not setting type specification(oneof string and integer), because explicitly saying which
   110  	// values are valid (and any other is invalid) is enough specification what can be used
   111  	// (this also overcome bug in example creator https://json-schema-faker.js.org/ that doesn't select
   112  	// correct type for enum value but rather chooses random type from oneof and cast value to that type)
   113  	//
   114  	// Allow both strings and integers:
   115  	// jsonSchemaType.OneOf = append(jsonSchemaType.OneOf, &jsonschema.Type{Type: "string"})
   116  	// jsonSchemaType.OneOf = append(jsonSchemaType.OneOf, &jsonschema.Type{Type: "integer"})
   117  
   118  	// Add the allowed values:
   119  	for _, enumValue := range enum.Value {
   120  		jsonSchemaType.Enum = append(jsonSchemaType.Enum, enumValue.Name)
   121  		jsonSchemaType.Enum = append(jsonSchemaType.Enum, enumValue.Number)
   122  	}
   123  
   124  	return jsonSchemaType, nil
   125  }
   126  
   127  // Converts a proto file into a JSON-Schema:
   128  func (c *Converter) convertFile(file *descriptorpb.FileDescriptorProto) ([]*pluginpb.CodeGeneratorResponse_File, error) {
   129  	// Input filename:
   130  	protoFileName := path.Base(file.GetName())
   131  
   132  	// Prepare a list of responses:
   133  	var response []*pluginpb.CodeGeneratorResponse_File
   134  
   135  	// user wants specific messages
   136  	genSpecificMessages := len(c.messageTargets) > 0
   137  
   138  	// Warn about multiple messages / enums in files:
   139  	if !genSpecificMessages && len(file.GetMessageType()) > 1 {
   140  		c.logger.WithField("schemas", len(file.GetMessageType())).WithField("proto_filename", protoFileName).Warn("protoc-gen-jsonschema will create multiple MESSAGE schemas from one proto file")
   141  	}
   142  
   143  	if len(file.GetEnumType()) > 1 {
   144  		c.logger.WithField("schemas", len(file.GetMessageType())).WithField("proto_filename", protoFileName).Warn("protoc-gen-jsonschema will create multiple ENUM schemas from one proto file")
   145  	}
   146  
   147  	// Generate standalone ENUMs:
   148  	if len(file.GetMessageType()) == 0 {
   149  		for _, enum := range file.GetEnumType() {
   150  			jsonSchemaFileName := c.generateSchemaFilename(file, enum.GetName())
   151  			c.logger.WithField("proto_filename", protoFileName).WithField("enum_name", enum.GetName()).WithField("jsonschema_filename", jsonSchemaFileName).Info("Generating JSON-schema for stand-alone ENUM")
   152  
   153  			// Convert the ENUM:
   154  			enumJSONSchema, err := c.convertEnumType(enum)
   155  			if err != nil {
   156  				c.logger.WithError(err).WithField("proto_filename", protoFileName).Error("Failed to convert")
   157  				return nil, err
   158  			}
   159  
   160  			// Marshal the JSON-Schema into JSON:
   161  			jsonSchemaJSON, err := json.MarshalIndent(enumJSONSchema, "", "    ")
   162  			if err != nil {
   163  				c.logger.WithError(err).Error("Failed to encode jsonSchema")
   164  				return nil, err
   165  			}
   166  
   167  			// Add a response:
   168  			resFile := &pluginpb.CodeGeneratorResponse_File{
   169  				Name:    proto.String(jsonSchemaFileName),
   170  				Content: proto.String(string(jsonSchemaJSON)),
   171  			}
   172  			response = append(response, resFile)
   173  		}
   174  	} else {
   175  		// Otherwise process MESSAGES (packages):
   176  		pkg, ok := c.relativelyLookupPackage(globalPkg, file.GetPackage())
   177  		if !ok {
   178  			return nil, fmt.Errorf("no such package found: %s", file.GetPackage())
   179  		}
   180  
   181  		for _, msg := range file.GetMessageType() {
   182  			// skip if we are only generating schema for specific messages
   183  			if genSpecificMessages && !contains(c.messageTargets, msg.GetName()) {
   184  				continue
   185  			}
   186  
   187  			jsonSchemaFileName := c.generateSchemaFilename(file, msg.GetName())
   188  			c.logger.WithField("proto_filename", protoFileName).WithField("msg_name", msg.GetName()).WithField("jsonschema_filename", jsonSchemaFileName).Info("Generating JSON-schema for MESSAGE")
   189  
   190  			// Convert the message:
   191  			messageJSONSchema, err := c.convertMessageType(pkg, msg)
   192  			if err != nil {
   193  				c.logger.WithError(err).WithField("proto_filename", protoFileName).Error("Failed to convert")
   194  				return nil, err
   195  			}
   196  
   197  			// Marshal the JSON-Schema into JSON:
   198  			jsonSchemaJSON, err := json.MarshalIndent(messageJSONSchema, "", "    ")
   199  			if err != nil {
   200  				c.logger.WithError(err).Error("Failed to encode jsonSchema")
   201  				return nil, err
   202  			}
   203  
   204  			// Add a response:
   205  			resFile := &pluginpb.CodeGeneratorResponse_File{
   206  				Name:    proto.String(jsonSchemaFileName),
   207  				Content: proto.String(string(jsonSchemaJSON)),
   208  			}
   209  			response = append(response, resFile)
   210  		}
   211  	}
   212  
   213  	return response, nil
   214  }
   215  
   216  func (c *Converter) convert(req *pluginpb.CodeGeneratorRequest) (*pluginpb.CodeGeneratorResponse, error) {
   217  	c.parseGeneratorParameters(req.GetParameter())
   218  
   219  	generateTargets := make(map[string]bool)
   220  	for _, file := range req.GetFileToGenerate() {
   221  		generateTargets[file] = true
   222  	}
   223  
   224  	c.sourceInfo = newSourceCodeInfo(req.GetProtoFile())
   225  	res := &pluginpb.CodeGeneratorResponse{}
   226  	for _, file := range req.GetProtoFile() {
   227  		if file.GetPackage() == "" {
   228  			c.logger.WithField("filename", file.GetName()).Warn("Proto file doesn't specify a package")
   229  			continue
   230  		}
   231  
   232  		for _, msg := range file.GetMessageType() {
   233  			c.logger.WithField("msg_name", msg.GetName()).WithField("package_name", file.GetPackage()).Debug("Loading a message")
   234  			c.registerType(file.Package, msg)
   235  		}
   236  
   237  		for _, en := range file.GetEnumType() {
   238  			c.logger.WithField("enum_name", en.GetName()).WithField("package_name", file.GetPackage()).Debug("Loading an enum")
   239  			c.registerEnum(file.Package, en)
   240  		}
   241  
   242  		if _, ok := generateTargets[file.GetName()]; ok {
   243  			c.logger.WithField("filename", file.GetName()).Debug("Converting file")
   244  			converted, err := c.convertFile(file)
   245  			if err != nil {
   246  				res.Error = proto.String(fmt.Sprintf("Failed to convert %s: %v", file.GetName(), err))
   247  				return res, err
   248  			}
   249  			res.File = append(res.File, converted...)
   250  		}
   251  	}
   252  	return res, nil
   253  }
   254  
   255  func (c *Converter) generateSchemaFilename(file *descriptorpb.FileDescriptorProto, protoName string) string {
   256  	if c.PrefixSchemaFilesWithPackage {
   257  		return fmt.Sprintf("%s/%s.jsonschema", file.GetPackage(), protoName)
   258  	}
   259  	return fmt.Sprintf("%s.jsonschema", protoName)
   260  }
   261  
   262  func contains(haystack []string, needle string) bool {
   263  	for i := 0; i < len(haystack); i++ {
   264  		if haystack[i] == needle {
   265  			return true
   266  		}
   267  	}
   268  
   269  	return false
   270  }