Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adding basic support for XML structure #328

Open
wants to merge 27 commits into
base: main
Choose a base branch
from

Conversation

DimShadoWWW
Copy link

This pull request introduces basic support for XML tags in OpenAPI documentation, allowing the following features to be defined for schema fields:

  1. Custom XML Element Names:
    The xml:"customName" struct tag allows specifying a custom name for the XML element.
  2. Attribute Definition:
    The xml:"name,attr" struct tag marks a field as an XML attribute.
  3. Wrapped Elements:
    The xml:"name,wrapped" struct tag enables support for wrapping elements (e.g., <items><item>...</item></items>).

Copy link
Collaborator

@dylanhitt dylanhitt left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be nice to split the json tag stuff and the xml tag stuff into helper functions. This function is getting rather large.

IngressDate string `json:"in_date" xml:"date,attr"`
}

type MyOutputStructWithXmlAttribute struct {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should add a test for a struct with just xml attributes. As well as one with json:"-"

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I still need to work on this.

Copy link
Collaborator

@ccoVeille ccoVeille left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Amazing work you did from a simple comment I made (am I right?)

A few remarks

Also. here is a more general feedback, I would have put all these metadata parser things in a package in internal to do not export them (I'm unsure it's possible)

- Resolved `gosec` warnings (G115) by adding `//nolint:gosec` comments to suppress integer overflow checks for `uint64` conversions.
- Addressed `gosimple` warning (S1021) by merging variable declaration and assignment for `parsersToRegister`.
- Fixed formatting issues detected by `gofumpt`, including unnecessary leading newlines and proper formatting of function declarations.
@DimShadoWWW
Copy link
Author

DimShadoWWW commented Jan 8, 2025

Added a new commit to the PR to fix the structure for []struct, where the Type should be set to "array" and properties should be moved to items.

Additionally, I detected a bug in the current code when handling embedded structs (see test Test_tagFromType/struct_with_embedded_struct_with_tags).

Copy link
Collaborator

@dylanhitt dylanhitt left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a lot. But it looks good and it's written really well.

I'll probably make a few passes over it.

processXMLStructChildren(params)
} else if params.Field.Type.Kind() == reflect.Slice {
elemType := params.Field.Type.Elem()
if elemType.Kind() == reflect.Struct {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This check feels weird. Maybe take a look at dive(). You're doing a bit more here than what dive is doing though.

Feel free to resolve this

Copy link
Author

@DimShadoWWW DimShadoWWW Jan 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have a struct like this one:

type Fixture struct {
	XMLName xml.Name `xml:"Fixture" json:"-"`

	// List of levels in the competition.
	Level []struct {
		Round []struct {
			DateBegin string `xml:"dateBegin,omitempty,attr" json:"dateBegin,omitempty" validate:"omitempty,datePattern"`
			// Date of the end of the competition.
			DateEnd string `xml:"dateEnd,omitempty,attr" json:"dateEnd,omitempty" validate:"omitempty,datePattern"`

			// Order of the round.
			Order     int    `json:"order,omitempty" xml:"order,attr"`
			RoundName string `xml:"roundName,attr" json:"roundName,omitempty" validate:"required" description:"Round name" example:"Gameweek 17"`
			RoundId   int    `xml:"roundId,attr" json:"roundId,omitempty" validate:"required,min=1" description:"Round ID in our system" example:"4"`
		} `json:"round,omitempty" xml:"Round"`
		// Level name.
		LevelName string `json:"levelName,omitempty" xml:"levelName1,attr" validate:"required" description:"Name of the phase of tournament" example:"Regular Season"`
		// Level ID.
		LevelNumber int `json:"levelNumber,omitempty" xml:"levelNumber,attr" validate:"required,min=1" description:"ID of the phase of tournament" example:"1"`
	} `json:"level,omitempty" xml:"Level"`
	// Competition identity name.
	Channel string `xml:"channel,attr" json:"channel,omitempty" validate:"required" description:"Name of channel corresponding to tournament." example:"argentinec"`
	// Competition name.
	Competition string `xml:"competition,attr" json:"competition,omitempty" validate:"required" description:"Season name" example:"Argentina - Campeonato Primera C Temporada 2024"`
}

which is generating this OpenAPI schema:

"Fixture": {
    "description": "Fixture schema",
    "properties": {
      "channel": {
        "description": "Name of channel corresponding to tournament.",
        "example": "argentinec",
        "nullable": true,
        "type": "string",
        "xml": {
          "attribute": true,
          "name": "channel"
        }
      },
      "competition": {
        "description": "Season name",
        "example": "Argentina - Campeonato Primera C Temporada 2024",
        "nullable": true,
        "type": "string",
        "xml": {
          "attribute": true,
          "name": "competition"
        }
      },
      "level": {
        "items": {
          "properties": {
            "levelName": {
              "type": "string"
            },
            "levelNumber": {
              "type": "integer"
            },
            "round": {
              "items": {
                "properties": {
                  "dateBegin": {
                    "type": "string"
                  },
                  "dateEnd": {
                    "type": "string"
                  },
                  "order": {
                    "type": "integer"
                  },
                  "roundId": {
                    "type": "integer"
                  },
                  "roundName": {
                    "type": "string"
                  }
                },
                "type": "object"
              },
              "type": "array"
            }
          },
          "type": "object"
        },
        "nullable": true,
        "type": "array",
        "xml": {
          "name": "Level"
        }
      }
    },
    "required": [
      "channel",
      "competition"
    ],
    "type": "object"
}

As you can see, from "level" to its child elements, there is no additional information—only "type" is present. This is the issue that persists.

I added logs to ParseStructTags to trace how it processes the attribute "LevelName." However, even though it is included in the schema, it is not found:

func (mp *MetadataParsers) ParseStructTags(t reflect.Type, schemaRef *openapi3.SchemaRef) {
	if t.Kind() == reflect.Ptr {
		t = t.Elem()
	}
	if t.Kind() != reflect.Struct {
		return
	}

	for i := 0; i < t.NumField(); i++ {
		field := t.Field(i)
		if field.Name == "levelName" || field.Name == "LevelName" {
			slog.Error("======= levelName field detected", "field", field.Name)
		}

		if field.Anonymous {
....

At this point, I’m out of ideas on how to proceed. In your experience, what steps should I take to identify and fix this bug?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll take a look tomorrow morning as I should have some spare time.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry won't be til later this week. Day job getting in the way.

Copy link
Author

@DimShadoWWW DimShadoWWW Jan 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Working with the original code where parseStructTags was used directly and the function included the logic for JSON, XML, example, and validation, I managed to make it work.

this is the code (I added enum_values too, to specify all possible values for field):

// parseStructTags parses struct tags and modifies the schema accordingly.
// t must be a struct type.
// It adds the following struct tags (tag => OpenAPI schema field):
// - description => description
// - example => example
// - json => nullable (if contains omitempty)
// - validate:
//   - required => required
//   - min=1 => min=1 (for integers)
//   - min=1 => minLength=1 (for strings)
//   - max=100 => max=100 (for integers)
//   - max=100 => maxLength=100 (for strings)
func parseStructTags(t reflect.Type, schemaRef *openapi3.SchemaRef) {
	if t.Kind() == reflect.Ptr {
		t = t.Elem()
	}

	if t.Kind() != reflect.Struct {
		return
	}

	for i := range t.NumField() {
		field := t.Field(i)
		if field.Anonymous {
			parseStructTags(field.Type, schemaRef)
			continue
		}

		jsonFieldName := field.Tag.Get("json")
		jsonFieldName = strings.Split(jsonFieldName, ",")[0] // remove omitempty, etc
		if jsonFieldName == "-" {
			continue
		}

		if jsonFieldName == "" {
			jsonFieldName = field.Name
		}

		if slices.Contains([]string{"XMLName"}, jsonFieldName) {
			// Skip XMLName field in struct tags as it is not relevant to OpenAPI
			continue
		}

		if schemaRef.Value.Properties == nil {
			schemaRef.Value.Properties = make(map[string]*openapi3.SchemaRef)
		}

		if _, exists := schemaRef.Value.Properties[jsonFieldName]; !exists {
			schemaRef.Value.Properties[jsonFieldName] = &openapi3.SchemaRef{
				Value: &openapi3.Schema{},
			}
		}

		property := schemaRef.Value.Properties[jsonFieldName]

		propertyCopy := *property
		propertyValue := *propertyCopy.Value

                //  --- This section does the magic. --- 
		if field.Type.Kind() == reflect.Struct {
			propertyValue.Type = &openapi3.Types{openapi3.TypeObject}
			if propertyValue.Properties == nil {
				propertyValue.Properties = make(map[string]*openapi3.SchemaRef)
			}
			parseStructTags(field.Type, &openapi3.SchemaRef{Value: &propertyValue})
		} else if field.Type.Kind() == reflect.Slice && field.Type.Elem().Kind() == reflect.Struct {
			propertyValue.Type = &openapi3.Types{openapi3.TypeArray}
			itemSchema := openapi3.NewSchemaRef("", openapi3.NewObjectSchema())
			parseStructTags(field.Type.Elem(), itemSchema)
			propertyValue.Items = itemSchema
		}
                
		propertyValue.XML = &openapi3.XML{}

		// Xml attributes
		xmlTag, ok := field.Tag.Lookup("xml")
		if ok {
			xmlTagName := strings.Split(xmlTag, ",")[0]
			if xmlTagName == "-" {
				continue
			}

			if field.Type.Kind() == reflect.Struct {
				for i := 0; i < field.Type.NumField(); i++ {
					childField := field.Type.Field(i)
					if childField.Name == "XMLName" && childField.Type == reflect.TypeOf(xml.Name{}) {
						xmlTagName = childField.Name
						break
					}
				}
			}

			if xmlTagName == "" {
				xmlTagName = field.Name
			}

			propertyValue.XML.Name = xmlTagName

			if slices.Contains(strings.Split(xmlTag, ","), "attr") {
				propertyValue.XML.Attribute = true
			}
		}

		// Example
		example, ok := field.Tag.Lookup("example")
		if ok {
			propertyValue.Example = example
			if propertyValue.Type.Is(openapi3.TypeInteger) {
				exNum, err := strconv.Atoi(example)
				if err != nil {
					slog.Warn("Example might be incorrect (should be integer)", "error", err)
				}
				propertyValue.Example = exNum
			}
		}

		// Emun values
		emunValues, ok := field.Tag.Lookup("enum_values")
		if ok {
			values := strings.Split(emunValues, ",")
			propertyValue.Enum = make([]any, len(values))
			for i, v := range values {
				propertyValue.Enum[i] = v
			}
		}

		// Validation
		validateTag, ok := field.Tag.Lookup("validate")
		validateTags := strings.Split(validateTag, ",")
		if ok && slices.Contains(validateTags, "required") {
			schemaRef.Value.Required = append(schemaRef.Value.Required, jsonFieldName)
		}
		for _, validateTag := range validateTags {
			if strings.HasPrefix(validateTag, "min=") {
				min, err := strconv.Atoi(strings.Split(validateTag, "=")[1])
				if err != nil {
					slog.Warn("Min might be incorrect (should be integer)", "error", err)
				}

				if propertyValue.Type.Is(openapi3.TypeInteger) {
					minPtr := float64(min)
					propertyValue.Min = &minPtr
				} else if propertyValue.Type.Is(openapi3.TypeString) {
					//nolint:gosec // disable G115
					propertyValue.MinLength = uint64(min)
				}
			}
			if strings.HasPrefix(validateTag, "max=") {
				max, err := strconv.Atoi(strings.Split(validateTag, "=")[1])
				if err != nil {
					slog.Warn("Max might be incorrect (should be integer)", "error", err)
				}
				if propertyValue.Type.Is(openapi3.TypeInteger) {
					maxPtr := float64(max)
					propertyValue.Max = &maxPtr
				} else if propertyValue.Type.Is(openapi3.TypeString) {
					//nolint:gosec // disable G115
					maxPtr := uint64(max)
					propertyValue.MaxLength = &maxPtr
				}
			}
		}

		// Description
		description, ok := field.Tag.Lookup("description")
		if ok {
			propertyValue.Description = description
		}
		jsonTag, ok := field.Tag.Lookup("json")
		if ok {
			if strings.Contains(jsonTag, ",omitempty") {
				propertyValue.Nullable = true
			}
		}

		propertyCopy.Value = &propertyValue

		schemaRef.Value.Properties[jsonFieldName] = &propertyCopy

	}
}

I will try to apply it to this PR next week, if it’s not done before.

DimShadoWWW and others added 6 commits January 8, 2025 07:51
- Corrected the implementation to properly utilize `openapi3.NewArraySchema` for defining array types in OpenAPI schemas.
- Ensured that the `Type` is set to "array" and properties are moved to `items` when handling `[]struct`.

This fix aligns the schema generation with OpenAPI specifications for arrays.
@DimShadoWWW
Copy link
Author

closed PR on error

@DimShadoWWW
Copy link
Author

restored commit history

@DimShadoWWW DimShadoWWW reopened this Jan 13, 2025
@EwenQuim
Copy link
Member

EwenQuim commented Jan 16, 2025

Looking at it today and tomorrow, PR is pretty big but of good quality from what I've currently read ;)

for _, entry := range mp.registeredParsers {
if entry.Name == name {
// Remove the existing parser before re-registering
mp.remove(entry)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Error is not checked.
Please run make to check the CI

Comment on lines +53 to +55
registeredParsers []MetadataParserEntry
parserLock sync.Mutex
registeredNames map[string]bool
Copy link
Member

@EwenQuim EwenQuim Jan 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From what I've read, registeredNames is a Set that just checks that parsers aren't registered twice. And they are re-declared in MetadataParserEntry.Name, am I right?

Since you just iterate on registeredParsers, can you please simply use a map? registeredParsers map[string] MetadataParserFunction. Unique source of truth + all previous capabilities are still available. Using correct data structures always helps readability and maintainability!

Parser MetadataParserFunction
}

type MetadataParsers struct {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please comment the type so one can easily know what is this struct about.

Comment on lines +20 to +21
mp := NewMetadataParsers()
mp.Initialize(DefaultParsers)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since you use NewMetadataParsers AND Initialize function, why don't you write something like mp := NewMetadataParsers(DefaultParsers) directly? Since it makes sense in a business point of view, I think it's a correct API.

Comment on lines +75 to +80
mp := &MetadataParsers{
registeredParsers: []MetadataParserEntry{
{Name: "parser1", Parser: func(params MetadataParserParams) { return }},
{Name: "parser2", Parser: func(params MetadataParserParams) { return }},
},
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here we should declare registeredNames: []string{"parser1", "parser2" isn't it? That's why I propose using maps

Comment on lines +117 to +134
// Verify returned slice matches original registered parsers
func TestGetRegisteredParsersMatchesOriginal(t *testing.T) {
mp := &MetadataParsers{
registeredParsers: []MetadataParserEntry{
{Name: "parser1", Parser: func(params MetadataParserParams) { return }},
{Name: "parser2", Parser: func(params MetadataParserParams) { return }},
},
}

parsers := mp.GetRegisteredParsers()

require.NotNil(t, parsers)
require.Len(t, parsers, 2)

if !reflect.DeepEqual(parsers, mp.registeredParsers) {
t.Errorf("Returned parsers do not match the original registered parsers")
}
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since mp.GetRegisteredParsers() is returning the mp.registeredParsers, this test is useless. What do you wanted to test exactly?

Comment on lines +136 to +156
// Reset clears all registered parsers from the slice
func TestResetClearsRegisteredParsers(t *testing.T) {
mp := &MetadataParsers{
registeredParsers: []MetadataParserEntry{
{Name: "parser1", Parser: nil},
{Name: "parser2", Parser: nil},
},
registeredNames: map[string]bool{
"parser1": true,
"parser2": true,
},
}

mp.Reset()

require.NotNil(t, mp.registeredParsers)
require.NotNil(t, mp.registeredNames)

require.Empty(t, mp.registeredParsers)
require.Empty(t, mp.registeredNames)
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea!

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's really good to have a such a big file with so many tests, thank you for that!

I'm a little disappointed that it does not test custom "Metadata parsers"

@EwenQuim
Copy link
Member

It seems that this PR does several things. If I understand correctly:

  • ✅ it refactors way more cleanly the "metadata parsing"
  • ✅ it allows users to implement their custom metadata parsing
  • ✅ it implements better XML structure parsing (support for xml.Name)

So this PR is really cool, I like the idea and it really improves Fuego, thank you for all these efforts! 🥳

But by doing 3 things at a time, it makes the PR really big (more than 800 lines!!!) and that's why it is hard to review and to understand. I also feel that it might be quite heavy for you (this PR is a month old), making changes is not easy and it begins to be quite slow.

Because of that, I will skip it for the next release (v0.18.0). I encourage you to iterate quickly with small changes rather than making big PRs, so we will be able to add it in the next release (v0.19.0). If you can't, no problem, we're all volunteers, I understand you might not have time :)

@EwenQuim EwenQuim modified the milestones: v0.14, v0.19 Jan 20, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants