Repository: swaggest/openapi-go (go)

Issue:
req/resp struct using generic could cause semantic error in openapi output
**Describe the bug**
If req/resp struct using generic, like `type req[T any] struct {}`, generated openapi output will contains schema with name like `Req[Blabla]`, and Swagger Editor complains it as semantic error


**To Reproduce**
Take the example code in README, I modify req/resp to use generic:

```go
package main

import (
	"fmt"
	"github.com/swaggest/openapi-go/openapi3"
	"log"
	"net/http"
	"time"
)

func handleError(err error) {
	if err != nil {
		panic(err)
	}
}

func main() {
	reflector := openapi3.Reflector{}
	reflector.Spec = &openapi3.Spec{Openapi: "3.0.3"}
	reflector.Spec.Info.
		WithTitle("Things API").
		WithVersion("1.2.3").
		WithDescription("Put something here")

	type req[T any] struct {
		ID     string `path:"id" example:"XXX-XXXXX"`
		Locale string `query:"locale" pattern:"^[a-z]{2}-[A-Z]{2}$"`
		Title  string `json:"string"`
		Amount uint   `json:"amount"`
		Items  []struct {
			Count uint   `json:"count"`
			Name  string `json:"name"`
		} `json:"items"`
	}

	type resp[T any] struct {
		ID     string `json:"id" example:"XXX-XXXXX"`
		Amount uint   `json:"amount"`
		Items  []struct {
			Count uint   `json:"count"`
			Name  string `json:"name"`
		} `json:"items"`
		UpdatedAt time.Time `json:"updated_at"`
	}

	putOp := openapi3.Operation{}

	handleError(reflector.SetRequest(&putOp, new(req[time.Time]), http.MethodPut))
	handleError(reflector.SetJSONResponse(&putOp, new(resp[time.Time]), http.StatusOK))
	handleError(reflector.SetJSONResponse(&putOp, new([]resp[time.Time]), http.StatusConflict))
	handleError(reflector.Spec.AddOperation(http.MethodPut, "/things/{id}", putOp))

	getOp := openapi3.Operation{}

	handleError(reflector.SetRequest(&getOp, new(req[time.Time]), http.MethodGet))
	handleError(reflector.SetJSONResponse(&getOp, new(resp[time.Time]), http.StatusOK))
	handleError(reflector.Spec.AddOperation(http.MethodGet, "/things/{id}", getOp))

	schema, err := reflector.Spec.MarshalYAML()
	if err != nil {
		log.Fatal(err)
	}

	fmt.Println(string(schema))
}

```

which will output:

```yaml
openapi: 3.0.3
info:
  description: Put something here
  title: Things API
  version: 1.2.3
paths:
  /things/{id}:
    get:
      parameters:
      - in: query
        name: locale
        schema:
          pattern: ^[a-z]{2}-[A-Z]{2}$
          type: string
      - in: path
        name: id
        required: true
        schema:
          example: XXX-XXXXX
          type: string
      responses:
        "200":
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Resp[TimeTime]'
          description: OK
    put:
      parameters:
      - in: query
        name: locale
        schema:
          pattern: ^[a-z]{2}-[A-Z]{2}$
          type: string
      - in: path
        name: id
        required: true
        schema:
          example: XXX-XXXXX
          type: string
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/Req[TimeTime]'
      responses:
        "200":
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Resp[TimeTime]'
          description: OK
        "409":
          content:
            application/json:
              schema:
                items:
                  $ref: '#/components/schemas/Resp[TimeTime]'
                type: array
          description: Conflict
components:
  schemas:
    Req[TimeTime]:
      properties:
        amount:
          minimum: 0
          type: integer
        items:
          items:
            properties:
              count:
                minimum: 0
                type: integer
              name:
                type: string
            type: object
          nullable: true
          type: array
        string:
          type: string
      type: object
    Resp[TimeTime]:
      properties:
        amount:
          minimum: 0
          type: integer
        id:
          example: XXX-XXXXX
          type: string
        items:
          items:
            properties:
              count:
                minimum: 0
                type: integer
              name:
                type: string
            type: object
          nullable: true
          type: array
        updated_at:
          format: date-time
          type: string
      type: object

```

Then paste it to https://editor.swagger.io/, it will complain `Component names can only contain the characters A-Z a-z 0-9 - . _`, although req/resp displayed correctly

**Expected behavior**
Output schema name should not contain bracket

**Additional context**
Nothing else and thanks for your library ❤️


Pull request:
Sanitize schema definition names
Fixes #71.

This PR strips all invalid chars (non `a-zA-Z0-9.\-_`) from definition names.

Patch:
diff --git a/openapi3/reflect.go b/openapi3/reflect.go
index 62fad67..bab795c 100644
--- a/openapi3/reflect.go
+++ b/openapi3/reflect.go
@@ -8,6 +8,7 @@ import (
 	"mime/multipart"
 	"net/http"
 	"reflect"
+	"regexp"
 	"strconv"
 	"strings"
 
@@ -65,11 +66,11 @@ func (r *Reflector) NewOperationContext(method, pathPattern string) (openapi.Ope
 // Can be used in jsonschema.Schema IsTrivial().
 func (r *Reflector) ResolveJSONSchemaRef(ref string) (s jsonschema.SchemaOrBool, found bool) {
 	if r.Spec == nil || r.Spec.Components == nil || r.Spec.Components.Schemas == nil ||
-		!strings.HasPrefix(ref, "#/components/schemas/") {
+		!strings.HasPrefix(ref, componentsSchemas) {
 		return s, false
 	}
 
-	ref = strings.TrimPrefix(ref, "#/components/schemas/")
+	ref = strings.TrimPrefix(ref, componentsSchemas)
 	os, found := r.Spec.Components.Schemas.MapOfSchemaOrRefValues[ref]
 
 	if found {
@@ -273,6 +274,8 @@ const (
 	mimeJSON           = "application/json"
 	mimeFormUrlencoded = "application/x-www-form-urlencoded"
 	mimeMultipart      = "multipart/form-data"
+
+	componentsSchemas = "#/components/schemas/"
 )
 
 func mediaType(format string) MediaType {
@@ -357,10 +360,11 @@ func (r *Reflector) parseRequestBody(
 
 	schema, err := r.Reflect(input,
 		openapi.WithOperationCtx(oc, false, "body"),
-		jsonschema.DefinitionsPrefix("#/components/schemas/"+definitionPrefix),
+		jsonschema.DefinitionsPrefix(componentsSchemas+definitionPrefix),
 		jsonschema.RootRef,
 		jsonschema.PropertyNameMapping(mapping),
 		jsonschema.PropertyNameTag(tag, additionalTags...),
+		sanitizeDefName,
 		jsonschema.InterceptSchema(func(params jsonschema.InterceptSchemaParams) (stop bool, err error) {
 			vv := params.Value.Interface()
 
@@ -443,7 +447,7 @@ func (r *Reflector) parseParametersIn(
 	}
 
 	defNamePrefix := strings.Title(string(in))
-	definitionsPrefix := "#/components/schemas/" + defNamePrefix
+	definitionsPrefix := componentsSchemas + defNamePrefix
 
 	s, err := r.Reflect(input,
 		openapi.WithOperationCtx(oc, false, in),
@@ -454,6 +458,7 @@ func (r *Reflector) parseParametersIn(
 		func(rc *jsonschema.ReflectContext) {
 			rc.UnnamedFieldWithTag = true
 		},
+		sanitizeDefName,
 		jsonschema.SkipEmbeddedMapsSlices,
 		jsonschema.InterceptProp(func(params jsonschema.InterceptPropParams) error {
 			if !params.Processed || len(params.Path) > 1 {
@@ -500,6 +505,7 @@ func (r *Reflector) parseParametersIn(
 					jsonschema.DefinitionsPrefix(definitionsPrefix),
 					jsonschema.CollectDefinitions(r.collectDefinition(defNamePrefix)),
 					jsonschema.RootRef,
+					sanitizeDefName,
 				)
 				if err != nil {
 					return err
@@ -512,7 +518,9 @@ func (r *Reflector) parseParametersIn(
 			} else {
 				ps, err := r.Reflect(reflect.New(field.Type).Interface(),
 					openapi.WithOperationCtx(oc, false, in),
-					jsonschema.InlineRefs)
+					jsonschema.InlineRefs,
+					sanitizeDefName,
+				)
 				if err != nil {
 					return err
 				}
@@ -562,6 +570,14 @@ func (r *Reflector) parseParametersIn(
 	return nil
 }
 
+var defNameSanitizer = regexp.MustCompile(`[^a-zA-Z0-9.\-_]+`)
+
+func sanitizeDefName(rc *jsonschema.ReflectContext) {
+	rc.DefName = func(t reflect.Type, defaultDefName string) string {
+		return defNameSanitizer.ReplaceAllString(defaultDefName, "")
+	}
+}
+
 func (r *Reflector) collectDefinition(namePrefix string) func(name string, schema jsonschema.Schema) {
 	return func(name string, schema jsonschema.Schema) {
 		name = namePrefix + name
@@ -592,6 +608,7 @@ func (r *Reflector) parseResponseHeader(resp *Response, oc openapi.OperationCont
 		jsonschema.InlineRefs,
 		jsonschema.PropertyNameMapping(mapping),
 		jsonschema.PropertyNameTag(tagHeader),
+		sanitizeDefName,
 		jsonschema.InterceptProp(func(params jsonschema.InterceptPropParams) error {
 			if !params.Processed || len(params.Path) > 1 { // only top-level fields (including embedded).
 				return nil
@@ -640,7 +657,7 @@ func (r *Reflector) parseResponseHeader(resp *Response, oc openapi.OperationCont
 }
 
 func (r *Reflector) hasJSONBody(output interface{}) (bool, error) {
-	schema, err := r.Reflect(output)
+	schema, err := r.Reflect(output, sanitizeDefName)
 	if err != nil {
 		return false, err
 	}
@@ -736,8 +753,9 @@ func (r *Reflector) parseJSONResponse(resp *Response, oc openapi.OperationContex
 	schema, err := r.Reflect(output,
 		openapi.WithOperationCtx(oc, true, openapi.InBody),
 		jsonschema.RootRef,
-		jsonschema.DefinitionsPrefix("#/components/schemas/"),
+		jsonschema.DefinitionsPrefix(componentsSchemas),
 		jsonschema.CollectDefinitions(r.collectDefinition("")),
+		sanitizeDefName,
 	)
 	if err != nil {
 		return err


Test patch:
diff --git a/openapi3/reflect_go1.18_test.go b/openapi3/reflect_go1.18_test.go
new file mode 100644
index 0000000..adad181
--- /dev/null
+++ b/openapi3/reflect_go1.18_test.go
@@ -0,0 +1,166 @@
+//go:build go1.18
+// +build go1.18
+
+package openapi3_test
+
+import (
+	"net/http"
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/require"
+	"github.com/swaggest/assertjson"
+	"github.com/swaggest/openapi-go"
+	"github.com/swaggest/openapi-go/openapi3"
+)
+
+func Test_Foo(t *testing.T) {
+	reflector := openapi3.Reflector{}
+	reflector.Spec = &openapi3.Spec{Openapi: "3.0.3"}
+	reflector.Spec.Info.
+		WithTitle("Things API").
+		WithVersion("1.2.3").
+		WithDescription("Put something here")
+
+	type req[T any] struct {
+		ID     string `path:"id" example:"XXX-XXXXX"`
+		Locale string `query:"locale" pattern:"^[a-z]{2}-[A-Z]{2}$"`
+		Title  string `json:"string"`
+		Amount uint   `json:"amount"`
+		Items  []struct {
+			Count uint   `json:"count"`
+			Name  string `json:"name"`
+		} `json:"items"`
+	}
+
+	type resp[T any] struct {
+		ID     string `json:"id" example:"XXX-XXXXX"`
+		Amount uint   `json:"amount"`
+		Items  []struct {
+			Count uint   `json:"count"`
+			Name  string `json:"name"`
+		} `json:"items"`
+		UpdatedAt time.Time `json:"updated_at"`
+	}
+
+	putOC, err := reflector.NewOperationContext(http.MethodPut, "/things/{id}")
+	require.NoError(t, err)
+	putOC.AddReqStructure(new(req[time.Time]))
+	putOC.AddRespStructure(new(resp[time.Time]), func(cu *openapi.ContentUnit) { cu.HTTPStatus = http.StatusOK })
+	putOC.AddRespStructure(new([]resp[time.Time]), func(cu *openapi.ContentUnit) { cu.HTTPStatus = http.StatusConflict })
+	require.NoError(t, reflector.AddOperation(putOC))
+
+	getOC, err := reflector.NewOperationContext(http.MethodGet, "/things/{id}")
+	require.NoError(t, err)
+	getOC.AddReqStructure(new(req[time.Time]))
+	getOC.AddRespStructure(new(resp[time.Time]), func(cu *openapi.ContentUnit) { cu.HTTPStatus = http.StatusOK })
+	require.NoError(t, reflector.AddOperation(getOC))
+
+	assertjson.EqMarshal(t, `{
+	  "openapi":"3.0.3",
+	  "info":{"title":"Things API","description":"Put something here","version":"1.2.3"},
+	  "paths":{
+		"/things/{id}":{
+		  "get":{
+			"parameters":[
+			  {
+				"name":"locale","in":"query",
+				"schema":{"pattern":"^[a-z]{2}-[A-Z]{2}$","type":"string"}
+			  },
+			  {
+				"name":"id","in":"path","required":true,
+				"schema":{"type":"string","example":"XXX-XXXXX"}
+			  }
+			],
+			"responses":{
+			  "200":{
+				"description":"OK",
+				"content":{
+				  "application/json":{
+					"schema":{"$ref":"#/components/schemas/Openapi3TestRespTimeTime"}
+				  }
+				}
+			  }
+			}
+		  },
+		  "put":{
+			"parameters":[
+			  {
+				"name":"locale","in":"query",
+				"schema":{"pattern":"^[a-z]{2}-[A-Z]{2}$","type":"string"}
+			  },
+			  {
+				"name":"id","in":"path","required":true,
+				"schema":{"type":"string","example":"XXX-XXXXX"}
+			  }
+			],
+			"requestBody":{
+			  "content":{
+				"application/json":{"schema":{"$ref":"#/components/schemas/Openapi3TestReqTimeTime"}}
+			  }
+			},
+			"responses":{
+			  "200":{
+				"description":"OK",
+				"content":{
+				  "application/json":{
+					"schema":{"$ref":"#/components/schemas/Openapi3TestRespTimeTime"}
+				  }
+				}
+			  },
+			  "409":{
+				"description":"Conflict",
+				"content":{
+				  "application/json":{
+					"schema":{
+					  "type":"array",
+					  "items":{"$ref":"#/components/schemas/Openapi3TestRespTimeTime"}
+					}
+				  }
+				}
+			  }
+			}
+		  }
+		}
+	  },
+	  "components":{
+		"schemas":{
+		  "Openapi3TestReqTimeTime":{
+			"type":"object",
+			"properties":{
+			  "amount":{"minimum":0,"type":"integer"},
+			  "items":{
+				"type":"array",
+				"items":{
+				  "type":"object",
+				  "properties":{
+					"count":{"minimum":0,"type":"integer"},"name":{"type":"string"}
+				  }
+				},
+				"nullable":true
+			  },
+			  "string":{"type":"string"}
+			}
+		  },
+		  "Openapi3TestRespTimeTime":{
+			"type":"object",
+			"properties":{
+			  "amount":{"minimum":0,"type":"integer"},
+			  "id":{"type":"string","example":"XXX-XXXXX"},
+			  "items":{
+				"type":"array",
+				"items":{
+				  "type":"object",
+				  "properties":{
+					"count":{"minimum":0,"type":"integer"},"name":{"type":"string"}
+				  }
+				},
+				"nullable":true
+			  },
+			  "updated_at":{"type":"string","format":"date-time"}
+			}
+		  }
+		}
+	  }
+	}`, reflector.Spec)
+}