diff --git a/.gitignore b/.gitignore index a1338d6..b4529ff 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ # Project-local glide cache, RE: https://github.com/Masterminds/glide/issues/736 .glide/ +vendor/ diff --git a/Gopkg.lock b/Gopkg.lock new file mode 100644 index 0000000..4b5c39e --- /dev/null +++ b/Gopkg.lock @@ -0,0 +1,33 @@ +# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. + + +[[projects]] + name = "github.com/davecgh/go-spew" + packages = ["spew"] + revision = "346938d642f2ec3594ed81d874461961cd0faa76" + version = "v1.1.0" + +[[projects]] + name = "github.com/pkg/errors" + packages = ["."] + revision = "645ef00459ed84a119197bfb8d8205042c6df63d" + version = "v0.8.0" + +[[projects]] + name = "github.com/pmezard/go-difflib" + packages = ["difflib"] + revision = "792786c7400a136282c1664665ae0a8db921c6c2" + version = "v1.0.0" + +[[projects]] + name = "github.com/stretchr/testify" + packages = ["assert"] + revision = "12b6f73e6084dad08a7c6e575284b177ecafbc71" + version = "v1.2.1" + +[solve-meta] + analyzer-name = "dep" + analyzer-version = 1 + inputs-digest = "58efaa4208508718e203a1f5197297f10c024c3adfccb5d6b542a6e561ba9689" + solver-name = "gps-cdcl" + solver-version = 1 diff --git a/Gopkg.toml b/Gopkg.toml new file mode 100644 index 0000000..4548614 --- /dev/null +++ b/Gopkg.toml @@ -0,0 +1,11 @@ +[[constraint]] + name = "github.com/pkg/errors" + version = "0.8.0" + +[[constraint]] + name = "github.com/stretchr/testify" + version = "1.2.1" + +[prune] + go-tests = true + unused-packages = true diff --git a/LICENSE b/LICENSE index ab60297..832936b 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2018 +Copyright (c) 2018 CyberAgent, Inc. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index eaa8729..b1f27de 100644 --- a/README.md +++ b/README.md @@ -1 +1,3 @@ -# goperiscope \ No newline at end of file +# goperiscope + +goperiscope is golang client for Periscope Producer API. \ No newline at end of file diff --git a/client.go b/client.go new file mode 100644 index 0000000..24c9f5d --- /dev/null +++ b/client.go @@ -0,0 +1,303 @@ +package goperiscope + +import ( + "bytes" + "encoding/json" + "fmt" + "log" + "net/http" + "time" + + "github.com/pkg/errors" +) + +type PeriscopeBuilder struct { + urlBase string + useragent string + clientID string + clientSecret string + refreshToken string +} + +func NewBuilder(urlBase, useragent, clientID, clientSecret string) PeriscopeBuilder { + return PeriscopeBuilder{ + urlBase: urlBase, + useragent: useragent, + clientID: clientID, + clientSecret: clientSecret, + } +} + +func (b *PeriscopeBuilder) RefreshToken(t string) *PeriscopeBuilder { + b.refreshToken = t + return b +} + +func (b *PeriscopeBuilder) BuildClient() (Client, error) { + + httpCli := http.Client{ + Timeout: 10 * time.Second, + } + + authCli := newAuthClient(b.urlBase, &httpCli, b.useragent, b.clientID, b.clientSecret) + auth, err := authCli.OAuthRefresh(b.refreshToken) + if err != nil { + return nil, errors.Wrapf(err, "OAuthRefresh is failed") + } + + return newClient(b.urlBase, &httpCli, b.useragent, auth.AccessToken), nil +} + +type AuthClient interface { + OAuthRefresh(refreshToken string) (*OAuthRefreshResponse, error) +} + +type AuthClientImpl struct { + urlBase string + httpCli *http.Client + useragent string + clientID string + clientSecret string +} + +func newAuthClient(urlBase string, httpCli *http.Client, useragent string, clientID string, clientSecret string) AuthClient { + return &AuthClientImpl{ + urlBase: urlBase, + httpCli: httpCli, + useragent: useragent, + clientID: clientID, + clientSecret: clientSecret, + } +} + +func (i AuthClientImpl) OAuthRefresh(refreshToken string) (*OAuthRefreshResponse, error) { + req := OAuthRefreshRequest{ + GrantType: "refresh_token", + ClientID: i.clientID, + ClientSecret: i.clientSecret, + RefreshToken: refreshToken, + } + + var result OAuthRefreshResponse + if err := i.request("POST", "/oauth/token", req, &result); err != nil { + return nil, errors.Wrapf(err, "Periscope /oauth/token is failed") + } + + return &result, nil +} + +func (c AuthClientImpl) request(method, path string, params interface{}, result interface{}) error { + + headers := map[string]string{ + "User-Agent": c.useragent, + } + + body, err := json.Marshal(params) + if err != nil { + return err + } + apiURL := fmt.Sprintf("%s%s", c.urlBase, path) + req, err := http.NewRequest(method, apiURL, bytes.NewBuffer(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + for name, value := range headers { + req.Header.Set(name, value) + } + + // request + resp, err := c.httpCli.Do(req) + if err != nil { + return err + } + defer func() { + if err := resp.Body.Close(); err != nil { + log.Println(err.Error()) + } + }() + + // error handling for status code + if resp.StatusCode >= 300 { + log.Printf("unexpected API Response. statusCode=%d, url=%s", resp.StatusCode, apiURL) + + internalErr := internalError{} + + if err := json.NewDecoder(resp.Body).Decode(&internalErr); err != nil { + return fmt.Errorf( + "JSON parse error [statusCode='%d', err='%v']", resp.StatusCode, err, + ) + } + return NewError(resp.StatusCode, params.(fmt.Stringer), internalErr) + } + + if result == nil { + return nil + } + + if err := json.NewDecoder(resp.Body).Decode(result); err != nil { + return fmt.Errorf( + "JSON parse error [statusCode='%d', err='%v']", resp.StatusCode, err, + ) + } + + return nil +} + +type Client interface { + GetRegion() (*GetRegionResponse, error) + CreateBroadcast(region string, is360 bool) (*CreateBroadcastResponse, error) + PublishBroadcast(broadcastID string, title string, withTweet bool, locale string) (*PublishBroadcastResponse, error) + StopBroadcast(broadcastID string) error + GetBroadcast(broadcastID string) (*Broadcast, error) + DeleteBroadcast(broadcastID string) error +} + +type ClientImpl struct { + urlBase string + httpCli *http.Client + useragent string + accessToken string +} + +func newClient(urlBase string, httpCli *http.Client, useragent string, accessToken string) Client { + return &ClientImpl{ + urlBase: urlBase, + httpCli: httpCli, + useragent: useragent, + accessToken: accessToken, + } +} + +func (i ClientImpl) GetRegion() (*GetRegionResponse, error) { + + var result GetRegionResponse + if err := i.request("GET", "/region", nil, &result); err != nil { + return nil, errors.Wrapf(err, "Periscope /region is failed") + } + return &result, nil +} + +func (i ClientImpl) CreateBroadcast(region string, is360 bool) (*CreateBroadcastResponse, error) { + + req := CreateBroadcastRequest{ + Region: region, + Is360: is360, + } + + var result CreateBroadcastResponse + if err := i.request("POST", "/broadcast/create", req, &result); err != nil { + return nil, errors.Wrapf(err, "Periscope /broadcast/create is failed") + } + + return &result, nil +} + +func (i ClientImpl) PublishBroadcast(broadcastID string, title string, withTweet bool, locale string) (*PublishBroadcastResponse, error) { + + req := PublishBroadcastRequest{ + BroadcaastID: broadcastID, + Title: title, + ShouldNotTweet: !withTweet, + Locale: locale, + } + + var result PublishBroadcastResponse + if err := i.request("POST", "/broadcast/publish", req, &result); err != nil { + return nil, errors.Wrapf(err, "Periscope /broadcast/publish is failed") + } + + return &result, nil +} + +func (i ClientImpl) StopBroadcast(broadcastID string) error { + + req := StopBroadcastRequest{ + BroadcaastID: broadcastID, + } + + if err := i.request("POST", "/broadcast/stop", req, nil); err != nil { + return errors.Wrapf(err, "Periscope /broadcast/stop is failed") + } + + return nil +} + +func (i ClientImpl) GetBroadcast(broadcastID string) (*Broadcast, error) { + var result Broadcast + if err := i.request("GET", fmt.Sprintf("/broadcast?id=%s", broadcastID), nil, &result); err != nil { + return nil, errors.Wrapf(err, "Periscope /region is failed") + } + return &result, nil +} + +func (i ClientImpl) DeleteBroadcast(broadcastID string) error { + req := DeleteBroadcastRequest{ + BroadcaastID: broadcastID, + } + + if err := i.request("POST", "/broadcast/delete", req, nil); err != nil { + return errors.Wrapf(err, "Periscope /broadcast/delete is failed") + } + + return nil +} + +func (c ClientImpl) request(method, path string, params interface{}, result interface{}) error { + + headers := map[string]string{ + "User-Agent": c.useragent, + "Authorization": fmt.Sprintf("Bearer %s", c.accessToken), + } + + body, err := json.Marshal(params) + if err != nil { + return err + } + apiURL := fmt.Sprintf("%s%s", c.urlBase, path) + req, err := http.NewRequest(method, apiURL, bytes.NewBuffer(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + for name, value := range headers { + req.Header.Set(name, value) + } + + // request + resp, err := c.httpCli.Do(req) + if err != nil { + return err + } + defer func() { + if err := resp.Body.Close(); err != nil { + log.Println(err.Error()) + } + }() + + // error handling for status code + if resp.StatusCode >= 300 { + log.Printf("unexpected API Response. statusCode=%d, url=%s", resp.StatusCode, apiURL) + + internalErr := internalError{} + + if err := json.NewDecoder(resp.Body).Decode(&internalErr); err != nil { + return fmt.Errorf( + "JSON parse error [statusCode='%d', err='%v']", resp.StatusCode, err, + ) + } + return NewError(resp.StatusCode, params.(fmt.Stringer), internalErr) + } + + if result == nil { + return nil + } + + if err := json.NewDecoder(resp.Body).Decode(result); err != nil { + return fmt.Errorf( + "JSON parse error [statusCode='%d', err='%v']", resp.StatusCode, err, + ) + } + + return nil +} diff --git a/client_test.go b/client_test.go new file mode 100644 index 0000000..12cd1b9 --- /dev/null +++ b/client_test.go @@ -0,0 +1,322 @@ +package goperiscope + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestOAuthRefresh(t *testing.T) { + + refreshToken := "hoge_refresh_token" + clientID := "hoge_client_id" + clientSecret := "hoge_client_secret" + + method := "POST" + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != method { + t.Errorf("r.Method = '%s', want '%s'", r.Method, method) + } + + correctPath := "/oauth/token" + if r.URL.Path != correctPath { + t.Errorf("r.URL.Path ='%v', want '%v'", r.URL.Path, correctPath) + } + + params := OAuthRefreshRequest{} + if err := json.NewDecoder(r.Body).Decode(¶ms); err != nil { + t.Fatal(err) + } + + assert.Equal(t, "refresh_token", params.GrantType) + assert.Equal(t, clientID, params.ClientID) + assert.Equal(t, clientSecret, params.ClientSecret) + assert.Equal(t, refreshToken, params.RefreshToken) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{ + "access_token": "new_token", + "user": { + "id": "1111", + "twitter_username": "hoge111", + "twitter_id": "11111111", + "username": "hoge_username", + "display_name": "hoge_display_name", + "description": "hoge description", + "profile_image_urls": [{ + "url": "http://example.com/small.png", + "ssl_url": "https://example.com/small.png", + "width": 128, + "height": 90 + }] + }, + "expires_in": 15551999, + "token_type": "Bearer" + }`)) + })) + defer ts.Close() + + httpCli := &http.Client{} + + c := AuthClientImpl{ + urlBase: ts.URL, + httpCli: httpCli, + useragent: "goperiscope test", + clientID: clientID, + clientSecret: clientSecret, + } + + result, err := c.OAuthRefresh(refreshToken) + assert.NoError(t, err) + + assert.Equal(t, "new_token", result.AccessToken) + assert.Equal(t, "1111", result.User.ID) + assert.Equal(t, "hoge111", result.User.TwitterUsername) + assert.Equal(t, "11111111", result.User.TwitterID) + assert.Equal(t, "hoge_display_name", result.User.DisplayName) + assert.Equal(t, "hoge description", result.User.Description) + assert.Equal(t, "http://example.com/small.png", result.User.ProfileImageURLs[0].URL) + assert.Equal(t, "https://example.com/small.png", result.User.ProfileImageURLs[0].SslURL) + assert.Equal(t, uint32(128), result.User.ProfileImageURLs[0].Width) + assert.Equal(t, uint32(90), result.User.ProfileImageURLs[0].Height) + assert.Equal(t, 15551999, result.ExpiresIn) + assert.Equal(t, "Bearer", result.TokenType) +} + +func TestCreateBroadcast(t *testing.T) { + + method := "POST" + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != method { + t.Errorf("r.Method = '%s', want '%s'", r.Method, method) + } + + correctPath := "/broadcast/create" + if r.URL.Path != correctPath { + t.Errorf("r.URL.Path ='%v', want '%v'", r.URL.Path, correctPath) + } + + params := CreateBroadcastRequest{} + if err := json.NewDecoder(r.Body).Decode(¶ms); err != nil { + t.Fatal(err) + } + assert.Equal(t, "ap-northeast-1", params.Region) + assert.Equal(t, false, params.Is360) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{ + "broadcast": { + "id": "hogehogehoge", + "state": "not_started", + "title": "" + }, + "video_access": { + "hls_url": "https://api.pscp.tv/v1/hls?token=hogehogehoge" + }, + "share_url": "https://www.pscp.tv/w/hogehogehoge", + "encoder": { + "stream_key": "hoge_stream_key", + "display_name": "hoge_display_name", + "rtmp_url": "hoge_rtmp_url", + "rtmps_url": "hoge_rtmps_url", + "recommended_configuration": { + "video_codec": "H.264/AVC", + "video_bitrate": 800000, + "framerate": 30, + "keyframe_interval": 3, + "width": 960, + "height": 540, + "audio_codec": "AAC", + "audio_sampling_rate": 44100, + "audio_bitrate": 96000, + "audio_num_channels": 2 + }, + "is_stream_active": false + } + }`)) + })) + defer ts.Close() + + httpCli := &http.Client{} + + c := ClientImpl{ + urlBase: ts.URL, + httpCli: httpCli, + useragent: "goperiscope test", + accessToken: "test-token", + } + + result, err := c.CreateBroadcast("ap-northeast-1", false) + assert.NoError(t, err) + + assert.Equal(t, "hogehogehoge", result.Broadcast.ID) + assert.Equal(t, "not_started", result.Broadcast.State) + assert.Equal(t, "https://api.pscp.tv/v1/hls?token=hogehogehoge", result.VideoAccess.HlsURL) + assert.Equal(t, "https://www.pscp.tv/w/hogehogehoge", result.ShareURL) + assert.Equal(t, "hoge_stream_key", result.Encoder.StreamKey) + assert.Equal(t, "hoge_display_name", result.Encoder.DisplayName) + assert.Equal(t, "hoge_rtmp_url", result.Encoder.RtmpURL) + assert.Equal(t, "hoge_rtmps_url", result.Encoder.RtmpsURL) + assert.Equal(t, "H.264/AVC", result.Encoder.RecommendedConfiguration.VideoCodec) + assert.Equal(t, false, result.Encoder.IsStreamActive) +} + +func TestGetBroadcast(t *testing.T) { + + method := "GET" + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != method { + t.Errorf("r.Method = '%s', want '%s'", r.Method, method) + } + + correctPath := "/broadcast" + if r.URL.Path != correctPath { + t.Errorf("r.URL.Path ='%v', want '%v'", r.URL.Path, correctPath) + } + + assert.Equal(t, "broadcast_id", r.URL.Query().Get("id")) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"id":"broadcast_id","state":"not_started","title":"title"}`)) + })) + defer ts.Close() + + httpCli := &http.Client{} + + c := ClientImpl{ + urlBase: ts.URL, + httpCli: httpCli, + useragent: "goperiscope test", + accessToken: "test-token", + } + + result, err := c.GetBroadcast("broadcast_id") + assert.NoError(t, err) + assert.Equal(t, "broadcast_id", result.ID) + assert.Equal(t, "not_started", result.State) + assert.Equal(t, "title", result.Title) +} + +func TestPublishBroadcast(t *testing.T) { + method := "POST" + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != method { + t.Errorf("r.Method = '%s', want '%s'", r.Method, method) + } + + correctPath := "/broadcast/publish" + if r.URL.Path != correctPath { + t.Errorf("r.URL.Path ='%v', want '%v'", r.URL.Path, correctPath) + } + + params := PublishBroadcastRequest{} + if err := json.NewDecoder(r.Body).Decode(¶ms); err != nil { + t.Fatal(err) + } + assert.Equal(t, "broadcast_id", params.BroadcaastID) + assert.Equal(t, "title", params.Title) + assert.Equal(t, true, params.ShouldNotTweet) + assert.Equal(t, "ja_JP", params.Locale) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"broadcast":{"id":"broadcast_id","state":"running","title":"title"}}`)) + })) + defer ts.Close() + + httpCli := &http.Client{} + + c := ClientImpl{ + urlBase: ts.URL, + httpCli: httpCli, + useragent: "goperiscope test", + accessToken: "test-token", + } + + result, err := c.PublishBroadcast("broadcast_id", "title", false, "ja_JP") + assert.NoError(t, err) + assert.Equal(t, "broadcast_id", result.Broadcast.ID) + assert.Equal(t, "running", result.Broadcast.State) + assert.Equal(t, "title", result.Broadcast.Title) +} + +func TestStopBroadcast(t *testing.T) { + method := "POST" + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != method { + t.Errorf("r.Method = '%s', want '%s'", r.Method, method) + } + + correctPath := "/broadcast/stop" + if r.URL.Path != correctPath { + t.Errorf("r.URL.Path ='%v', want '%v'", r.URL.Path, correctPath) + } + + params := StopBroadcastRequest{} + if err := json.NewDecoder(r.Body).Decode(¶ms); err != nil { + t.Fatal(err) + } + assert.Equal(t, "broadcast_id", params.BroadcaastID) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{}`)) + })) + defer ts.Close() + + httpCli := &http.Client{} + + c := ClientImpl{ + urlBase: ts.URL, + httpCli: httpCli, + useragent: "goperiscope test", + accessToken: "test-token", + } + + err := c.StopBroadcast("broadcast_id") + assert.NoError(t, err) +} + +func TestDeleteBroadcast(t *testing.T) { + + method := "POST" + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != method { + t.Errorf("r.Method = '%s', want '%s'", r.Method, method) + } + + correctPath := "/broadcast/delete" + if r.URL.Path != correctPath { + t.Errorf("r.URL.Path ='%v', want '%v'", r.URL.Path, correctPath) + } + + params := DeleteBroadcastRequest{} + if err := json.NewDecoder(r.Body).Decode(¶ms); err != nil { + t.Fatal(err) + } + assert.Equal(t, "broadcast_id", params.BroadcaastID) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{}`)) + })) + defer ts.Close() + + httpCli := &http.Client{} + + c := ClientImpl{ + urlBase: ts.URL, + httpCli: httpCli, + useragent: "goperiscope test", + accessToken: "test-token", + } + + err := c.DeleteBroadcast("broadcast_id") + assert.NoError(t, err) +} diff --git a/response.go b/response.go new file mode 100644 index 0000000..7650371 --- /dev/null +++ b/response.go @@ -0,0 +1,53 @@ +package goperiscope + +type OAuthRefreshRequest struct { + GrantType string `json:"grant_type"` + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret"` + RefreshToken string `json:"refresh_token"` +} + +type OAuthRefreshResponse struct { + AccessToken string `json:"access_token"` + User User `json:"user"` + ExpiresIn int `json:"expires_in"` + TokenType string `json:"token_type"` +} + +type CreateBroadcastRequest struct { + Region string `json:"region"` + Is360 bool `json:"is_360"` +} + +type CreateBroadcastResponse struct { + Broadcast Broadcast `json:"broadcast"` + VideoAccess VideoAccess `json:"video_access"` + ShareURL string `json:"share_url"` + Encoder Encoder `json:"encoder"` +} + +type PublishBroadcastRequest struct { + BroadcaastID string `json:"broadcast_id"` + Title string `json:"title"` + ShouldNotTweet bool `json:"should_not_tweet"` + Locale string `json:"locale"` +} + +type PublishBroadcastResponse struct { + Broadcast Broadcast `json:"broadcast"` +} + +type StopBroadcastRequest struct { + BroadcaastID string `json:"broadcast_id"` +} + +type DeleteBroadcastRequest struct { + BroadcaastID string `json:"broadcast_id"` +} + +type DeleteBroadcastResponse struct { +} + +type GetRegionResponse struct { + Region string `json:"region"` +} diff --git a/types.go b/types.go new file mode 100644 index 0000000..885045d --- /dev/null +++ b/types.go @@ -0,0 +1,89 @@ +package goperiscope + +import "fmt" + +type Broadcast struct { + ID string `json:"id"` + State string `json:"state"` + Title string `json:"title"` +} + +type Encoder struct { + StreamKey string `json:"stream_key"` + RtmpURL string `json:"rtmp_url"` + RtmpsURL string `json:"rtmps_url"` + DisplayName string `json:"display_name"` + RecommendedConfiguration StreamConfiguration `json:"recommended_configuration"` + IsStreamActive bool `json:"is_stream_active"` +} + +type StreamConfiguration struct { + VideoCodec string `json:"video_codec"` + VideoBitrate uint32 `json:"video_bitrate"` + Framerate uint32 `json:"framerate"` + KeyframeInterval uint32 `json:"keyframe_interval"` + Width uint32 `json:"width"` + Height uint32 `json:"height"` + AudioCodec string `json:"audio_codec"` + AudioSamplingRate uint32 `json:"audio_sampling_rate"` + AudioBitrate uint32 `json:"audio_bitrate"` + AudioNumChannels uint32 `json:"audio_num_channels"` +} + +type User struct { + ID string `json:"id"` + Username string `json:"username"` + TwitterID string `json:"twitter_id"` + TwitterUsername string `json:"twitter_username"` + Description string `json:"description"` + DisplayName string `json:"display_name"` + ProfileImageURLs []ProfileImageURLs `json:"profile_image_urls"` +} + +type ProfileImageURLs struct { + Width uint32 `json:"width"` + Height uint32 `json:"height"` + SslURL string `json:"ssl_url"` + URL string `json:"url"` +} + +type VideoAccess struct { + HlsURL string `json:"hls_url"` + HTTPSHlsURL string `json:"https_hls_url"` +} + +type internalError struct { + Message string `json:"message"` + DocumentationURL string `json:"documentation_url"` +} + +func (i internalError) String() string { + return fmt.Sprintf("message=%v, documentationURL=%v", i.Message, i.DocumentationURL) +} + +type Error struct { + StatusCode int + Params fmt.Stringer + InternalError fmt.Stringer +} + +func (e Error) Error() string { + return fmt.Sprintf( + `statusCode="%d" params="%s" error="%v"]`, + e.StatusCode, + e.Params, + e.InternalError, + ) +} + +func (e Error) HTTPStatusCode() int { + return e.StatusCode +} + +func NewError(statusCode int, params, internalErr fmt.Stringer) error { + return &Error{ + StatusCode: statusCode, + Params: params, + InternalError: internalErr, + } +}