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

[BUG] Golang pattern validation with regex fails on commas #20079

Open
5 of 6 tasks
DVasselli opened this issue Nov 11, 2024 · 11 comments
Open
5 of 6 tasks

[BUG] Golang pattern validation with regex fails on commas #20079

DVasselli opened this issue Nov 11, 2024 · 11 comments

Comments

@DVasselli
Copy link

Bug Report Checklist

  • Have you provided a full/minimal spec to reproduce the issue?
  • Have you validated the input using an OpenAPI validator (example)?
  • Have you tested with the latest master to confirm the issue still exists?
  • Have you searched for related issues/PRs?
  • What's the actual output vs expected output?
  • [Optional] Sponsorship to speed up the bug fix or feature request (example)
Description

When having a struct with a oneOf keyword which has an item with a regex validation, if the regex has a comma, the validation will always fail.

For example, if I want a number to be present 2 or more times [0-9]{2,}, this would fail since the validator library wants us to escape the comma.

Furthermore, the error message says data failed to match schemas in oneOf which is misleading since the error is really coming from the regex validation.

openapi-generator version

7.9.0

OpenAPI declaration file content or url
openapi: '3.0.3'
info:
  title: API Test
  version: '1.0'
paths:
  /foo:
    get:
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  code:
                    oneOf:
                      - $ref: "#/components/schemas/importCode"

components:
  schemas:
    importCode:
      type: object
      properties:
        code:
          type: string
          pattern: "^[0-9]{2,}$"
Generation Details

From the folder where you put the yml file (I am working in ~/playground):

openapi-generator generate -i api_test.yml \
    -g go \
    -o ~/playground/gen \
    --package-name=foo \
    --global-property=verbose=false,apiDocs=false,apiTests=false,modelDocs=false,modelTests=false \
    --additional-properties=isGoSubmodule=true,withGoMod=false
Steps to reproduce
  1. Generate the file yml definition and the command above.
  2. Create a foo_test.go file like the following:
package playground

import (
	"encoding/json"
	foo "foo/gen"
	"testing"
)

func TestFoo(t *testing.T) {
	f1 := foo.FooGet200Response{
		Code: &foo.FooGet200ResponseCode{
			ImportCode: &foo.ImportCode{
				Code: Get("12"),
			},
		},
	}

	m, err := json.Marshal(f1)
	if err != nil {
		panic(err)
	}

	f2 := &foo.FooGet200Response{}
	err = json.Unmarshal(m, f2)
	if err != nil {
		panic(err)
	}
}

func Get[T any](v T) *T {
	return &v
}

This will fail at unmarshalling no matter how many digits you put on that string.
3. To prove the point, edit the yml file removing the comma, regenerate, and provide exactly 2 digits in the test. This will succeed.
4. Bonus point, if on the file with an exact number of repetition (without the comma) you put more than expected, the error will say data failed to match schemas in oneOf which is too generic for regex validation failure.

Related issues/PRs

None

Suggest a fix

From where I stand, there are 2 possible issues:

  1. Remove this manipulation and accept the regex as it is provided by the user
    cp.pattern.replace("\\", "\\\\").replaceAll("^/|/$", "") +
    "\"");
    }
  2. The current library is expecting an invalid regex syntax, and it could be replaced with any other that does not have this issue
@mlebihan
Copy link
Contributor

mlebihan commented Dec 9, 2024

Currently investigating (with Java and Go, but I'm not using playground)...

I would like to add to this issue a test case with another regex that causes me a trouble, if you agree:

        name:
          type: string
          pattern: "^[&#x9;!'&\"()*+,-./0-9:;<=>;?A-Z_a-z[]{}|^@#~]+$`"

This one, also:

  • due to the presence of a backtick inside it, go generator attempts to generate a validate:regex enclosed into "..." instead of backticks, that generates this statement:
Name *string "json:\"name,omitempty\" validate:regex=\"/^[&#x9;!'&\\"()*+,-.\/0-9:;<=>;?A-Z_a-z[]{}|^@#~]+`$/\""

which is also invalid, another way, by a go vet ./...:

./model_import_code.go:23:71: syntax error: unexpected ( in struct type; possibly missing semicolon or newline or }
./model_import_code.go:23:78: invalid character U+005C '\'
./model_import_code.go:23:89: invalid character U+003F '?'
./model_import_code.go:23:103: invalid character U+0040 '@'
./model_import_code.go:23:104: invalid character U+0023 '#'
./model_import_code.go:23:108: string not terminated

So that, I add to remove that backtick character, resuming my regex in yaml's oneOf to whats following, in order to go further:
^[&#x9;!'&\"()*+,-./0-9:;<=>;?A-Z_a-z[]{}|^@#~]+$


I did the first attempt you suggested, @DVasselli

if (cp.pattern != null) {
    String regex = String.format(Locale.getDefault(), "regex=\"%s\"", cp.pattern);
    String validate = String.format(Locale.getDefault(), "validate:%s", regex);

    cp.vendorExtensions.put(X_GO_CUSTOM_TAG, validate);
}

// construct data tag in the template: x-go-datatag
// originl template
// `json:"{{{baseName}}}{{^required}},omitempty{{/required}}"{{#withXml}} xml:"{{{baseName}}}{{#isXmlAttribute}},attr{{/isXmlAttribute}}"{{/withXml}}{{#withValidate}} validate:"{{validate}}"{{/withValidate}}{{#vendorExtensions.x-go-custom-tag}} {{{.}}}{{/vendorExtensions.x-go-custom-tag}}`
String goDataTag = "json:\"" + cp.baseName;
if (!cp.required) {
    goDataTag += ",omitempty";
}
goDataTag += "\"";

if (withXml) {
    goDataTag += " xml:" + "\"" + cp.baseName;
    if (cp.isXmlAttribute) {
        goDataTag += ",attr";
    }
    goDataTag += "\"";
}

// {{#withValidate}} validate:"{{validate}}"{{/withValidate}}
if (Boolean.parseBoolean(String.valueOf(additionalProperties.getOrDefault("withValidate", "false")))) {
    goDataTag += " validate:\"" + additionalProperties.getOrDefault("validate", "") + "\"";
}

// {{#vendorExtensions.x-go-custom-tag}} {{{.}}}{{/vendorExtensions.x-go-custom-tag}}
if (StringUtils.isNotEmpty(String.valueOf(cp.vendorExtensions.getOrDefault(X_GO_CUSTOM_TAG, "")))) {
    goDataTag += " " + cp.vendorExtensions.get(X_GO_CUSTOM_TAG);
}

// if it contains backtick, wrap with " instead
if (goDataTag.contains("`")) {
    goDataTag = " \"" + goDataTag.replace("\"", "\\\"") + "\"";
} else {
    goDataTag = " `" + goDataTag + "`";
}
cp.vendorExtensions.put("x-go-datatag", goDataTag);

Making Go client generator producing this, that looks nicer to me:

// ImportCode struct for ImportCode
type ImportCode struct {
	Code *string `json:"code,omitempty" validate:regex="/^[0-9]{2,}$/"`
	Name *string `json:"name,omitempty" validate:regex="/^[&#x9;!'&\"()*+,-.\/0-9:;<=>;?A-Z_a-z[]{}|^@#~]+$/"`
}

But it still doesn't work:

go vet ./...
./model_import_code.go:22:2: struct field tag `json:"code,omitempty" validate:regex="/^[0-9]{2,}$/"` not compatible with reflect.StructTag.Get: bad syntax for struct tag value
./model_import_code.go:23:2: struct field tag `json:"name,omitempty" validate:regex="/^[&#x9;!'&\"()*+,-.\/0-9:;<=>;?A-Z_a-z[]{}|^@#~]+$/"` not compatible with reflect.StructTag.Get: bad syntax for struct tag value

Removing /.../ of both expressions doesn't help.

Does anybody have a clue about what target expression should be enclosed into regex="...", that would be accepted for each case ?

go vet ./... only explains that the cause is "bad syntax" which doesn't help a lot.

@DVasselli
Copy link
Author

DVasselli commented Dec 9, 2024

@mlebihan thanks for looking into this!

Looking at your generated code I found the following:

  • The commas should be right after validate and not at the beginning of the regex expression
  • The ^ and $ should not have a / in front of
  • The key name is regexp and not regex
  • The commas should be escaped
    So this actually works
// ImportCode struct for ImportCode
type ImportCode struct {
	Code *string `json:"code,omitempty" validate:"regexp=^[0-9]{2\\,}$"`
        // Name field I am not sure I understand that regex...
}

As per this:

Does anybody have a clue about what target expression should be enclosed into regex="...", that would be accepted for each case ?

The generator is generating code that uses this validation library https://github.com/go-validator/validator so the rules described in the read me should apply. In a nutshell, the struct tag should have this format `validate:"regexp=<regex_string>"` where `<regex_string> is a normal valid go regex with the only nuance that commas should be escaped (this is a library need).

As per the backtick ` issue, replacing it with \x60 works.

Hope this helps

mlebihan added a commit to mlebihan/openapi-generator that referenced this issue Dec 10, 2024
@mlebihan
Copy link
Contributor

mlebihan commented Dec 10, 2024

I succeeded in correcting most of the generation, but I'm still a bit unease. See my fork, WIP.

https://github.com/mlebihan/openapi-generator/blob/9e1afba9419422e4bf279d22260a8b0984965202/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractGoCodegen.java#L789-L823

The second pattern I'm trying to compile for the Name test case, is a correct one:

^[&#x9;!'&\"()*+,-./0-9:;<=>;?A-Z_a-z[]{}|^@#~]+`$  

...even if it might produce no interesting matches: I've received a regex near that one, had to work it a bit to make it accepted by one online validator

image

and then, whatever it is a clever one or not, it is syntactically correct (even in the case I would have broken its semantic and that it would become useless) and should be taken as it is. But I'll try to correct it, if I can, in order to have a complex but fully functional regexp.

Whatever, I've almost succeeded:

components:
  schemas:
    importCode:
      type: object
      properties:
        code:
          type: string
          pattern: "^[0-9]{2,}$"

        name:
          type: string
          pattern: "^[&#x9;!'&\"()*+,-./0-9:;<=>;?A-Z_a-z[]{}|^@#~]+`$"

leads now to this fragment, that is accepted by go vet ./...

type ImportCode struct {
	Code *string `json:"code,omitempty" validate:"regexp=^[0-9]{2\\,}$"`
	Name *string `json:"name,omitempty" validate:"regexp=^[&#x9;!'&\"()*+\\,-.\\0-9:;<=>;?A-Z_a-z[]{}|^@#~]+\x60$"`
}

I have no clue why is it so, but it's by construction, for a long time and for all languages, cp.pattern comes surrounded by /.../ and with some treatment already of characters: my /0-9 has become \/0-9

/^[0-9]{2,}$/
/^[&#x9;!'&\"()*+,-.\/0-9:;<=>;?A-Z_a-z[]{}|^@#~]+`$/

And then my Java statements are currently defective:

/* We need to escape \ to \\
     except if it is currently present in the string for the quote itself. Regexp:
     \\[^"]
*/
String BACKSLASH_THAT_ISNT_AN_ESCAPED_QUOTE="\\\\[^\"]";
String ESCAPED_BACKSLASH_INTO_A_STRING_ITSELF = Matcher.quoteReplacement("\\\\");

validate = validate.replaceAll(BACKSLASH_THAT_ISNT_AN_ESCAPED_QUOTE, ESCAPED_BACKSLASH_INTO_A_STRING_ITSELF);

They are here to prevent the escaping of the \ of the \" that is in the middle of regexp string, but escape all the others. But it has a problem: it doesn't preserve the character that comes just after.

So:
\" stays as it is. It's ok
\/0-9 becomes \\0-9 instead of \\/0-9

And I am not experienced a lot in regexp to correct this.

@DVasselli
Copy link
Author

So, I might be a bit naive here, but why are we doing all that manipulation on the incoming pattern field? If I didn't know any better, I would expect the generator to take the regex as provided. Now, since we know that go and the library we use have some requirements, I see the need to replace , with \\, and ` with \x60, however why are we also replacing backslashes and so on?
Also, /0-9 is a valid regex as is, \/0-9 will generate something that fails go vet ./..., and \\/0-9 is not the same regex as the first.

And I am not experienced a lot in regexp to correct this.

I can help if you need, but I am not sure what you need help with

@mlebihan
Copy link
Contributor

mlebihan commented Dec 10, 2024

If I remove the suspicious lines you've seen, and write this code instead:

if (cp.pattern != null) {
    LOGGER.info("Received pattern: {}", cp.pattern);

    // Pattern might be enclosed into /.../ that aren't wished. Remove them.
    String p = cp.pattern;

    if (cp.pattern.startsWith("/") && cp.pattern.endsWith("/")) {
        p = cp.pattern.substring(1, cp.pattern.length() - 1);
    }

    String regexp = String.format(Locale.getDefault(), "regexp=%s", p);
    String validate = String.format(Locale.getDefault(), "validate:\"%s\"", regexp);

    // Replace backtick by \\x60, if found
    if (validate.contains("`")) {
        validate = validate.replace("`", "\\x60");
    }

    // Escape comma
    if (validate.contains(",")) {
        validate = validate.replace(",", "\\\\,");
    }

    LOGGER.info("validate clause: {}", validate);
    cp.vendorExtensions.put(X_GO_CUSTOM_TAG, validate);
}

and that I choose for name a simpler pattern:

        name:
          type: string
          pattern: "^/0-9$"

Here are the logs I'm receiving during generation:

[main] INFO  o.o.c.languages.AbstractGoCodegen - Received pattern: /^[0-9]{2,}$/
[main] INFO  o.o.c.languages.AbstractGoCodegen - validate clause: validate:"regexp=^[0-9]{2\\,}$"
[main] INFO  o.o.c.languages.AbstractGoCodegen - Received pattern: /^\/0-9$/
[main] INFO  o.o.c.languages.AbstractGoCodegen - validate clause: validate:"regexp=^\/0-9$"

The cp.pattern comes to me at the beginning with an extra \ inside it.

This causes the whole generation to be defective soon:

// ImportCode struct for ImportCode
type ImportCode struct {
	Code *string `json:"code,omitempty" validate:"regexp=^[0-9]{2\\,}$"`
	Name *string `json:"name,omitempty" validate:"regexp=^\/0-9$"`
}

if that \ stays, the ImportCode struct doesn't pass the go vet ./...
but if it was: validate:"regexp=^/0-9$" it would.

@DVasselli
Copy link
Author

I see, but if you never gave that \ to the input, where does it get it?

@mlebihan
Copy link
Contributor

In DefaultCodeGen.java an addVars(...) method is called, that receives the good value with ModelUtils.syncValidationProperties(p, property); statement, but then changes it by calling an updatePropertyForString(...) method:

    protected void updatePropertyForString(CodegenProperty property, Schema p) {
        if (ModelUtils.isByteArraySchema(p)) {
            property.setIsString(false);
            property.isByteArray = true;
        } else if (ModelUtils.isBinarySchema(p)) {
            property.isBinary = true;
            property.isFile = true; // file = binary in OAS3
        } else if (ModelUtils.isUUIDSchema(p)) {
            property.isUuid = true;
        } else if (ModelUtils.isURISchema(p)) {
            property.isUri = true;
        } else if (ModelUtils.isEmailSchema(p)) {
            property.isEmail = true;
        } else if (ModelUtils.isPasswordSchema(p)) {
            property.isPassword = true;
        } else if (ModelUtils.isDateSchema(p)) { // date format
            property.setIsString(false); // for backward compatibility with 2.x
            property.isDate = true;
        } else if (ModelUtils.isDateTimeSchema(p)) { // date-time format
            property.setIsString(false); // for backward compatibility with 2.x
            property.isDateTime = true;
        } else if (ModelUtils.isDecimalSchema(p)) { // type: string, format: number
            property.isDecimal = true;
            property.setIsString(false);
        }
        property.pattern = toRegularExpression(p.getPattern());
    }

that eventually causes this:

    public String addRegularExpressionDelimiter(String pattern) {
        if (StringUtils.isEmpty(pattern)) {
            return pattern;
        }

        if (!pattern.matches("^/.*")) {
            return "/" + pattern.replaceAll("/", "\\\\/") + "/";
        }

        return pattern;
    }

I think the best thing I have to do is to save the pattern value just taken from ModelUtils.syncValidationProperties(p, property); step, genuine, in a cp.genuinePattern variable member, at convenience of all modules requiring to examine it as it is given.

mlebihan added a commit to mlebihan/openapi-generator that referenced this issue Dec 13, 2024
mlebihan added a commit to mlebihan/openapi-generator that referenced this issue Dec 14, 2024
mlebihan added a commit to mlebihan/openapi-generator that referenced this issue Dec 14, 2024
mlebihan added a commit to mlebihan/openapi-generator that referenced this issue Dec 14, 2024
@DVasselli
Copy link
Author

I agree with the approach, and specifically with this:

I think the best thing I have to do is to save the pattern value just taken from ModelUtils.syncValidationProperties(p, property); step, genuine, in a cp.genuinePattern variable member, at convenience of all modules requiring to examine it as it is given.

Let me know if you need any support from me, please

@mlebihan
Copy link
Contributor

I went on an interesting page of regex samples with their matches and non-matches from SAP help

I've added them to yaml, but I had to do few changes to enter them:

openapi: '3.0.3'
info:
  title: API Test
  version: '1.0'
paths:
  /foo:
    get:
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  code:
                    oneOf:
                      - $ref: "#/components/schemas/importCode"

components:
  schemas:
    importCode:
      type: object
      properties:
        code:
          type: string
          pattern: "^[0-9]{2,}$"

        name:
          description: "A very long and complex regex"
          type: string
          pattern: "^[&#x9;!'&\"()*+,-./0-9:;<=>;?A-Z_a-z[]{}|^@#~]+`$"

        creditCard:
          description: "Visa credit card\n
            matches: 4123 6453 2222 1746\n
            non-matches: 3124 5675 4400 4567, 4123-6453-2222-1746"
          type: string

          # found unknown escape character s if kept as \s
          pattern: "^4[0-9]{3}\\s[0-9]{4}\\s[0-9]{4}\\s[0-9]{4}$"

        date:
          description: "Some dates\n
            matches: 31/04/1999, 15/12/4567\n
            non-matches: 31/4/1999, 31/4/99, 1999/04/19, 42/67/25456"
          type: string
          pattern: "^([0-2][0-9]|30|31)/(0[1-9]|1[0-2])/[0-9]{4}$"

        windowsAbsolutePath:
          description: "Windows absolute path\n
            matches: \\\\server\\share\\file\n
            non-matches: \\directory\\directory2, /directory2"
          type: string
          pattern: "^([A-Za-z]:|\\)\\[[:alnum:][:whitespace:]!"#$%&'()+,-.\\;=@\[\]^_`{}~.]*$"

        email1:
          description: "Email Address 1\n
            matches: [email protected], [email protected]\n
            non-matches: abc@dummy, ab*[email protected]"
          type: string
          # \- and \. rejected
          pattern: "^[[:word:]\\-.]+@[[:word:]\\-.]+\\.[[:alpha:]]{2,3}$"

        email2:
          description: "Email Address 2\n
            matches: *@[email protected], __1234^%@@abc.def.ghijkl\n
            non-matches: abc.123.*&ca, ^%abcdefg123"
          type: string
          # \.. rejected => \\..
          pattern: "^.+@.+\\..+$"

        htmlHexadecimalColorCode1:
          description: "HTML Hexadecimal Color Code 1\n
            matches: AB1234, CCCCCC, 12AF3B\n
            non-matches: 123G45, 12-44-CC"
          type: string
          pattern: "^[A-F0-9]{6}$"

        htmlHexadecimalColorCode2:
          description: "HTML Hexadecimal Color Code 2\n
            matches: AB 11 00, CC 12 D3\n
            non-matches: SS AB CD, AA BB CC DD, 1223AB"
          type: string
          # found unknown escape character s if kept as \s
          pattern: "^[A-F0-9]{2}\\s[A-F0-9]{2}\\s[A-F0-9]{2}$"

        ipAddress:
          description: "IP Address\n
            matches: 10.25.101.216\n
            non-matches: 0.0.0, 256.89.457.02"
          type: string
          # \. ==> \\.
          pattern: "^((2(5[0-5]|[0-4][0-9])|1([0-9][0-9])|([1-9][0-9])|[0-9])\\.){3}(2(5[0-5]|[0-4][0-9])|1([0-9][0-9])|([1-9][0-9])|[0-9])$"

        javaComments:
          description: "Java Comments\n
            matches: Matches Java comments that are between /* and */, or one line comments prefaced by //\n
            non-matches: a=1"
          type: string
          # \* ==> \\*
          pattern: "^/\\*.*\\*/|//[^\n]*$"

        money:
          description: "\n
            matches: $1.00, -$97.65
            non-matches: $1, 1.00$, $-75.17"
          type: string
          # \+ => \\+ and \$ => \\$ and \. => \\.
          pattern: "^(\\+|-)?\\$[0-9]*\\.[0-9]{2}$"

        positiveNegativeDecimalValue:
          description: "Positive, negative numbers, and decimal values\n
            matches: +41, -412, 2, 7968412, 41, +41.1, -3.141592653 
            non-matches: ++41, 41.1.19, -+97.14"
          type: string
          # \+ => \\+ and \. => \\.
          pattern: "^(\\+|-)?[0-9]+(\\.[0-9]+)?$"

        password1:
          description: "Passwords 1\n
            matches: abcd, 1234, A1b2C3d4, 1a2B3\n
            non-matches: abc, *ab12, abcdefghijkl"
          type: string
          pattern: "^[[:alnum:]]{4,10}$"

        password2:
          description: "Passwords 2\n
            matches: AB_cd, A1_b2c3, a123_\n
            non-matches: *&^g, abc, 1bcd"
          type: string
          # \w => \\w
          pattern: "^[a-zA-Z]\\w{3,7}$"

        phoneNumber:
          description: "Phone Numbers\n
            matches: 519-883-6898, 519 888 6898\n
            non-matches: 888 6898, 5198886898, 519 883-6898"
          type: string
          # \s => \\s
          pattern: "^([2-9][0-9]{2}-[2-9][0-9]{2}-[0-9]{4})|([2-9][0-9]{2}\\s[2-9][0-9]{2}\\s[0-9]{4})$"

        sentence1:
          description: "Sentences 1\n
              matches: Hello, how are you?\n
              non-matches: i am fine"
          type: string
          # \. ==> \\. and \? => \\?
          pattern: "^[A-Z0-9].*(\\.|\\?|!)$"

        sentence2:
          description: "Sentences 2\n
              matches: Hello, how are you?n
              non-matches: i am fine"
          type: string
          pattern: "^[[:upper:]0-9].*[.?!]$"

        socialSecurityNumber:
          description: "Social Security Number\n
              matches: 123-45-6789\n
              non-matches: 123 45 6789, 123456789, 1234-56-7891"
          type: string
          pattern: "^[0-9]{3}-[0-9]{2}-[0-9]{4}$"

        url:
          description: "URL\n
              matches: http://www.sample.com, www.sample.com\n
              non-matches: http://sample.com, http://www.sample.comm"
          type: string
          # \. ==> \\.
          pattern: "^(http://)?www\\.[a-zA-Z0-9]+\\.[a-zA-Z]{2,3}$"

Alas, this yaml generates a Go model that isn't accepted.

/*
API Test

No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)

API version: 1.0
*/

// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.

package openapi

import (
	"encoding/json"
)

// checks if the ImportCode type satisfies the MappedNullable interface at compile time
var _ MappedNullable = &ImportCode{}

// ImportCode struct for ImportCode
type ImportCode struct {
	Code *string `json:"code,omitempty" validate:"regexp=^[0-9]{2\\,}$"`
	// A very long and complex regex
	Name *string `json:"name,omitempty" validate:"regexp=^[&#x9;!'&\",()*+\\,-./0-9:;<=>;?A-Z_a-z[]{}|^@#~]+\x60$"`
	// Visa credit card  matches: 4123 6453 2222 1746  non-matches: 3124 5675 4400 4567, 4123-6453-2222-1746
	CreditCard *string `json:"creditCard,omitempty" validate:"regexp=^4[0-9]{3}\s[0-9]{4}\s[0-9]{4}\s[0-9]{4}$"`
	// Some dates  matches: 31/04/1999, 15/12/4567  non-matches: 31/4/1999, 31/4/99, 1999/04/19, 42/67/25456
	Date *string `json:"date,omitempty" validate:"regexp=^([0-2][0-9]|30|31)/(0[1-9]|1[0-2])/[0-9]{4}$"`
	// Windows absolute path  matches: \\\\server\\share\\file  non-matches: \\directory\\directory2, /directory2
	WindowsAbsolutePath *string `json:"windowsAbsolutePath,omitempty" validate:"regexp=^([A-Za-z]:|\)\[[:alnum:][:whitespace:]!"`
	// Email Address 1  matches: [email protected], [email protected]  non-matches: abc@dummy, ab*[email protected]
	Email1 *string `json:"email1,omitempty" validate:"regexp=^[[:word:]\-.]+@[[:word:]\-.]+\.[[:alpha:]]{2\\,3}$"`
	// Email Address 2  matches: *@[email protected], __1234^%@@abc.def.ghijkl  non-matches: abc.123.*&ca, ^%abcdefg123
	Email2 *string `json:"email2,omitempty" validate:"regexp=^.+@.+\..+$"`
	// HTML Hexadecimal Color Code 1  matches: AB1234, CCCCCC, 12AF3B  non-matches: 123G45, 12-44-CC
	HtmlHexadecimalColorCode1 *string `json:"htmlHexadecimalColorCode1,omitempty" validate:"regexp=^[A-F0-9]{6}$"`
	// HTML Hexadecimal Color Code 2  matches: AB 11 00, CC 12 D3  non-matches: SS AB CD, AA BB CC DD, 1223AB
	HtmlHexadecimalColorCode2 *string `json:"htmlHexadecimalColorCode2,omitempty" validate:"regexp=^[A-F0-9]{2}\s[A-F0-9]{2}\s[A-F0-9]{2}$"`
	// IP Address  matches: 10.25.101.216  non-matches: 0.0.0, 256.89.457.02
	IpAddress *string `json:"ipAddress,omitempty" validate:"regexp=^((2(5[0-5]|[0-4][0-9])|1([0-9][0-9])|([1-9][0-9])|[0-9])\.){3}(2(5[0-5]|[0-4][0-9])|1([0-9][0-9])|([1-9][0-9])|[0-9])$"`
	// Java Comments  matches: Matches Java comments that are between /_* and *_/, or one line comments prefaced by //  non-matches: a=1
	JavaComments *string `json:"javaComments,omitempty" validate:"regexp=^/\*.*\*/|//[^
]*$"`
	//   matches: $1.00, -$97.65 non-matches: $1, 1.00$, $-75.17
	Money *string `json:"money,omitempty" validate:"regexp=^(\+|-)?\$[0-9]*\.[0-9]{2}$"`
	// Positive, negative numbers, and decimal values  matches: +41, -412, 2, 7968412, 41, +41.1, -3.141592653 non-matches: ++41, 41.1.19, -+97.14
	PositiveNegativeDecimalValue *string `json:"positiveNegativeDecimalValue,omitempty" validate:"regexp=^(\+|-)?[0-9]+(\.[0-9]+)?$"`
	// Passwords 1  matches: abcd, 1234, A1b2C3d4, 1a2B3  non-matches: abc, *ab12, abcdefghijkl
	Password1 *string `json:"password1,omitempty" validate:"regexp=^[[:alnum:]]{4\\,10}$"`
	// Passwords 2  matches: AB_cd, A1_b2c3, a123_  non-matches: *&^g, abc, 1bcd
	Password2 *string `json:"password2,omitempty" validate:"regexp=^[a-zA-Z]\w{3\\,7}$"`
	// Phone Numbers  matches: 519-883-6898, 519 888 6898  non-matches: 888 6898, 5198886898, 519 883-6898
	PhoneNumber *string `json:"phoneNumber,omitempty" validate:"regexp=^([2-9][0-9]{2}-[2-9][0-9]{2}-[0-9]{4})|([2-9][0-9]{2}\s[2-9][0-9]{2}\s[0-9]{4})$"`
	// Sentences 1  matches: Hello, how are you?  non-matches: i am fine
	Sentence1 *string `json:"sentence1,omitempty" validate:"regexp=^[A-Z0-9].*(\.|\?|!)$"`
	// Sentences 2  matches: Hello, how are you?n non-matches: i am fine
	Sentence2 *string `json:"sentence2,omitempty" validate:"regexp=^[[:upper:]0-9].*[.?!]$"`
	// Social Security Number  matches: 123-45-6789  non-matches: 123 45 6789, 123456789, 1234-56-7891
	SocialSecurityNumber *string `json:"socialSecurityNumber,omitempty" validate:"regexp=^[0-9]{3}-[0-9]{2}-[0-9]{4}$"`
	// URL  matches: http://www.sample.com, www.sample.com  non-matches: http://sample.com, http://www.sample.comm
	Url *string `json:"url,omitempty" validate:"regexp=^(http://)?www\.[a-zA-Z0-9]+\.[a-zA-Z]{2\\,3}$"`
}
// [...]
$ go vet ./...
# github.com/GIT_USER_ID/GIT_REPO_ID
./model_import_code.go:26:2: struct field tag `json:"creditCard,omitempty" validate:"regexp=^4[0-9]{3}\s[0-9]{4}\s[0-9]{4}\s[0-9]{4}$"` not compatible with reflect.StructTag.Get: bad syntax for struct tag value
./model_import_code.go:30:2: struct field tag `json:"windowsAbsolutePath,omitempty" validate:"regexp=^([A-Za-z]:|\)\[[:alnum:][:whitespace:]!"` not compatible with reflect.StructTag.Get: bad syntax for struct tag value
./model_import_code.go:32:2: struct field tag `json:"email1,omitempty" validate:"regexp=^[[:word:]\-.]+@[[:word:]\-.]+\.[[:alpha:]]{2\\,3}$"` not compatible with reflect.StructTag.Get: bad syntax for struct tag value
./model_import_code.go:34:2: struct field tag `json:"email2,omitempty" validate:"regexp=^.+@.+\..+$"` not compatible with reflect.StructTag.Get: bad syntax for struct tag value
./model_import_code.go:38:2: struct field tag `json:"htmlHexadecimalColorCode2,omitempty" validate:"regexp=^[A-F0-9]{2}\s[A-F0-9]{2}\s[A-F0-9]{2}$"` not compatible with reflect.StructTag.Get: bad syntax for struct tag value
./model_import_code.go:40:2: struct field tag `json:"ipAddress,omitempty" validate:"regexp=^((2(5[0-5]|[0-4][0-9])|1([0-9][0-9])|([1-9][0-9])|[0-9])\.){3}(2(5[0-5]|[0-4][0-9])|1([0-9][0-9])|([1-9][0-9])|[0-9])$"` not compatible with reflect.StructTag.Get: bad syntax for struct tag value
./model_import_code.go:42:2: struct field tag "json:\"javaComments,omitempty\" validate:\"regexp=^/\\*.*\\*/|//[^\n]*$\"" not compatible with reflect.StructTag.Get: bad syntax for struct tag value
./model_import_code.go:45:2: struct field tag `json:"money,omitempty" validate:"regexp=^(\+|-)?\$[0-9]*\.[0-9]{2}$"` not compatible with reflect.StructTag.Get: bad syntax for struct tag value
./model_import_code.go:47:2: struct field tag `json:"positiveNegativeDecimalValue,omitempty" validate:"regexp=^(\+|-)?[0-9]+(\.[0-9]+)?$"` not compatible with reflect.StructTag.Get: bad syntax for struct tag value
./model_import_code.go:51:2: struct field tag `json:"password2,omitempty" validate:"regexp=^[a-zA-Z]\w{3\\,7}$"` not compatible with reflect.StructTag.Get: bad syntax for struct tag value
./model_import_code.go:53:2: struct field tag `json:"phoneNumber,omitempty" validate:"regexp=^([2-9][0-9]{2}-[2-9][0-9]{2}-[0-9]{4})|([2-9][0-9]{2}\s[2-9][0-9]{2}\s[0-9]{4})$"` not compatible with reflect.StructTag.Get: bad syntax for struct tag value
./model_import_code.go:55:2: struct field tag `json:"sentence1,omitempty" validate:"regexp=^[A-Z0-9].*(\.|\?|!)$"` not compatible with reflect.StructTag.Get: bad syntax for struct tag value
./model_import_code.go:61:2: struct field tag `json:"url,omitempty" validate:"regexp=^(http://)?www\.[a-zA-Z0-9]+\.[a-zA-Z]{2\\,3}$"` not compatible with reflect.StructTag.Get: bad syntax for struct tag value

So the handling of that issue isn't finished.
Eventually, I would like a go test case to succeed in matching/non-matching what the SAP's help pages says.

@mlebihan
Copy link
Contributor

mlebihan commented Dec 21, 2024

The last push in my fork is now able to accept a go vet ./..., provided that most of \ are already escaped like \\ (but not \\ to \\\\, the generator will do it automatically)

The problem to pass is that before the AbstractGoCodegen::postProcessModels(ModelsMap objs) is called, far before, a compat io.swagger module is called to validate the regex yaml content, and it fails if any \ isn't escaped.

It's boring, because when one sets for regex [[:word:]\-.]+@[[:word:]\-.]+\.[[:alpha:]]{2,3} to validate an email, for example, it's that one that he wants, that one that it was able to validate on any online regexp validator tool.
And he isn't willing at all to enter it as ^[[:word:]\\-.]+@[[:word:]\\-.]+\\.[[:alpha:]]{2,3}$ which is clumsy. That's the work of the generator to do so, but yet I see no way to achieve that. So escaping \ to  \\ in yaml pattern parameter is mandatory.

Now, this yaml:

openapi: '3.0.3'
info:
  title: API Test
  version: '1.0'
paths:
  /foo:
    get:
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  code:
                    oneOf:
                      - $ref: "#/components/schemas/importCode"

components:
  schemas:
    importCode:
      type: object
      properties:
        code:
          type: string
          pattern: "^[0-9]{2,}$"

        creditCard:
          description: "Visa credit card\n
            matches: 4123 6453 2222 1746\n
            non-matches: 3124 5675 4400 4567, 4123-6453-2222-1746"
          type: string

          pattern: "^4[0-9]{3}\\s[0-9]{4}\\s[0-9]{4}\\s[0-9]{4}$"
          # Original was: 4[0-9]{3}\s[0-9]{4}\s[0-9]{4}\s[0-9]{4}

        date:
          description: "Some dates\n
            matches: 31/04/1999, 15/12/4567\n
            non-matches: 31/4/1999, 31/4/99, 1999/04/19, 42/67/25456"
          type: string
          pattern: "^([0-2][0-9]|30|31)/(0[1-9]|1[0-2])/[0-9]{4}$"
          # Original was: ([0-2][0-9]|30|31)/(0[1-9]|1[0-2])/[0-9]{4} : unchanged

        windowsAbsolutePath:
          description: "Windows absolute path\n
            matches: \\\\server\\share\\file\n
            non-matches: \\directory\\directory2, /directory2"
          type: string

          # This test case doesn't work due to a problem (?) in validator.v2 (?)
          # it issues an unexpected unknown tag or Bad Parameter.

          # pattern: "^([A-Za-z]:|\\)\\[[:alnum:][:whitespace:]!\"#$%&'()+,-.;=@[]^_`{}~.]*$"
          # Original was: ([A-Za-z]:|\\)\\[[:alnum:][:whitespace:]!"#$%&'()+,-.\\;=@\[\]^_`{}~.]*

        email1:
          description: "Email Address 1\n
            matches: [email protected], [email protected]\n
            non-matches: abc@dummy, ab*[email protected]"
          type: string

          pattern: "^[[:word:]\\-.]+@[[:word:]\\-.]+\\.[[:alpha:]]{2,3}$"
          # Original was: [[:word:]\-.]+@[[:word:]\-.]+\.[[:alpha:]]{2,3}

        email2:
          description: "Email Address 2\n
            matches: *@[email protected], __1234^%@@abc.def.ghijkl\n
            non-matches: abc.123.*&ca, ^%abcdefg123"
          type: string

          pattern: "^.+@.+\\..+$"
          # Original was: .+@.+\..+

        htmlHexadecimalColorCode1:
          description: "HTML Hexadecimal Color Code 1\n
            matches: AB1234, CCCCCC, 12AF3B\n
            non-matches: 123G45, 12-44-CC"
          type: string
          pattern: "^[A-F0-9]{6}$"
          # Original was: [A-F0-9]{6} : unchanged

        htmlHexadecimalColorCode2:
          description: "HTML Hexadecimal Color Code 2\n
            matches: AB 11 00, CC 12 D3\n
            non-matches: SS AB CD, AA BB CC DD, 1223AB"
          type: string

          pattern: "^[A-F0-9]{2}\\s[A-F0-9]{2}\\s[A-F0-9]{2}$"
          # Original was: [A-F0-9]{2}\s[A-F0-9]{2}\s[A-F0-9]{2}

        ipAddress:
          description: "IP Address\n
            matches: 10.25.101.216\n
            non-matches: 0.0.0, 256.89.457.02"
          type: string

          pattern: "^((2(5[0-5]|[0-4][0-9])|1([0-9][0-9])|([1-9][0-9])|[0-9])\\.){3}(2(5[0-5]|[0-4][0-9])|1([0-9][0-9])|([1-9][0-9])|[0-9])$"
          # Original was: ((2(5[0-5]|[0-4][0-9])|1([0-9][0-9])|([1-9][0-9])|[0-9])\.){3}(2(5[0-5]|[0-4][0-9])|1([0-9][0-9])|([1-9][0-9])|[0-9])

        javaComments:
          description: "Java Comments\n
            matches: Matches Java comments that are between /* and */, or one line comments prefaced by //\n
            non-matches: a=1"
          type: string

          # This test case doesn't work due to a problem (?) in validator.v2 (?)
          # org.yaml.snakeyaml.scanner declares \* being an invalid escape code at yaml checking step

          # pattern: "^/\*.*\*/|//[^\\n]*$"
          # Original was: /\*.*\*/|//[^\n]*

        money:
          description: "\n
            matches: $1.00, -$97.65
            non-matches: $1, 1.00$, $-75.17"
          type: string

          pattern: "^(\\+|-)?\\$[0-9]*\\.[0-9]{2}$"
          # Original was: (\+|-)?\$[0-9]*\.[0-9]{2}

        positiveNegativeDecimalValue:
          description: "Positive, negative numbers, and decimal values\n
            matches: +41, -412, 2, 7968412, 41, +41.1, -3.141592653 
            non-matches: ++41, 41.1.19, -+97.14"
          type: string

          pattern: "^(\\+|-)?[0-9]+(\\.[0-9]+)?$"
          # Original was: (\+|-)?[0-9]+(\.[0-9]+)?

        password1:
          description: "Passwords 1\n
            matches: abcd, 1234, A1b2C3d4, 1a2B3\n
            non-matches: abc, *ab12, abcdefghijkl"
          type: string
          pattern: "^[[:alnum:]]{4,10}$"
          # Original was: [[:alnum:]]{4,10} : unchanged

        password2:
          description: "Passwords 2\n
            matches: AB_cd, A1_b2c3, a123_\n
            non-matches: *&^g, abc, 1bcd"
          type: string

          pattern: "^[a-zA-Z]\\w{3,7}$"
          # Original was: [a-zA-Z]\w{3,7} : unchanged

        phoneNumber:
          description: "Phone Numbers\n
            matches: 519-883-6898, 519 888 6898\n
            non-matches: 888 6898, 5198886898, 519 883-6898"
          type: string

          pattern: "^([2-9][0-9]{2}-[2-9][0-9]{2}-[0-9]{4})|([2-9][0-9]{2}\\s[2-9][0-9]{2}\\s[0-9]{4})$"
          # Original was: ([2-9][0-9]{2}-[2-9][0-9]{2}-[0-9]{4})|([2-9][0-9]{2}\s[2-9][0-9]{2}\s[0-9]{4})

        sentence1:
          description: "Sentences 1\n
              matches: Hello, how are you?\n
              non-matches: i am fine"
          type: string

          pattern: "^[A-Z0-9].*(\\.|\\?|!)$"
          # Original was: [A-Z0-9].*(\.|\?|!)

        sentence2:
          description: "Sentences 2\n
              matches: Hello, how are you?n
              non-matches: i am fine"
          type: string
          pattern: "^[[:upper:]0-9].*[.?!]$"
          # Original was: [[:upper:]0-9].*[.?!] : unchanged

        socialSecurityNumber:
          description: "Social Security Number\n
              matches: 123-45-6789\n
              non-matches: 123 45 6789, 123456789, 1234-56-7891"
          type: string
          pattern: "^[0-9]{3}-[0-9]{2}-[0-9]{4}$"
          # Original was: [0-9]{3}-[0-9]{2}-[0-9]{4} : unchanged

        url:
          description: "URL\n
              matches: http://www.sample.com, www.sample.com\n
              non-matches: http://sample.com, http://www.sample.comm"
          type: string

          # \. ==> \\.
          pattern: "^(http://)?www\\.[a-zA-Z0-9]+\\.[a-zA-Z]{2,3}$"
          # Original was: (http://)?www\.[a-zA-Z0-9]+\.[a-zA-Z]{2,3}

generates this (considering two issues remaining with test cases, see further comment):

type ImportCode struct {
	Code *string `json:"code,omitempty" validate:"regexp=^[0-9]{2\\,}$"`
	// Visa credit card  matches: 4123 6453 2222 1746  non-matches: 3124 5675 4400 4567, 4123-6453-2222-1746
	CreditCard *string `json:"creditCard,omitempty" validate:"regexp=^4[0-9]{3}\\s[0-9]{4}\\s[0-9]{4}\\s[0-9]{4}$"`
	// Some dates  matches: 31/04/1999, 15/12/4567  non-matches: 31/4/1999, 31/4/99, 1999/04/19, 42/67/25456
	Date *string `json:"date,omitempty" validate:"regexp=^([0-2][0-9]|30|31)/(0[1-9]|1[0-2])/[0-9]{4}$"`
	// Windows absolute path  matches: \\\\server\\share\\file  non-matches: \\directory\\directory2, /directory2
	WindowsAbsolutePath *string `json:"windowsAbsolutePath,omitempty"`
	// Email Address 1  matches: [email protected], [email protected]  non-matches: abc@dummy, ab*[email protected]
	Email1 *string `json:"email1,omitempty" validate:"regexp=^[[:word:]\\-.]+@[[:word:]\\-.]+\\.[[:alpha:]]{2\\,3}$"`
	// Email Address 2  matches: *@[email protected], __1234^%@@abc.def.ghijkl  non-matches: abc.123.*&ca, ^%abcdefg123
	Email2 *string `json:"email2,omitempty" validate:"regexp=^.+@.+\\..+$"`
	// HTML Hexadecimal Color Code 1  matches: AB1234, CCCCCC, 12AF3B  non-matches: 123G45, 12-44-CC
	HtmlHexadecimalColorCode1 *string `json:"htmlHexadecimalColorCode1,omitempty" validate:"regexp=^[A-F0-9]{6}$"`
	// HTML Hexadecimal Color Code 2  matches: AB 11 00, CC 12 D3  non-matches: SS AB CD, AA BB CC DD, 1223AB
	HtmlHexadecimalColorCode2 *string `json:"htmlHexadecimalColorCode2,omitempty" validate:"regexp=^[A-F0-9]{2}\\s[A-F0-9]{2}\\s[A-F0-9]{2}$"`
	// IP Address  matches: 10.25.101.216  non-matches: 0.0.0, 256.89.457.02
	IpAddress *string `json:"ipAddress,omitempty" validate:"regexp=^((2(5[0-5]|[0-4][0-9])|1([0-9][0-9])|([1-9][0-9])|[0-9])\\.){3}(2(5[0-5]|[0-4][0-9])|1([0-9][0-9])|([1-9][0-9])|[0-9])$"`
	// Java Comments  matches: Matches Java comments that are between /_* and *_/, or one line comments prefaced by //  non-matches: a=1
	JavaComments *string `json:"javaComments,omitempty"`
	//   matches: $1.00, -$97.65 non-matches: $1, 1.00$, $-75.17
	Money *string `json:"money,omitempty" validate:"regexp=^(\\+|-)?\\$[0-9]*\\.[0-9]{2}$"`
	// Positive, negative numbers, and decimal values  matches: +41, -412, 2, 7968412, 41, +41.1, -3.141592653 non-matches: ++41, 41.1.19, -+97.14
	PositiveNegativeDecimalValue *string `json:"positiveNegativeDecimalValue,omitempty" validate:"regexp=^(\\+|-)?[0-9]+(\\.[0-9]+)?$"`
	// Passwords 1  matches: abcd, 1234, A1b2C3d4, 1a2B3  non-matches: abc, *ab12, abcdefghijkl
	Password1 *string `json:"password1,omitempty" validate:"regexp=^[[:alnum:]]{4\\,10}$"`
	// Passwords 2  matches: AB_cd, A1_b2c3, a123_  non-matches: *&^g, abc, 1bcd
	Password2 *string `json:"password2,omitempty" validate:"regexp=^[a-zA-Z]\\w{3\\,7}$"`
	// Phone Numbers  matches: 519-883-6898, 519 888 6898  non-matches: 888 6898, 5198886898, 519 883-6898
	PhoneNumber *string `json:"phoneNumber,omitempty" validate:"regexp=^([2-9][0-9]{2}-[2-9][0-9]{2}-[0-9]{4})|([2-9][0-9]{2}\\s[2-9][0-9]{2}\\s[0-9]{4})$"`
	// Sentences 1  matches: Hello, how are you?  non-matches: i am fine
	Sentence1 *string `json:"sentence1,omitempty" validate:"regexp=^[A-Z0-9].*(\\.|\\?|!)$"`
	// Sentences 2  matches: Hello, how are you?n non-matches: i am fine
	Sentence2 *string `json:"sentence2,omitempty" validate:"regexp=^[[:upper:]0-9].*[.?!]$"`
	// Social Security Number  matches: 123-45-6789  non-matches: 123 45 6789, 123456789, 1234-56-7891
	SocialSecurityNumber *string `json:"socialSecurityNumber,omitempty" validate:"regexp=^[0-9]{3}-[0-9]{2}-[0-9]{4}$"`
	// URL  matches: http://www.sample.com, www.sample.com  non-matches: http://sample.com, http://www.sample.comm
	Url *string `json:"url,omitempty" validate:"regexp=^(http://)?www\\.[a-zA-Z0-9]+\\.[a-zA-Z]{2\\,3}$"`
}

That is accepted. I have to write a test now to ensure that all these regexp are matching the samples they shall and not matching those they shouldn't.

mlebihan added a commit to mlebihan/openapi-generator that referenced this issue Dec 23, 2024
mlebihan added a commit to mlebihan/openapi-generator that referenced this issue Dec 24, 2024
@mlebihan
Copy link
Contributor

Submitted as PR, with the following mentions:

In a struct, for a type: string accompanied by a pattern, these generated Go directives are corrected:

json:"creditCard,omitempty" validate:"regexp=^4[0-9]{3}\\s[0-9]{4}\\s[0-9]{4}\\s[0-9]{4}$"` 

Test cases added

18 new regular expressions has been taken for test cases, from an SAP help page.

A go test has been run with various values, and it succeeds except for one test case: windowsAbsolutePath that might encounter a go validator.v2 package internal trouble.

Tests are in modules/openapi-generator/src/test/resources/3_0:
ls modules/openapi-generator/src/test/resources/3_0/issue_20079_*

issue_20079_go_regex_wrongly_translated.yaml

# For testing purpose
issue_20079_api_default_test.go                               # In the sample, replace test/api_default_test.go by this one
issue_20079_matching01.json                                   # Copy these candidates json files along
issue_20079_matching02.json
issue_20079_matching03.json
issue_20079_matching04.json
issue_20079_non_matching01.json
issue_20079_non_matching02.json
issue_20079_non_matching03.json
issue_20079_non_matching04.json

The following command should then show the success:

go mod tidy && go vet ./... && go test ./... -v

Remaining problem

This fix mostly fixes the handling of regular expressions with Go.
But there's still two problems remaining, that will require other issues to be handled 👍

  1. For javaComments test case, when it should use the ^/\*.*\*/|//[^\\n]*$ pattern, the org.yaml.snakeyaml.scanner parser that scans the spec file, wrongly tells that \* is an invalid escape code.
    But attempting to enter ^/\\*.*\\*/|//[^\\n]*$ keeps the \\* and generates ^/\\*.*\\*/|//[^\n]*$ instead of the wished ^/\*.*\*/|//[^\n]*$

  2. For windowsAbsolutePath test case, Go validator.package v2 doesn't look handling this regexp well, and tell it having a bad parameter.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

2 participants