I recently learned an interesting feature in the Goa framework, which we use for some of our services. How can we return a ZIP file which is created by a service function implementation, but also include additional custom headers in the response?

Big thanks to one of the Goa creators/contributors Raphael Simon, who helped me with this issue very quickly in the Goa Slack channel!

Encoding bytes

Goa provides default response encoders for JSON, XML, GOB and plain text/bytes. In my case the plain bytes response was almost enough, but I wanted to have additional response headers returned to the client.

1
2
3
4
5
6
7
8
9
	Method("ExportZip", func() {
		Description("Export a signed zip file.")
		Payload(ExportZipRequest)
		Result(Bytes)
		HTTP(func() {
			GET("/{path}/export")
			Response(StatusOK)
		})
	})

As I wanted to have Content-Type: application/zip header, I wrote a small encoder using the default TextEncoder as example and everything worked fine.

What’s the problem

I wanted to have one additional response header specifying the name of the ZIP file which was dependent on some internal resource characteristics. More specific, I wanted to include a response header Content-Disposition: attachment; filename="myfile.zip" where myfile.zip could be different with every request.

The simple text/bytes encoder cannot do this, because there’s no way I can give it more specific values from the Service implementation - I can only return []byte slice as response.

With Goa, if you need to return more specific headers, you have to define them in a Goa DSL response type.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
	var ExportZipResult = Type("ExportZipResult", func() {
		Field(1, "body", Bytes, "Body is the zip file bytes.")
		Field(2, "content-disposition", String, "Content-Disposition response header containing the name of the file.")
		Required("body", "content-disposition")
	})


	Method("ExportZip", func() {
		Description("Export a signed zip file.")
		Payload(ExportZipRequest)
		Result(ExportZipResult)
		HTTP(func() {
			GET("/{path}/export")
			Response(StatusOK, func(){
				Header("content-disposition")				
			})
		})
	})

Now my small custom bytes encoder receives this object, but the body is wrapped in a generated wrapper object which I must cast to the specific ExportZipResult type in order to get the body bytes. It didn’t seem natural as it means that this bytes encoder will only be usable for that specific function and won’t be useful for other similar functions. That’s when I decided to write in the Slack channel and to my surprise the creator of Goa responded within a few hours.

The solution

It turned out that Goa provides a custom DSL function which instructs the code generation process to skip the creation of any response encoder for that function. Instead, you must implement a function which returns an object containing all header values which you’d like to return, plus an io.ReadCloser for the bytes that you want to return to the client.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
	var ExportZipResult = Type("ExportZipResult", func() {
		Field(1, "content-type", String, "Content-Type response header.")
		Field(2, "content-disposition", String, "Content-Disposition response header containing the name of the file.")
		Required("content-type", "content-disposition")
	})


	Method("ExportZip", func() {
		Description("Export a signed zip file.")
		Payload(ExportZipRequest)
		Result(ExportZipResult)
		HTTP(func() {
			GET("/{path}/export")

			// bypass response body encoder code generation, so that
			// an io.ReadCloser can be returned to the client
			// while specific response headers can be specified
			// in the ExportZipResult type.
			SkipResponseBodyEncodeDecode()

			Response(StatusOK, func(){
				Header("content-type")
				Header("content-disposition")				
			})
		})
	})

Now there is no need for writing any custom Encoder and the function that implements the creation of the ZIP archive looks like this.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
func (s *Service) ExportZip(ctx context.Context, req *mysvc.ExportZipRequest) (*mysvc.ExportZipResult, io.ReadCloser, error) {
	// prepare the ZIP archive []byte slice
	myzip := createSignedZip(...) 

	// prepare custom header value
	contentDisposition := fmt.Sprintf(...)

	return &mysvc.ExportZipResult{
		ContentType: "application/zip",
		ContentDisposition: contentDisposition,
	}, io.NopCloser(bytes.NewReader(myzip)), nil
}

And it works like charm! :)