Repository: go-swagger/go-swagger
Pull request #3042 (2024-01-03 15:54:11)

feat(gen client): added support to work with multiple mimes
* fixes https://github.com/go-swagger/go-swagger/issues/2773

1. fix: no longer sort producers and consumers
   The client runtime picks the first one, so the order is relevant.

2. feat: added extra options to set media type
   For client codegen only, AND whenever a single group of operations
   deals with more than one media for producers OR more than one media
   for consumers , THEN we generate an extra set of convenience ClientOption
   functions to switch media type.

   Generates generic options to set the mime as an arbitrary string
   (comes in handy to set mime extensions):

   WithContentType(string) (to negotiate consumers)
   WithAccept(string) (to negotiate producers)

   Generate shorthands with a mangled name such as:
	`WithContentTypeApplicationxWwwFormUrlencoded`
   to be used directly as an option.

* Simple APIs with a single producer and a single consumer are not
affected.
* Server codegen is not affected

* test: content type is not sorted
* test: asserted codegen

> NOTE: see https://github.com/go-swagger/go-swagger/issues/2773 on github issues for a complete analysis.
> The part of that issue relative to the runtime client not supporting
> file upload with application/x-www-form-urlencoded is obviously not
> fixed by this PR.

Signed-off-by: Frédéric BIDON <fredbi@yahoo.com>

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/go-swagger/go-swagger/pull/3042?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=go-swagger) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Comparison is base [(`876c950`)](https://app.codecov.io/gh/go-swagger/go-swagger/commit/876c950f2cbfc883323e954df8c85938dd4b32a0?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=go-swagger) 0.00% compared to head [(`0b235d0`)](https://app.codecov.io/gh/go-swagger/go-swagger/pull/3042?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=go-swagger) 0.00%.

> :exclamation: Current head 0b235d0 differs from pull request most recent head 353609c. Consider uploading reports for the commit 353609c to get more accurate results


<details><summary>Additional details and impacted files</summary>


```diff
@@      Coverage Diff       @@
##   master   #3042   +/-   ##
==============================
==============================

```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/go-swagger/go-swagger/pull/3042?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=go-swagger).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=go-swagger).


Patch:
diff --git a/fixtures/bugs/2773/2773.yaml b/fixtures/bugs/2773/2773.yaml
new file mode 100644
index 00000000..fb4a49c4
--- /dev/null
+++ b/fixtures/bugs/2773/2773.yaml
@@ -0,0 +1,32 @@
+swagger: "2.0"
+info:
+  description: Example server uploading a file
+  version: "1.0.0"
+  title: File Upload
+basePath: /
+schemes:
+  - http
+consumes:
+  - application/json
+produces:
+  - application/octet-stream
+  - application/json
+
+paths:
+  /upload:
+    post:
+      tags:
+      - uploads
+      summary: uploads
+      operationId: uploadFile
+      consumes:
+      - multipart/form-data
+      - application/x-www-form-urlencoded
+      parameters:
+      - name: file
+        in: formData
+        type: file
+        required: true
+      responses:
+        "200":
+          description: OK
diff --git a/generator/media.go b/generator/media.go
index f9dad9fa..239926dc 100644
--- a/generator/media.go
+++ b/generator/media.go
@@ -71,6 +71,10 @@ func mediaMime(orig string) string {
 	return strings.SplitN(orig, ";", 2)[0]
 }
 
+func mediaGoName(media string) string {
+	return pascalize(strings.ReplaceAll(media, "*", "Star"))
+}
+
 func mediaParameters(orig string) string {
 	parts := strings.SplitN(orig, ";", 2)
 	if len(parts) < 2 {
diff --git a/generator/operation.go b/generator/operation.go
index 33484518..9587f118 100644
--- a/generator/operation.go
+++ b/generator/operation.go
@@ -428,10 +428,7 @@ func (b *codeGenOpBuilder) MakeOperation() (GenOperation, error) {
 	originalExtraSchemes := getExtraSchemes(operation.Extensions)
 
 	produces := producesOrDefault(operation.Produces, swsp.Produces, b.DefaultProduces)
-	sort.Strings(produces)
-
 	consumes := producesOrDefault(operation.Consumes, swsp.Consumes, b.DefaultConsumes)
-	sort.Strings(consumes)
 
 	var successResponse *GenResponse
 	for _, resp := range successResponses {
diff --git a/generator/structs.go b/generator/structs.go
index de765d76..14522888 100644
--- a/generator/structs.go
+++ b/generator/structs.go
@@ -520,6 +520,8 @@ type GenOperationGroup struct {
 	RootPackage    string
 	GenOpts        *GenOpts
 	PackageAlias   string
+
+	ClientOptions *GenClientOptions
 }
 
 // GenOperationGroups is a sorted collection of operation groups
@@ -807,3 +809,10 @@ type GenSecurityRequirements []GenSecurityRequirement
 func (g GenSecurityRequirements) Len() int           { return len(g) }
 func (g GenSecurityRequirements) Swap(i, j int)      { g[i], g[j] = g[j], g[i] }
 func (g GenSecurityRequirements) Less(i, j int) bool { return g[i].Name < g[j].Name }
+
+// GenClientOptions holds extra pieces of information
+// to generate a client.
+type GenClientOptions struct {
+	ProducesMediaTypes []string // filled with all producers if any method as more than 1
+	ConsumesMediaTypes []string // filled with all consumers if any method as more than 1
+}
diff --git a/generator/support.go b/generator/support.go
index cccc4603..d1c8c889 100644
--- a/generator/support.go
+++ b/generator/support.go
@@ -311,6 +311,9 @@ func (a *appGenerator) makeCodegenApp() (GenApp, error) {
 	log.Printf("planning operations (found: %d)", len(a.Operations))
 
 	genOps := make(GenOperations, 0, len(a.Operations))
+	consumesIndex := make(map[string][]string)
+	producesIndex := make(map[string][]string)
+
 	for operationName, opp := range a.Operations {
 		o := opp.Op
 		o.ID = operationName
@@ -363,6 +366,18 @@ func (a *appGenerator) makeCodegenApp() (GenApp, error) {
 		op.ReceiverName = receiver
 		op.Tags = tags // ordered tags for this operation, possibly filtered by CLI params
 
+		allConsumes := pruneEmpty(op.ConsumesMediaTypes)
+		if bldr.DefaultConsumes != "" {
+			allConsumes = append(allConsumes, bldr.DefaultConsumes)
+		}
+		consumesIndex[bldr.Name] = allConsumes
+
+		allProduces := pruneEmpty(op.ProducesMediaTypes)
+		if bldr.DefaultProduces != "" {
+			allProduces = append(allProduces, bldr.DefaultProduces)
+		}
+		producesIndex[bldr.Name] = allProduces
+
 		if !a.GenOpts.SkipTagPackages && tag != "" {
 			importPath := filepath.ToSlash(
 				path.Join(
@@ -397,6 +412,10 @@ func (a *appGenerator) makeCodegenApp() (GenApp, error) {
 	for k, v := range opsGroupedByPackage {
 		log.Printf("operations for package %q (found: %d)", k, len(v))
 		sort.Sort(v)
+
+		consumesInGroup := make([]string, 0, 2)
+		producesInGroup := make([]string, 0, 2)
+
 		// trim duplicate extra schemas within the same package
 		vv := make(GenOperations, 0, len(v))
 		seenExtraSchema := make(map[string]bool)
@@ -410,6 +429,9 @@ func (a *appGenerator) makeCodegenApp() (GenApp, error) {
 			}
 			op.ExtraSchemas = uniqueExtraSchemas
 			vv = append(vv, op)
+
+			consumesInGroup = concatUnique(consumesInGroup, consumesIndex[op.Name])
+			producesInGroup = concatUnique(producesInGroup, producesIndex[op.Name])
 		}
 		var pkg string
 		if len(vv) > 0 {
@@ -431,6 +453,19 @@ func (a *appGenerator) makeCodegenApp() (GenApp, error) {
 			RootPackage:    a.APIPackage,
 			GenOpts:        a.GenOpts,
 		}
+
+		if a.GenOpts.IsClient {
+			// generating extra options to switch media type in client
+			if len(consumesInGroup) > 1 || len(producesInGroup) > 1 {
+				sort.Strings(producesInGroup)
+				sort.Strings(consumesInGroup)
+				options := &GenClientOptions{
+					ProducesMediaTypes: producesInGroup,
+					ConsumesMediaTypes: consumesInGroup,
+				}
+				opGroup.ClientOptions = options
+			}
+		}
 		opGroups = append(opGroups, opGroup)
 	}
 	sort.Sort(opGroups)
diff --git a/generator/template_repo.go b/generator/template_repo.go
index 50c75b59..b4cd0897 100644
--- a/generator/template_repo.go
+++ b/generator/template_repo.go
@@ -93,6 +93,7 @@ func DefaultFuncMap(lang *LanguageOpts) template.FuncMap {
 		"inspect":          pretty.Sprint,
 		"cleanPath":        path.Clean,
 		"mediaTypeName":    mediaMime,
+		"mediaGoName":      mediaGoName,
 		"arrayInitializer": lang.arrayInitializer,
 		"hasPrefix":        strings.HasPrefix,
 		"stringContains":   strings.Contains,
diff --git a/generator/templates/client/client.gotmpl b/generator/templates/client/client.gotmpl
index 46d8c974..85a99637 100644
--- a/generator/templates/client/client.gotmpl
+++ b/generator/templates/client/client.gotmpl
@@ -65,9 +65,58 @@ type Client struct {
   formats strfmt.Registry
 }
 
-// ClientOption is the option for Client methods
+// ClientOption may be used to customize the behavior of Client methods.
 type ClientOption func(*runtime.ClientOperation)
 
+{{- with .ClientOptions }}{{/* use ad'hoc function mediaGoName rather than pascalize because of patterns with * */}}
+
+// This client is generated with a few options you might find useful for your swagger spec.
+//
+// Feel free to add you own set of options.
+  {{- if gt (len .ConsumesMediaTypes) 1 }}
+
+// WithContentType allows the client to force the Content-Type header
+// to negotiate a specific Consumer from the server.
+//
+// You may use this option to set arbitrary extensions to your MIME media type.
+func WithContentType(mime string) ClientOption {
+	return func(r *runtime.ClientOperation) {
+		r.ConsumesMediaTypes = []string{mime}
+	}
+}
+    {{ range .ConsumesMediaTypes  }}
+      {{- if not ( eq (mediaGoName .) ""  ) }}{{/* guard: in case garbled input produces a (conflicting) empty name */}}
+
+// WithContentType{{ mediaGoName . }} sets the Content-Type header to {{ printf "%q" . }}.
+func WithContentType{{ mediaGoName . }}(r *runtime.ClientOperation) {
+	r.ConsumesMediaTypes = []string{ {{ printf "%q" . }} }
+}
+      {{- end }}
+    {{- end }}
+  {{- end }}
+  {{- if gt (len .ProducesMediaTypes) 1 }}
+
+// WithAccept allows the client to force the Accept header
+// to negotiate a specific Producer from the server.
+//
+// You may use this option to set arbitrary extensions to your MIME media type.
+func WithAccept(mime string) ClientOption {
+	return func(r *runtime.ClientOperation) {
+		r.ProducesMediaTypes = []string{mime}
+	}
+}
+    {{ range .ProducesMediaTypes  }}
+      {{- if not ( eq (mediaGoName .) ""  ) }}{{/* guard: in case garbled input produces a (conflicting) empty name */}}
+
+// WithAccept{{ mediaGoName . }} sets the Accept header to {{ printf "%q" . }}.
+func WithAccept{{ mediaGoName . }}(r *runtime.ClientOperation) {
+	r.ProducesMediaTypes = []string{ {{ printf "%q" . }} }
+}
+      {{- end }}
+    {{- end }}
+  {{- end }}
+{{- end }}
+
 // ClientService is the interface for Client methods
 type ClientService interface {
 	{{ range .Operations }}
@@ -150,4 +199,4 @@ func (a *Client) {{ pascalize .Name }}(params *{{ pascalize .Name }}Params{{ if
 // SetTransport changes the transport on the client
 func (a *Client) SetTransport(transport runtime.ClientTransport) {
   a.transport = transport
-}
\ No newline at end of file
+}


Test patch:
diff --git a/generator/client_test.go b/generator/client_test.go
index ae57de8f..1ec56653 100644
--- a/generator/client_test.go
+++ b/generator/client_test.go
@@ -1220,3 +1220,84 @@ func TestGenClient_2590(t *testing.T) {
 		}
 	}
 }
+
+func TestGenClient_2773(t *testing.T) {
+	t.Parallel()
+	defer discardOutput()()
+
+	opts := testClientGenOpts()
+	opts.Spec = filepath.Join("..", "fixtures", "bugs", "2773", "2773.yaml")
+
+	cwd, err := os.Getwd()
+	require.NoError(t, err)
+	tft, err := os.MkdirTemp(cwd, "generated")
+	require.NoError(t, err)
+	opts.Target = tft
+
+	t.Cleanup(func() {
+		_ = os.RemoveAll(tft)
+	})
+
+	require.NoError(t,
+		GenerateClient("client", []string{}, []string{}, opts),
+	)
+
+	t.Run("generated operation should keep content type in the specified order", func(t *testing.T) {
+		fixtureConfig := map[string][]string{
+			"client/uploads/uploads_client.go": { // generated file
+				// expected code lines
+				`ProducesMediaTypes: []string{"application/octet-stream", "application/json"},`,
+				`ConsumesMediaTypes: []string{"multipart/form-data", "application/x-www-form-urlencoded"},`,
+			},
+		}
+
+		for fileToInspect, expectedCode := range fixtureConfig {
+			code, err := os.ReadFile(filepath.Join(opts.Target, filepath.FromSlash(fileToInspect)))
+			require.NoError(t, err)
+
+			for line, codeLine := range expectedCode {
+				if !assertInCode(t, strings.TrimSpace(codeLine), string(code)) {
+					t.Logf("Code expected did not match in codegenfile %s for expected line %d: %q", fileToInspect, line, expectedCode[line])
+				}
+			}
+		}
+	})
+
+	t.Run("generated operation should have options to set media type", func(t *testing.T) {
+		fixtureConfig := map[string][]string{
+			"client/uploads/uploads_client.go": { // generated file
+				// free mime consumes option
+				`func WithContentType(mime string) ClientOption {`,
+				`	return func(r *runtime.ClientOperation) {`,
+				`	r.ConsumesMediaTypes = []string{mime}`,
+				// shorthand options
+				`func WithContentTypeApplicationJSON(r *runtime.ClientOperation) {`,
+				`	r.ConsumesMediaTypes = []string{"application/json"}`,
+				`func WithContentTypeApplicationxWwwFormUrlencoded(r *runtime.ClientOperation) {`,
+				`	r.ConsumesMediaTypes = []string{"application/x-www-form-urlencoded"}`,
+				`func WithContentTypeMultipartFormData(r *runtime.ClientOperation) {`,
+				`	r.ConsumesMediaTypes = []string{"multipart/form-data"}`,
+				// free mime produces option
+				`func WithAccept(mime string) ClientOption {`,
+				`	return func(r *runtime.ClientOperation) {`,
+				`		r.ProducesMediaTypes = []string{mime}`,
+				// shorthand options
+				`func WithAcceptApplicationJSON(r *runtime.ClientOperation) {`,
+				`	r.ProducesMediaTypes = []string{"application/json"}`,
+				`func WithAcceptApplicationOctetStream(r *runtime.ClientOperation) {`,
+				`	r.ProducesMediaTypes = []string{"application/octet-stream"}`,
+			},
+		}
+
+		for fileToInspect, expectedCode := range fixtureConfig {
+			code, err := os.ReadFile(filepath.Join(opts.Target, filepath.FromSlash(fileToInspect)))
+			require.NoError(t, err)
+
+			for line, codeLine := range expectedCode {
+				if !assertInCode(t, strings.TrimSpace(codeLine), string(code)) {
+					t.Logf("Code expected did not match in codegenfile %s for expected line %d: %q", fileToInspect, line, expectedCode[line])
+				}
+			}
+		}
+	})
+}
diff --git a/generator/media_test.go b/generator/media_test.go
index d0cacbb2..1efc5610 100644
--- a/generator/media_test.go
+++ b/generator/media_test.go
@@ -37,6 +37,10 @@ func TestMediaMime(t *testing.T) {
 	assert.Equal(t, "", mediaParameters(runtime.JSONMime))
 }
 
+func TestMediaGoName(t *testing.T) {
+	assert.Equal(t, "StarStar", mediaGoName("*/*"))
+}
+
 func TestMediaMakeSerializers(t *testing.T) {
 	app := appGenerator{
 		Name:     "myapp",