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! :)