Skip to content

Commit

Permalink
implement HTTP server for Alexa requests (#2)
Browse files Browse the repository at this point in the history
feat: HTTP server for Alexa requests
chore: update golangci-lint
  • Loading branch information
DrPsychick authored Feb 13, 2022
1 parent 9c08f64 commit a19a1a6
Show file tree
Hide file tree
Showing 7 changed files with 163 additions and 20 deletions.
2 changes: 2 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ linters:
- wrapcheck
- wsl
- tagliatelle
- varnamelen
- ireturn

issues:
exclude-use-default: false
Expand Down
4 changes: 2 additions & 2 deletions .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions request.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
72 changes: 70 additions & 2 deletions server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -160,15 +186,57 @@ 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)
}

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.
Expand Down
49 changes: 49 additions & 0 deletions server_test.go
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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", "") })
Expand Down
34 changes: 25 additions & 9 deletions skill/model_builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -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!
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down
14 changes: 7 additions & 7 deletions skill/model_builder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down

0 comments on commit a19a1a6

Please sign in to comment.