diff --git a/.gitignore b/.gitignore index e6a0a52..bce34cb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,8 @@ .envrc -cover.out ngrok.yml +cover.out + +*.mp3 +*.ogg +*.opus +*.wav diff --git a/README.md b/README.md index 382dac0..9a0da65 100644 --- a/README.md +++ b/README.md @@ -7,3 +7,31 @@ Package alice provides helpers for developing skills for Alice virtual assistant via Yandex.Dialogs platform. + +# Example + +```go +responder := func(ctx context.Context, request *alice.Request) (*alice.ResponsePayload, error) { + return &alice.ResponsePayload{ + Text: "Bye!", + EndSession: true, + }, nil +} + +h := alice.NewHandler(responder) +h.Errorf = log.Printf +http.Handle("/", h) + +const addr = "127.0.0.1:8080" +log.Printf("Listening on http://%s ...", addr) +log.Fatal(http.ListenAndServe(addr, nil)) +``` + +See [documentation](https://godoc.org/github.com/AlekSi/alice) and [examples](examples). + +# License + +Copyright (c) 2019 Alexey Palazhchenko. [MIT-style license](LICENSE). + +Tests use the following resources: +* https://freesound.org/people/prucanada/sounds/415341/ by prucanada. diff --git a/doc_test.go b/doc_test.go index 16f0df5..7f9a8ac 100644 --- a/doc_test.go +++ b/doc_test.go @@ -9,13 +9,14 @@ import ( ) func Example() { - h := alice.NewHandler(func(ctx context.Context, request *alice.Request) (*alice.ResponsePayload, error) { + responder := func(ctx context.Context, request *alice.Request) (*alice.ResponsePayload, error) { return &alice.ResponsePayload{ Text: "Bye!", EndSession: true, }, nil - }) + } + h := alice.NewHandler(responder) h.Errorf = log.Printf http.Handle("/", h) diff --git a/handler.go b/handler.go index f82fc5b..5fbe8c4 100644 --- a/handler.go +++ b/handler.go @@ -91,6 +91,8 @@ func (h *Handler) ServeHTTP(rw http.ResponseWriter, req *http.Request) { return } req.Body = ioutil.NopCloser(&body) + req.ContentLength = int64(body.Len()) + req.TransferEncoding = nil } b, err := httputil.DumpRequest(req, true) diff --git a/resources/client.go b/resources/client.go new file mode 100644 index 0000000..38f18ca --- /dev/null +++ b/resources/client.go @@ -0,0 +1,204 @@ +package resources + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "io/ioutil" + "mime/multipart" + "net/http" + "net/http/httputil" + "os" + "path/filepath" + "time" + + "github.com/AlekSi/alice" +) + +type Quota struct { + Total int + Used int +} + +type Sound struct { + ID string + SkillID string + Size *int + OriginalName string + CreatedAt time.Time + IsProcessed bool + Error *string +} + +type Client struct { + SkillID string + OAuthToken string + HTTPClient *http.Client + + // debugging options + Debugf alice.Printf // debug logger + Indent bool // indent requests and responses + StrictDecoder bool // disallow unexpected fields in responses +} + +func (c *Client) do(req *http.Request, respBody interface{}) error { + httpClient := c.HTTPClient + if httpClient == nil { + httpClient = http.DefaultClient + } + + var jsonRequst bool + if c.OAuthToken != "" { + req.Header.Set("Authorization", "OAuth "+c.OAuthToken) + } + if req.Body != nil && req.Header.Get("Content-Type") == "" { + jsonRequst = true + req.Header.Set("Content-Type", "application/json; charset=utf-8") + } + + if c.Debugf != nil { + if c.Indent && jsonRequst { + b, err := ioutil.ReadAll(req.Body) + if err != nil { + return err + } + + var body bytes.Buffer + if err = json.Indent(&body, b, "", " "); err != nil { + return err + } + req.Body = ioutil.NopCloser(&body) + req.ContentLength = int64(body.Len()) + req.TransferEncoding = nil + } + + b, err := httputil.DumpRequestOut(req, jsonRequst) + if err != nil { + return err + } + c.debugf("Request:\n%s", b) + } + + resp, err := httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() //nolint:errcheck + + if c.Debugf != nil { + if c.Indent { + b, err := ioutil.ReadAll(resp.Body) + if err != nil { + return err + } + + var body bytes.Buffer + if err = json.Indent(&body, b, "", " "); err != nil { + return err + } + resp.Body = ioutil.NopCloser(&body) + resp.ContentLength = int64(body.Len()) + resp.TransferEncoding = nil + } + + b, err := httputil.DumpResponse(resp, true) + if err != nil { + return err + } + c.debugf("Response:\n%s", b) + } + + if resp.StatusCode/100 != 2 { + return fmt.Errorf("status code %d", resp.StatusCode) + } + + d := json.NewDecoder(resp.Body) + if c.StrictDecoder { + d.DisallowUnknownFields() + } + return d.Decode(&respBody) +} + +func (c *Client) debugf(format string, a ...interface{}) { + if c.Debugf != nil { + c.Debugf(format, a...) + } +} + +type StatusResponse struct { + Images struct { + Quota Quota + } + Sounds struct { + Quota Quota + } +} + +func (c *Client) Status() (*StatusResponse, error) { + req, err := http.NewRequest("GET", "https://dialogs.yandex.net/api/v1/status", nil) + if err != nil { + return nil, err + } + + var res StatusResponse + if err = c.do(req, &res); err != nil { + return nil, err + } + return &res, nil +} + +func (c *Client) UploadSound(name string, r io.Reader) (*Sound, error) { + var buf bytes.Buffer + mw := multipart.NewWriter(&buf) + fw, err := mw.CreateFormFile("file", name) + if err != nil { + return nil, err + } + if _, err = io.Copy(fw, r); err != nil { + return nil, err + } + if err = mw.Close(); err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", "https://dialogs.yandex.net/api/v1/skills/"+c.SkillID+"/sounds", &buf) + if err != nil { + return nil, err + } + req.Header.Add("Content-Type", mw.FormDataContentType()) + + var res struct { + Sound Sound + } + if err = c.do(req, &res); err != nil { + return nil, err + } + return &res.Sound, nil +} + +func (c *Client) UploadSoundFile(filename string) (*Sound, error) { + f, err := os.Open(filename) + if err != nil { + return nil, err + } + defer f.Close() //nolint:errcheck + + return c.UploadSound(filepath.Base(filename), f) +} + +func (c *Client) ListSounds() ([]Sound, error) { + req, err := http.NewRequest("GET", "https://dialogs.yandex.net/api/v1/skills/"+c.SkillID+"/sounds", nil) + if err != nil { + return nil, err + } + + var res struct { + Sounds []Sound + Total int + } + if err = c.do(req, &res); err != nil { + return nil, err + } + return res.Sounds, nil +} diff --git a/resources/client_test.go b/resources/client_test.go new file mode 100644 index 0000000..eb9e5a5 --- /dev/null +++ b/resources/client_test.go @@ -0,0 +1,60 @@ +package resources + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestClient(t *testing.T) { + if testing.Short() { + t.Skip("-short is passed, skipping integration test") + } + + skillID := os.Getenv("ALICE_TEST_SKILL_ID") + oAuthToken := os.Getenv("ALICE_TEST_OAUTH_TOKEN") + if skillID == "" || oAuthToken == "" { + t.Skip("`ALICE_TEST_SKILL_ID` or `ALICE_TEST_OAUTH_TOKEN` is not set, skipping integration test") + } + + c := Client{ + SkillID: skillID, + OAuthToken: oAuthToken, + Indent: true, + StrictDecoder: true, + } + + t.Run("Status", func(t *testing.T) { + c.Debugf = t.Logf + + status, err := c.Status() + require.NoError(t, err) + assert.Equal(t, 104857600, status.Images.Quota.Total) + assert.Equal(t, 1073741824, status.Sounds.Quota.Total) + }) + + t.Run("Sound", func(t *testing.T) { + t.Run("UploadSoundFile", func(t *testing.T) { + c.Debugf = t.Logf + + sound, err := c.UploadSoundFile(filepath.Join("..", "testdata", "go.wav")) + require.NoError(t, err) + require.NotEmpty(t, sound) + assert.NotEmpty(t, skillID, sound.ID) + assert.Equal(t, skillID, sound.SkillID) + assert.Empty(t, sound.Size) + assert.Equal(t, "go.wav", sound.OriginalName) + assert.WithinDuration(t, time.Now(), sound.CreatedAt, 5*time.Second) + assert.False(t, sound.IsProcessed) + assert.Nil(t, sound.Error) + + sounds, err := c.ListSounds() + require.NoError(t, err) + assert.Empty(t, sounds) + }) + }) +}