diff --git a/.golangci.yml b/.golangci.yml index e8e5226..4844107 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -32,6 +32,8 @@ linters: - wrapcheck - wsl - tagliatelle + - varnamelen + - ireturn issues: exclude-use-default: false diff --git a/.travis.yml b/.travis.yml index 8cf8284..0d7e491 100644 --- a/.travis.yml +++ b/.travis.yml @@ -9,14 +9,14 @@ env: - GO111MODULE=on - GOARCH=amd64 - GOOS=linux - - GOLANGCI_LINTER_VERSION=v1.42.0 + - GOLANGCI_LINTER_VERSION=v1.44.0 - CGO_ENABLED=1 # COVERALLS_TOKEN before_script: - go mod download - go get github.com/mattn/goveralls - - curl -sfL https://install.goreleaser.com/github.com/golangci/golangci-lint.sh | bash -s -- -b $GOPATH/bin $GOLANGCI_LINTER_VERSION + - curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin $GOLANGCI_LINTER_VERSION script: - golangci-lint run diff --git a/request.go b/request.go index 9bfaaa4..8de584c 100644 --- a/request.go +++ b/request.go @@ -380,6 +380,14 @@ func (r *RequestEnvelope) SessionID() string { return r.Session.SessionID } +// SessionUser returns the user in the session or returns an error if no user exists. +func (r *RequestEnvelope) SessionUser() (*ContextUser, error) { + if r.Session == nil || r.Session.User == nil { + return nil, &NotFoundError{"Session.User", ""} + } + return r.Session.User, nil +} + // ContextSystemPerson describes the person who is making the request to Alexa // // This is the user recognized by voice, not account from which the request came. diff --git a/server.go b/server.go index 73d3634..98b7102 100644 --- a/server.go +++ b/server.go @@ -4,16 +4,21 @@ import ( "context" "errors" "fmt" + "io" + "io/ioutil" + "net/http" "sync" "github.com/aws/aws-lambda-go/lambda" log "github.com/hamba/logger/v2" + lctx "github.com/hamba/logger/v2/ctx" jsoniter "github.com/json-iterator/go" ) // Handler represents an alexa request handler. type Handler interface { Serve(*ResponseBuilder, *RequestEnvelope) + ServeHTTP(w http.ResponseWriter, r *http.Request) } // HandlerFunc is an adapter allowing a function to be used as a handler. @@ -24,6 +29,27 @@ func (fn HandlerFunc) Serve(b *ResponseBuilder, r *RequestEnvelope) { fn(b, r) } +// ServeHTTP serves a HTTP request. +func (fn HandlerFunc) ServeHTTP(rw http.ResponseWriter, r *http.Request) { + req, err := parseRequest(r.Body) + if err != nil { + rw.WriteHeader(http.StatusInternalServerError) + _, _ = rw.Write([]byte(`{"error": "failed to parse request"}`)) + return + } + defer func() { _ = r.Body.Close() }() + + builder := &ResponseBuilder{} + fn(builder, req) + + resp, err := jsoniter.Marshal(builder.Build()) + if err != nil { + rw.WriteHeader(http.StatusInternalServerError) + resp = []byte(`{"error": "failed to marshal response"}`) + } + _, _ = rw.Write(resp) +} + // A Server defines parameters for running an Alexa server. type Server struct { Handler Handler @@ -160,7 +186,7 @@ func fallbackHandler(err error) HandlerFunc { // Serve serves the matched handler. func (m *ServeMux) Serve(b *ResponseBuilder, r *RequestEnvelope) { json, _ := jsoniter.Marshal(r) - m.logger.Debug(string(json)) + m.logger.Debug("request", lctx.Str("json", string(json))) h, err := m.Handler(r) if err != nil { h = fallbackHandler(err) @@ -168,7 +194,49 @@ func (m *ServeMux) Serve(b *ResponseBuilder, r *RequestEnvelope) { h.Serve(b, r) json, _ = jsoniter.Marshal(b.Build()) - m.logger.Debug(string(json)) + m.logger.Debug("response", lctx.Str("json", string(json))) +} + +// ServeHTTP dispatches the request to the handler whose +// alexa intent matches the request URL. +func (m *ServeMux) ServeHTTP(w http.ResponseWriter, r *http.Request) { + var h Handler + req, err := parseRequest(r.Body) + if err != nil { + h = fallbackHandler(err) + } else { + h, err = m.Handler(req) + if err != nil { + h = fallbackHandler(err) + } + } + defer func() { _ = r.Body.Close() }() + + builder := &ResponseBuilder{} + h.Serve(builder, req) + + resp, err := jsoniter.Marshal(builder.Build()) + if err != nil { + m.logger.Error("failed to marshal response", lctx.Error("error", err)) + w.WriteHeader(http.StatusInternalServerError) + resp = []byte(`{"error": "failed to marshal response"}`) + } + if _, err := w.Write(resp); err != nil { + m.logger.Debug("failed to write response") + } +} + +func parseRequest(b io.Reader) (*RequestEnvelope, error) { + payload, err := ioutil.ReadAll(b) + if err != nil { + return nil, err + } + + req := &RequestEnvelope{} + if err := jsoniter.Unmarshal(payload, req); err != nil { + return nil, err + } + return req, nil } // DefaultServerMux is the default mux. diff --git a/server_test.go b/server_test.go index 617867e..412e421 100644 --- a/server_test.go +++ b/server_test.go @@ -1,10 +1,14 @@ package alexa import ( + "bytes" ctx "context" log "github.com/hamba/logger/v2" jsoniter "github.com/json-iterator/go" "github.com/stretchr/testify/assert" + "io/ioutil" + "net/http" + "net/http/httptest" "reflect" "runtime" "testing" @@ -27,6 +31,51 @@ func TestServer(t *testing.T) { assert.NotEmpty(t, resp) } +func TestMuxServeHTTP(t *testing.T) { + mux := NewServerMux(log.New(nil, log.ConsoleFormat(), log.Info)) + rw := httptest.NewRecorder() + b := ioutil.NopCloser(bytes.NewReader([]byte(`{}`))) + r := &http.Request{Method: http.MethodGet, Body: b} + + mux.ServeHTTP(rw, r) + + res, _ := ioutil.ReadAll(rw.Result().Body) + resp := &ResponseEnvelope{} + err := jsoniter.Unmarshal(res, resp) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, rw.Result().StatusCode) + assert.Contains(t, string(res), "error") + + rw = httptest.NewRecorder() + b = ioutil.NopCloser(bytes.NewReader([]byte(`foo`))) + r = &http.Request{Method: http.MethodGet, Body: b} + + mux.ServeHTTP(rw, r) + + res, _ = ioutil.ReadAll(rw.Result().Body) + resp = &ResponseEnvelope{} + err = jsoniter.Unmarshal(res, resp) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, rw.Result().StatusCode) + assert.Contains(t, string(res), "error") + + rw = httptest.NewRecorder() + req := &RequestEnvelope{Request: &Request{Type: TypeIntentRequest, Intent: Intent{Name: HelpIntent}}} + content, err := jsoniter.Marshal(req) + assert.NoError(t, err) + b = ioutil.NopCloser(bytes.NewReader(content)) + r = &http.Request{Method: http.MethodGet, Body: b} + + mux.ServeHTTP(rw, r) + + res, _ = ioutil.ReadAll(rw.Result().Body) + resp = &ResponseEnvelope{} + err = jsoniter.Unmarshal(res, resp) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, rw.Result().StatusCode) + assert.Contains(t, string(res), "error") +} + func TestHandler(t *testing.T) { mux := NewServerMux(log.New(nil, log.ConsoleFormat(), log.Info)) h := HandlerFunc(func(b *ResponseBuilder, r *RequestEnvelope) { b.WithSimpleCard("title", "") }) diff --git a/skill/model_builder.go b/skill/model_builder.go index c96907b..590a455 100644 --- a/skill/model_builder.go +++ b/skill/model_builder.go @@ -131,6 +131,21 @@ func (m *modelBuilder) WithConfirmationSlotPrompt(intent, slot string) *modelBui return m } +// WithIntentConfirmationPrompt does nothing. +// func (m *modelBuilder) WithIntentConfirmationPrompt(intent string) *modelBuilder { +// // TODO: WithIntentConfirmationPrompt +// // add a prompt to the model `model.WithIntentConfirmationPrompt(intent, slot)` +// // add variations `model.IntentConfirmationPrompt(intent, slot).WithVariation("PlainText")` +// // https://developer.amazon.com/docs/custom-skills/define-the-dialog-to-collect-and-confirm-required-information.html +// // https://developer.amazon.com/en-US/docs/alexa/custom-skills/dialog-interface-reference.html#confirmintent +// p := NewIntentConfirmationPromptBuilder(intent). +// WithLocaleRegistry(m.registry) +// m.prompts[p.id] = p +// +// +// return m +// } + // WithValidationSlotPrompt creates and sets a validation prompt for a slot dialog. func (m *modelBuilder) WithValidationSlotPrompt(slot, t string, valuesKey ...string) *modelBuilder { // slot must exist! @@ -351,6 +366,16 @@ func (i *modelIntentBuilder) WithConfirmation(c bool) *modelIntentBuilder { return i } +// WithIntentConfirmationPrompt does nothing. +func (i *modelIntentBuilder) WithIntentConfirmationPrompt(prompt string) *modelIntentBuilder { + // TODO: WithIntentConfirmationPrompt + // add a prompt to the model `model.WithIntentConfirmationPrompt(intent, slot)` + // add variations `model.IntentConfirmationPrompt(intent, slot).WithVariation("PlainText")` + // https://developer.amazon.com/docs/custom-skills/define-the-dialog-to-collect-and-confirm-required-information.html + // https://developer.amazon.com/en-US/docs/alexa/custom-skills/dialog-interface-reference.html#confirmintent + return i +} + // BuildLanguageIntent generates a ModelIntent for the locale. func (i *modelIntentBuilder) BuildLanguageIntent(locale string) (ModelIntent, error) { loc, err := i.registry.Resolve(locale) @@ -467,15 +492,6 @@ func (s *modelSlotBuilder) WithElicitationPrompt(id string) *modelSlotBuilder { return s } -// WithIntentConfirmationPrompt does nothing. -func (s *modelSlotBuilder) WithIntentConfirmationPrompt(prompt string) *modelSlotBuilder { - // TODO: WithIntentConfirmationPrompt - // https://developer.amazon.com/docs/custom-skills/ - // -> define-the-dialog-to-collect-and-confirm-required-information.html#intent-confirmation - // https://developer.amazon.com/en-US/docs/alexa/custom-skills/dialog-interface-reference.html#confirmintent - return s -} - // WithValidationRule adds a validation rule to the slot. func (s *modelSlotBuilder) WithValidationRule(t, prompt string, valuesKey ...string) *modelSlotBuilder { if nil == s.validationRules { diff --git a/skill/model_builder_test.go b/skill/model_builder_test.go index 7fa7ec2..70e3a8a 100644 --- a/skill/model_builder_test.go +++ b/skill/model_builder_test.go @@ -373,13 +373,13 @@ func TestModelSlotBuilder_WithPrompt(t *testing.T) { } // modelSlotBuilder with intent confirmation prompt is covered. -func TestModelSlotBuilder_WithIntentConfirmationPrompt(t *testing.T) { - msb := skill.NewModelSlotBuilder("MyIntent", "MySlot", "SlotType") - - msb2 := msb.WithIntentConfirmationPrompt("foo") - - assert.Equal(t, msb, msb2) -} +// func TestModelSlotBuilder_WithIntentConfirmationPrompt(t *testing.T) { +// msb := skill.NewModelSlotBuilder("MyIntent", "MySlot", "SlotType") +// +// msb2 := msb.WithIntentConfirmationPrompt("foo") +// +// assert.Equal(t, msb, msb2) +// } // TODO: test modelValidationRulesBuilder