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

Add support for multipart form requests #1606

Merged
merged 6 commits into from
Mar 18, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions design/apidsl/action.go
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,18 @@ func payload(isOptional bool, p interface{}, dsls ...func()) {
}
}

// MultipartForm can be used in: Action
//
// MultipartForm implements the action multipart form DSL. An action multipart form indicates that
// the HTTP request body should be encoded using multipart form data as described in
// https://www.w3.org/TR/html401/interact/forms.html#h-17.13.4.2.
//
func MultipartForm() {
if a, ok := actionDefinition(); ok {
a.PayloadMultipart = true
}
}

// newAttribute creates a new attribute definition using the media type with the given identifier
// as base type.
func newAttribute(baseMT string) *design.AttributeDefinition {
Expand Down
2 changes: 2 additions & 0 deletions design/definitions.go
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,8 @@ type (
Payload *UserTypeDefinition
// PayloadOptional is true if the request payload is optional, false otherwise.
PayloadOptional bool
// PayloadOptional is true if the request payload is multipart, false otherwise.
PayloadMultipart bool
// Request headers that need to be made available to action
Headers *AttributeDefinition
// Metadata is a list of key/value pairs
Expand Down
20 changes: 12 additions & 8 deletions goagen/gen_app/generator.go
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,9 @@ func (g *Generator) generateControllers() (err error) {
codegen.SimpleImport("github.com/goadesign/goa"),
codegen.SimpleImport("github.com/goadesign/goa/cors"),
codegen.SimpleImport("regexp"),
codegen.SimpleImport("strconv"),
codegen.SimpleImport("time"),
codegen.NewImport("uuid", "github.com/satori/go.uuid"),
}
encoders, err := BuildEncoders(g.API.Produces, true)
if err != nil {
Expand Down Expand Up @@ -305,14 +308,15 @@ func (g *Generator) generateControllers() (err error) {
context := fmt.Sprintf("%s%sContext", codegen.Goify(a.Name, true), codegen.Goify(r.Name, true))
unmarshal := fmt.Sprintf("unmarshal%s%sPayload", codegen.Goify(a.Name, true), codegen.Goify(r.Name, true))
action := map[string]interface{}{
"Name": codegen.Goify(a.Name, true),
"DesignName": a.Name,
"Routes": a.Routes,
"Context": context,
"Unmarshal": unmarshal,
"Payload": a.Payload,
"PayloadOptional": a.PayloadOptional,
"Security": a.Security,
"Name": codegen.Goify(a.Name, true),
"DesignName": a.Name,
"Routes": a.Routes,
"Context": context,
"Unmarshal": unmarshal,
"Payload": a.Payload,
"PayloadOptional": a.PayloadOptional,
"PayloadMultipart": a.PayloadMultipart,
"Security": a.Security,
}
data.Actions = append(data.Actions, action)
return nil
Expand Down
73 changes: 73 additions & 0 deletions goagen/gen_app/generator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ var _ = Describe("Generate", func() {
outDir, err = ioutil.TempDir(filepath.Join(workspace.Path, "src"), "")
Ω(err).ShouldNot(HaveOccurred())
os.Args = []string{"goagen", "--out=" + outDir, "--design=foo", "--version=" + version.String()}
codegen.TempCount = 0
})

JustBeforeEach(func() {
Expand Down Expand Up @@ -244,6 +245,30 @@ var _ = Describe("Generate", func() {
})
})

Context("with a multipart payload", func() {
BeforeEach(func() {
elemType := &design.AttributeDefinition{Type: design.Integer}
payload = &design.UserTypeDefinition{
AttributeDefinition: &design.AttributeDefinition{
Type: design.Object{
"int": elemType,
},
},
TypeName: "Collection",
}
design.Design.Resources["Widget"].Actions["get"].Payload = payload
design.Design.Resources["Widget"].Actions["get"].PayloadMultipart = true
runCodeTemplates(map[string]string{"outDir": outDir, "design": "foo", "tmpDir": filepath.Base(outDir), "version": version.String()})
})

It("generates the corresponding code", func() {
Ω(genErr).Should(BeNil())

contextsContent, err := ioutil.ReadFile(filepath.Join(outDir, "app", "controllers.go"))
Ω(err).ShouldNot(HaveOccurred())
Ω(string(contextsContent)).Should(ContainSubstring(controllersMultipartPayloadCode))
})
})
})
})

Expand Down Expand Up @@ -502,3 +527,51 @@ func unmarshalGetWidgetPayload(ctx context.Context, service *goa.Service, req *h
return nil
}
`

const controllersMultipartPayloadCode = `
// MountWidgetController "mounts" a Widget resource controller on the given service.
func MountWidgetController(service *goa.Service, ctrl WidgetController) {
initService(service)
var h goa.Handler

h = func(ctx context.Context, rw http.ResponseWriter, req *http.Request) error {
// Check if there was an error loading the request
if err := goa.ContextError(ctx); err != nil {
return err
}
// Build the context
rctx, err := NewGetWidgetContext(ctx, req, service)
if err != nil {
return err
}
// Build the payload
if rawPayload := goa.ContextRequest(ctx).Payload; rawPayload != nil {
rctx.Payload = rawPayload.(*Collection)
} else {
return goa.MissingPayloadError()
}
return ctrl.Get(rctx)
}
service.Mux.Handle("GET", "/:id", ctrl.MuxHandler("get", h, unmarshalGetWidgetPayload))
service.LogInfo("mount", "ctrl", "Widget", "action", "Get", "route", "GET /:id")
}

// unmarshalGetWidgetPayload unmarshals the request body into the context request data Payload field.
func unmarshalGetWidgetPayload(ctx context.Context, service *goa.Service, req *http.Request) error {
var err error
var payload collection
rawInt := req.FormValue("int")
if int_, err2 := strconv.Atoi(rawInt); err2 == nil {
tmp2 := int_
tmp1 := &tmp2
payload.Int = tmp1
} else {
err = goa.MergeErrors(err, goa.InvalidParamTypeError("int", rawInt, "integer"))
}
if err != nil {
return err
}
goa.ContextRequest(ctx).Payload = payload.Publicize()
return nil
}
`
12 changes: 10 additions & 2 deletions goagen/gen_app/writers.go
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,7 @@ func (w *ControllersWriter) Execute(data []*ControllerTemplateData) error {
}
}
fn := template.FuncMap{
"newCoerceData": newCoerceData,
"finalizeCode": w.Finalizer.Code,
"validationCode": w.Validator.Code,
}
Expand Down Expand Up @@ -768,10 +769,17 @@ func handle{{ .Resource }}Origin(h goa.Handler) goa.Handler {

// unmarshalT generates the code for an action payload unmarshal function.
// template input: *ControllerTemplateData
unmarshalT = `{{ range .Actions }}{{ if .Payload }}
unmarshalT = `{{ define "Coerce" }}` + coerceT + `{{ end }}` + `{{ range .Actions }}{{ if .Payload }}
// {{ .Unmarshal }} unmarshals the request body into the context request data Payload field.
func {{ .Unmarshal }}(ctx context.Context, service *goa.Service, req *http.Request) error {
{{ if .Payload.IsObject }}payload := &{{ gotypename .Payload nil 1 true }}{}
{{ if .PayloadMultipart}}var err error
var payload {{ gotypename .Payload nil 1 true }}
{{ $o := .Payload.ToObject }}{{ range $name, $att := $o -}}
raw{{ goify $name true }} := req.FormValue("{{ $name }}")
{{ template "Coerce" (newCoerceData $name $att true (printf "payload.%s" (goifyatt $att $name true)) 0) }}{{ end }}{{/*
*/}} if err != nil {
return err
}{{ else if .Payload.IsObject }}payload := &{{ gotypename .Payload nil 1 true }}{}
if err := service.DecodeRequest(req, payload); err != nil {
return err
}{{ $assignment := finalizeCode .Payload.AttributeDefinition "payload" 1 }}{{ if $assignment }}
Expand Down
65 changes: 62 additions & 3 deletions goagen/gen_app/writers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -994,6 +994,7 @@ var _ = Describe("ControllersWriter", func() {
})

Context("with data", func() {
var multipart bool
var actions, verbs, paths, contexts, unmarshals []string
var payloads []*design.UserTypeDefinition
var encoders, decoders []*genapp.EncoderTemplateData
Expand All @@ -1002,6 +1003,7 @@ var _ = Describe("ControllersWriter", func() {
var data []*genapp.ControllerTemplateData

BeforeEach(func() {
multipart = false
actions = nil
verbs = nil
paths = nil
Expand Down Expand Up @@ -1038,9 +1040,10 @@ var _ = Describe("ControllersWriter", func() {
Verb: verbs[i],
Path: paths[i],
}},
"Context": contexts[i],
"Unmarshal": unmarshal,
"Payload": payload,
"Context": contexts[i],
"Unmarshal": unmarshal,
"Payload": payload,
"PayloadMultipart": multipart,
}
}
if len(as) > 0 {
Expand Down Expand Up @@ -1150,6 +1153,42 @@ var _ = Describe("ControllersWriter", func() {
})
})

Context("with actions that take a multipart payload", func() {
BeforeEach(func() {
actions = []string{"list"}
required := &dslengine.ValidationDefinition{
Required: []string{"id"},
}
verbs = []string{"GET"}
paths = []string{"/accounts/:accountID/bottles"}
contexts = []string{"ListBottleContext"}
unmarshals = []string{"unmarshalListBottlePayload"}
payloads = []*design.UserTypeDefinition{
{
TypeName: "ListBottlePayload",
AttributeDefinition: &design.AttributeDefinition{
Type: design.Object{
"id": &design.AttributeDefinition{
Type: design.String,
},
},
Validation: required,
},
},
}
multipart = true
})

It("writes the payload unmarshal function", func() {
err := writer.Execute(data)
Ω(err).ShouldNot(HaveOccurred())
b, err := ioutil.ReadFile(filename)
Ω(err).ShouldNot(HaveOccurred())
written := string(b)
Ω(written).Should(ContainSubstring(payloadMultipartObjUnmarshal))
})
})

Context("with multiple controllers", func() {
BeforeEach(func() {
actions = []string{"list", "show"}
Expand Down Expand Up @@ -2274,6 +2313,26 @@ func unmarshalListBottlePayload(ctx context.Context, service *goa.Service, req *
return nil
}
`

payloadMultipartObjUnmarshal = `
func unmarshalListBottlePayload(ctx context.Context, service *goa.Service, req *http.Request) error {
var err error
var payload listBottlePayload
rawID := req.FormValue("id")
payload.ID = &rawID
if err != nil {
return err
}
if err := payload.Validate(); err != nil {
// Initialize payload with private data structure so it can be logged
goa.ContextRequest(ctx).Payload = payload
return err
}
goa.ContextRequest(ctx).Payload = payload.Publicize()
return nil
}
`

payloadNoValidationsObjUnmarshal = `
func unmarshalListBottlePayload(ctx context.Context, service *goa.Service, req *http.Request) error {
payload := &listBottlePayload{}
Expand Down
29 changes: 25 additions & 4 deletions goagen/gen_client/generator.go
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,7 @@ func (g *Generator) generateResourceClient(pkgDir string, res *design.ResourceDe
codegen.SimpleImport("fmt"),
codegen.SimpleImport("io"),
codegen.SimpleImport("io/ioutil"),
codegen.SimpleImport("mime/multipart"),
codegen.SimpleImport("net/http"),
codegen.SimpleImport("net/url"),
codegen.SimpleImport("os"),
Expand Down Expand Up @@ -495,6 +496,8 @@ func (g *Generator) generateActionClient(action *design.ActionDefinition, file *
ResourceName string
Description string
Routes []*design.RouteDefinition
Payload *design.UserTypeDefinition
PayloadMultipart bool
HasPayload bool
HasMultiContent bool
DefaultContentType string
Expand All @@ -509,6 +512,8 @@ func (g *Generator) generateActionClient(action *design.ActionDefinition, file *
ResourceName: action.Parent.Name,
Description: action.Description,
Routes: action.Routes,
Payload: action.Payload,
PayloadMultipart: action.PayloadMultipart,
HasPayload: action.Payload != nil,
HasMultiContent: len(design.Design.Consumes) > 1,
DefaultContentType: design.Design.Consumes[0].MIMETypes[0],
Expand Down Expand Up @@ -1043,14 +1048,29 @@ func (c * Client) {{ .Name }}(ctx context.Context, {{ if .DirName }}filename, {{
*/}}// {{ $funcName }} create the request corresponding to the {{ .Name }} action endpoint of the {{ .ResourceName }} resource.
func (c *Client) {{ $funcName }}(ctx context.Context, path string{{ if .Params }}, {{ .Params }}{{ end }}{{ if .HasPayload }}{{ if .HasMultiContent }}, contentType string{{ end }}{{ end }}) (*http.Request, error) {
{{ if .HasPayload }} var body bytes.Buffer
{{ if .HasMultiContent }} if contentType == "" {
{{ if .PayloadMultipart }} w := multipart.NewWriter(&body)
{{ $o := .Payload.ToObject }}{{ range $name, $att := $o }}{{/*
*/}} {
fw, err := w.CreateFormField("{{ $name }}")
if err != nil {
return nil, err
}
{{ toString (printf "payload.%s" (goify $name true)) "s" $att }}
if _, err := fw.Write([]byte(s)); err != nil {
return nil, err
}
}
{{ end }} if err := w.Close(); err != nil {
return nil, err
}
{{ else }}{{ if .HasMultiContent }} if contentType == "" {
contentType = "*/*" // Use default encoder
}
{{ end }} err := c.Encoder.Encode(payload, &body, {{ if .HasMultiContent }}contentType{{ else }}"*/*"{{ end }})
if err != nil {
return nil, fmt.Errorf("failed to encode body: %s", err)
}
{{ end }} scheme := c.Scheme
{{ end }}{{ end }} scheme := c.Scheme
if scheme == "" {
scheme = "{{ .CanonicalScheme }}"
}
Expand Down Expand Up @@ -1084,13 +1104,14 @@ func (c *Client) {{ $funcName }}(ctx context.Context, path string{{ if .Params }
return nil, err
}
{{ if or .HasPayload .Headers }} header := req.Header
{{ if .HasPayload }}{{ if .HasMultiContent }} if contentType == "*/*" {
{{ if .PayloadMultipart }} header.Set("Content-Type", w.FormDataContentType())
{{ else }}{{ if .HasPayload }}{{ if .HasMultiContent }} if contentType == "*/*" {
header.Set("Content-Type", "{{ .DefaultContentType }}")
} else {
header.Set("Content-Type", contentType)
}
{{ else }} header.Set("Content-Type", "{{ .DefaultContentType }}")
{{ end }}{{ end }}{{ range .Headers }}{{ if .CheckNil }} if {{ .VarName }} != nil {
{{ end }}{{ end }}{{ end }}{{ range .Headers }}{{ if .CheckNil }} if {{ .VarName }} != nil {
{{ end }}{{ if .MustToString }}{{ $tmp := tempvar }} {{ toString .ValueName $tmp .Attribute }}
header.Set("{{ .Name }}", {{ $tmp }}){{ else }}
header.Set("{{ .Name }}", {{ .ValueName }})
Expand Down
Loading