Repository: cloudflare/cloudflare-go (go)

Issue:
Add support for video / stream API
### Current cloudflare-go version

v0.32.0

### Description

Please provide the methods for the the [stream](https://developers.cloudflare.com/stream/) API endpoints, like is done for the [Images](https://pkg.go.dev/github.com/cloudflare/cloudflare-go#Image). I've recently included the latter into our project, to great satisfaction, and would like to continue to expand it by hosting videos on CF.

### Use cases

For us, as a starter we would like to use:

- [Direct creator uploads](https://api.cloudflare.com/#stream-videos-create-a-video-and-get-authenticated-direct-upload-url)
- [List videos](https://api.cloudflare.com/#stream-videos-list-videos)
- [Delete video](https://api.cloudflare.com/#stream-videos-delete-video)

### Potential cloudflare-go usage

```go
u, err := cf.CreateStreamDirectUploadURL(ctx, os.Getenv("CLOUDFLARE_ACCOUNT"), cloudflare.StreamDirectUploadURLRequest{
	Expiry: time.Now().Add(30 * time.Minute),
})
```


### References

 #300 (closed by stalebot)
 #393 (closed by stalebot)

Pull request:
Add support for stream API
## Description

Adds support for Cloudflare Stream API

Docs found here: https://api.cloudflare.com/#stream-videos-properties

Note: This does not add support for [upload using TUS](https://api.cloudflare.com/#stream-videos-initiate-a-video-upload-using-tus). It seemed out of scope for this module.

## Has your change been tested?

Unit tests have been added for all functions

## Types of changes

What sort of change does your code introduce/modify?

- [ ] Bug fix (non-breaking change which fixes an issue)
- [X] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to change)

## Checklist:

- [X] My code follows the code style of this project.
- [ ] My change requires a change to the documentation.
- [X] I have updated the documentation accordingly.
- [X] I have added tests to cover my changes.
- [X] All new and existing tests passed.
- [X] This change is using publicly documented (api.cloudflare.com or developers.cloudflare.com) and stable APIs.

Resolves #811 


Patch:
diff --git a/stream.go b/stream.go
new file mode 100644
index 000000000..b49083080
--- /dev/null
+++ b/stream.go
@@ -0,0 +1,444 @@
+package cloudflare
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"fmt"
+	"github.com/pkg/errors"
+	"io"
+	"mime/multipart"
+	"net/http"
+	"os"
+	"time"
+)
+
+var (
+	// ErrMissingUploadURL is for when a URL is required but missing.
+	ErrMissingUploadURL = errors.New("required url missing")
+	// ErrMissingMaxDuration is for when MaxDuration is required but missing.
+	ErrMissingMaxDuration = errors.New("required max duration missing")
+	// ErrMissingVideoID is for when VideoID is required but missing.
+	ErrMissingVideoID = errors.New("required video id missing")
+	// ErrMissingFilePath is for when FilePath is required but missing.
+	ErrMissingFilePath = errors.New("required file path missing")
+)
+
+// StreamVideo represents a stream video.
+type StreamVideo struct {
+	AllowedOrigins        []string                 `json:"allowedOrigins,omitempty"`
+	Created               *time.Time               `json:"created,omitempty"`
+	Duration              int                      `json:"duration,omitempty"`
+	Input                 StreamVideoInput         `json:"input,omitempty"`
+	MaxDurationSeconds    int                      `json:"maxDurationSeconds,omitempty"`
+	Meta                  map[string]interface{}   `json:"meta,omitempty"`
+	Modified              *time.Time               `json:"modified,omitempty"`
+	UploadExpiry          *time.Time               `json:"uploadExpiry,omitempty"`
+	Playback              StreamVideoPlayback      `json:"playback,omitempty"`
+	Preview               string                   `json:"preview,omitempty"`
+	ReadyToStream         bool                     `json:"readyToStream,omitempty"`
+	RequireSignedURLs     bool                     `json:"requireSignedURLs,omitempty"`
+	Size                  int                      `json:"size,omitempty"`
+	Status                StreamVideoStatus        `json:"status,omitempty"`
+	Thumbnail             string                   `json:"thumbnail,omitempty"`
+	ThumbnailTimestampPct float64                  `json:"thumbnailTimestampPct,omitempty"`
+	UID                   string                   `json:"uid,omitempty"`
+	Creator               string                   `json:"creator,omitempty"`
+	LiveInput             string                   `json:"liveInput,omitempty"`
+	Uploaded              *time.Time               `json:"uploaded,omitempty"`
+	Watermark             StreamVideoWatermark     `json:"watermark,omitempty"`
+	NFT                   StreamVideoNFTParameters `json:"nft,omitempty"`
+}
+
+// StreamVideoInput represents the video input values of a stream video.
+type StreamVideoInput struct {
+	Height int `json:"height,omitempty"`
+	Width  int `json:"width,omitempty"`
+}
+
+// StreamVideoPlayback represents the playback URLs for a video.
+type StreamVideoPlayback struct {
+	HLS  string `json:"hls,omitempty"`
+	Dash string `json:"dash,omitempty"`
+}
+
+// StreamVideoStatus represents the status of a stream video.
+type StreamVideoStatus struct {
+	State           string `json:"state,omitempty"`
+	PctComplete     int    `json:"pctComplete,omitempty"`
+	ErrorReasonCode string `json:"errorReasonCode,omitempty"`
+	ErrorReasonText string `json:"errorReasonText,omitempty"`
+}
+
+// StreamVideoWatermark represents a watermark for a stream video.
+type StreamVideoWatermark struct {
+	UID            string     `json:"uid,omitempty"`
+	Size           int        `json:"size,omitempty"`
+	Height         int        `json:"height,omitempty"`
+	Width          int        `json:"width,omitempty"`
+	Created        *time.Time `json:"created,omitempty"`
+	DownloadedFrom string     `json:"downloadedFrom,omitempty"`
+	Name           string     `json:"name,omitempty"`
+	Opacity        float64    `json:"opacity,omitempty"`
+	Padding        float64    `json:"padding,omitempty"`
+	Scale          float64    `json:"scale,omitempty"`
+	Position       string     `json:"position,omitempty"`
+}
+
+// StreamVideoNFTParameters represents a NFT for a stream video.
+type StreamVideoNFTParameters struct {
+	AccountID string
+	VideoID   string
+	Contract  string `json:"contract,omitempty"`
+	Token     int    `json:"token,omitempty"`
+}
+
+// StreamUploadFromURLParameters are the parameters used when uploading a video from URL.
+type StreamUploadFromURLParameters struct {
+	AccountID             string
+	VideoID               string
+	URL                   string                  `json:"url"`
+	Creator               string                  `json:"creator,omitempty"`
+	ThumbnailTimestampPct float64                 `json:"thumbnailTimestampPct,omitempty"`
+	AllowedOrigins        []string                `json:"allowedOrigins,omitempty"`
+	RequiredSignedURLs    bool                    `json:"requiredSignedURLs,omitempty"`
+	Watermark             UploadVideoURLWatermark `json:"watermark,omitempty"`
+}
+
+// StreamCreateVideoParameters are parameters used when creating a video.
+type StreamCreateVideoParameters struct {
+	AccountID             string
+	MaxDurationSeconds    int                     `json:"maxDurationSeconds,omitempty"`
+	Expiry                *time.Time              `json:"expiry,omitempty"`
+	Creator               string                  `json:"creator,omitempty"`
+	ThumbnailTimestampPct float64                 `json:"thumbnailTimestampPct,omitempty"`
+	AllowedOrigins        []string                `json:"allowedOrigins,omitempty"`
+	RequiredSignedURLs    bool                    `json:"requiredSignedURLs,omitempty"`
+	Watermark             UploadVideoURLWatermark `json:"watermark,omitempty"`
+}
+
+// UploadVideoURLWatermark represents UID of an existing watermark.
+type UploadVideoURLWatermark struct {
+	UID string `json:"uid,omitempty"`
+}
+
+// StreamVideoCreate represents parameters returned after creating a video.
+type StreamVideoCreate struct {
+	UploadURL string               `json:"uploadURL,omitempty"`
+	UID       string               `json:"uid,omitempty"`
+	Watermark StreamVideoWatermark `json:"watermark,omitempty"`
+}
+
+// StreamParameters are the basic parameters needed.
+type StreamParameters struct {
+	AccountID string
+	VideoID   string
+}
+
+// StreamUploadFileParameters are parameters needed for file upload of a video.
+type StreamUploadFileParameters struct {
+	AccountID string
+	VideoID   string
+	FilePath  string
+}
+
+// StreamListParameters represents parameters used when listing stream videos.
+type StreamListParameters struct {
+	AccountID     string
+	VideoID       string
+	After         *time.Time `url:"after,omitempty"`
+	Before        *time.Time `url:"before,omitempty"`
+	Creator       string     `url:"creator,omitempty"`
+	IncludeCounts bool       `url:"include_counts,omitempty"`
+	Search        string     `url:"search,omitempty"`
+	Limit         int        `url:"limit,omitempty"`
+	Asc           bool       `url:"asc,omitempty"`
+	Status        string     `url:"status,omitempty"`
+}
+
+// StreamSignedURLParameters represent parameters used when creating a signed URL.
+type StreamSignedURLParameters struct {
+	AccountID    string
+	VideoID      string
+	ID           string             `json:"id,omitempty"`
+	PEM          string             `json:"pem,omitempty"`
+	EXP          int                `json:"exp,omitempty"`
+	NBF          int                `json:"nbf,omitempty"`
+	Downloadable bool               `json:"downloadable,omitempty"`
+	AccessRules  []StreamAccessRule `json:"accessRules,omitempty"`
+}
+
+// StreamVideoResponse represents an API response of a stream video.
+type StreamVideoResponse struct {
+	Response
+	Result StreamVideo `json:"result,omitempty"`
+}
+
+// StreamVideoCreateResponse represents an API response of creating a stream video.
+type StreamVideoCreateResponse struct {
+	Response
+	Result StreamVideoCreate `json:"result,omitempty"`
+}
+
+// StreamListResponse represents the API response from a StreamListRequest.
+type StreamListResponse struct {
+	Response
+	Result []StreamVideo `json:"result,omitempty"`
+	Total  string        `json:"total,omitempty"`
+	Range  string        `json:"range,omitempty"`
+}
+
+// StreamSignedURLResponse represents an API response for a signed URL.
+type StreamSignedURLResponse struct {
+	Response
+	Result struct {
+		Token string `json:"token,omitempty"`
+	}
+}
+
+// StreamAccessRule represents the accessRules when creating a signed URL.
+type StreamAccessRule struct {
+	Type    string   `json:"type"`
+	Country []string `json:"country,omitempty"`
+	Action  string   `json:"action"`
+	IP      []string `json:"ip,omitempty"`
+}
+
+// StreamUploadFromURL send a video URL to it will be downloaded and made available on Stream.
+//
+// API Reference: https://api.cloudflare.com/#stream-videos-upload-a-video-from-a-url
+func (api *API) StreamUploadFromURL(ctx context.Context, params StreamUploadFromURLParameters) (StreamVideo, error) {
+	if params.AccountID == "" {
+		return StreamVideo{}, ErrMissingAccountID
+	}
+
+	if params.URL == "" {
+		return StreamVideo{}, ErrMissingUploadURL
+	}
+
+	uri := fmt.Sprintf("/accounts/%s/stream/copy", params.AccountID)
+
+	res, err := api.makeRequestContext(ctx, http.MethodPost, uri, params)
+	if err != nil {
+		return StreamVideo{}, err
+	}
+
+	var streamVideoResponse StreamVideoResponse
+	if err := json.Unmarshal(res, &streamVideoResponse); err != nil {
+		return StreamVideo{}, err
+	}
+	return streamVideoResponse.Result, nil
+}
+
+// StreamUploadVideoFile uploads a video from a path to the file.
+//
+// API Reference: https://api.cloudflare.com/#stream-videos-upload-a-video-using-a-single-http-request
+func (api *API) StreamUploadVideoFile(ctx context.Context, params StreamUploadFileParameters) (StreamVideo, error) {
+	if params.AccountID == "" {
+		return StreamVideo{}, ErrMissingAccountID
+	}
+
+	if params.FilePath == "" {
+		return StreamVideo{}, ErrMissingFilePath
+	}
+
+	uri := fmt.Sprintf("/accounts/%s/stream", params.AccountID)
+
+	// Create new multipart writer
+	body := &bytes.Buffer{}
+	writer := multipart.NewWriter(body)
+	formFile, err := writer.CreateFormFile("file", params.FilePath)
+	if err != nil {
+		return StreamVideo{}, err
+	}
+	file, err := os.Open(params.FilePath)
+	if err != nil {
+		return StreamVideo{}, err
+	}
+	if _, err := io.Copy(formFile, file); err != nil {
+		return StreamVideo{}, err
+	}
+	if err := writer.Close(); err != nil {
+		return StreamVideo{}, err
+	}
+
+	res, err := api.makeRequestContextWithHeaders(ctx, http.MethodPost, uri, body, http.Header{
+		"Accept":       []string{"application/json"},
+		"Content-Type": []string{writer.FormDataContentType()},
+	})
+	if err != nil {
+		return StreamVideo{}, err
+	}
+
+	var streamVideoResponse StreamVideoResponse
+	if err := json.Unmarshal(res, &streamVideoResponse); err != nil {
+		return StreamVideo{}, err
+	}
+	return streamVideoResponse.Result, nil
+}
+
+// StreamCreateVideoDirectURL creates a video and returns an authenticated URL.
+//
+// API Reference: https://api.cloudflare.com/#stream-videos-create-a-video-and-get-authenticated-direct-upload-url
+func (api *API) StreamCreateVideoDirectURL(ctx context.Context, params StreamCreateVideoParameters) (StreamVideoCreate, error) {
+	if params.AccountID == "" {
+		return StreamVideoCreate{}, ErrMissingAccountID
+	}
+
+	if params.MaxDurationSeconds == 0 {
+		return StreamVideoCreate{}, ErrMissingMaxDuration
+	}
+
+	uri := fmt.Sprintf("/accounts/%s/stream/direct_upload", params.AccountID)
+
+	res, err := api.makeRequestContext(ctx, http.MethodPost, uri, params)
+	if err != nil {
+		return StreamVideoCreate{}, err
+	}
+
+	var streamVideoCreateResponse StreamVideoCreateResponse
+	if err := json.Unmarshal(res, &streamVideoCreateResponse); err != nil {
+		return StreamVideoCreate{}, err
+	}
+	return streamVideoCreateResponse.Result, nil
+}
+
+// StreamListVideos list videos currently in stream.
+//
+// API reference: https://api.cloudflare.com/#stream-videos-list-videos
+func (api *API) StreamListVideos(ctx context.Context, params StreamListParameters) ([]StreamVideo, error) {
+	if params.AccountID == "" {
+		return []StreamVideo{}, ErrMissingAccountID
+	}
+
+	uri := buildURI(fmt.Sprintf("/accounts/%s/stream", params.AccountID), params)
+
+	res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil)
+	if err != nil {
+		return []StreamVideo{}, err
+	}
+
+	var streamListResponse StreamListResponse
+	if err := json.Unmarshal(res, &streamListResponse); err != nil {
+		return []StreamVideo{}, err
+	}
+	return streamListResponse.Result, nil
+}
+
+// Skipped: https://api.cloudflare.com/#stream-videos-initiate-a-video-upload-using-tus
+
+// StreamGetVideo gets the details for a specific video.
+//
+// API Reference: https://api.cloudflare.com/#stream-videos-video-details
+func (api *API) StreamGetVideo(ctx context.Context, options StreamParameters) (StreamVideo, error) {
+	if options.AccountID == "" {
+		return StreamVideo{}, ErrMissingAccountID
+	}
+
+	if options.VideoID == "" {
+		return StreamVideo{}, ErrMissingVideoID
+	}
+
+	uri := fmt.Sprintf("/accounts/%s/stream/%s", options.AccountID, options.VideoID)
+
+	res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil)
+	if err != nil {
+		return StreamVideo{}, err
+	}
+	var streamVideoResponse StreamVideoResponse
+	if err := json.Unmarshal(res, &streamVideoResponse); err != nil {
+		return StreamVideo{}, err
+	}
+	return streamVideoResponse.Result, nil
+}
+
+// StreamEmbedHTML gets an HTML fragment to embed on a web page.
+//
+// API Reference: https://api.cloudflare.com/#stream-videos-embed-code-html
+func (api *API) StreamEmbedHTML(ctx context.Context, options StreamParameters) (string, error) {
+	if options.AccountID == "" {
+		return "", ErrMissingAccountID
+	}
+
+	if options.VideoID == "" {
+		return "", ErrMissingVideoID
+	}
+
+	uri := fmt.Sprintf("/accounts/%s/stream/%s/embed", options.AccountID, options.VideoID)
+
+	res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil)
+
+	if err != nil {
+		return "", err
+	}
+	return string(res), nil
+}
+
+// StreamDeleteVideo deletes a video.
+//
+// API Reference: https://api.cloudflare.com/#stream-videos-delete-video
+func (api *API) StreamDeleteVideo(ctx context.Context, options StreamParameters) error {
+	if options.AccountID == "" {
+		return ErrMissingAccountID
+	}
+
+	if options.VideoID == "" {
+		return ErrMissingVideoID
+	}
+
+	uri := fmt.Sprintf("/accounts/%s/stream/%s", options.AccountID, options.VideoID)
+	if _, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil); err != nil {
+		return err
+	}
+	return nil
+}
+
+// StreamAssociateNFT associates a video to a token and contract address.
+//
+// API Reference: https://api.cloudflare.com/#stream-videos-associate-video-to-an-nft
+func (api *API) StreamAssociateNFT(ctx context.Context, options StreamVideoNFTParameters) (StreamVideo, error) {
+	if options.AccountID == "" {
+		return StreamVideo{}, ErrMissingAccountID
+	}
+
+	if options.VideoID == "" {
+		return StreamVideo{}, ErrMissingVideoID
+	}
+
+	uri := fmt.Sprintf("/accounts/%s/stream/%s", options.AccountID, options.VideoID)
+
+	res, err := api.makeRequestContext(ctx, http.MethodPost, uri, options)
+	if err != nil {
+		return StreamVideo{}, err
+	}
+	var streamVideoResponse StreamVideoResponse
+	if err := json.Unmarshal(res, &streamVideoResponse); err != nil {
+		return StreamVideo{}, err
+	}
+	return streamVideoResponse.Result, nil
+}
+
+// StreamCreateSignedURL creates a signed URL token for a video.
+//
+// API Reference: https://api.cloudflare.com/#stream-videos-associate-video-to-an-nft
+func (api *API) StreamCreateSignedURL(ctx context.Context, params StreamSignedURLParameters) (string, error) {
+	if params.AccountID == "" {
+		return "", ErrMissingAccountID
+	}
+	if params.VideoID == "" {
+		return "", ErrMissingVideoID
+	}
+
+	uri := fmt.Sprintf("/accounts/%s/stream/%s/token", params.AccountID, params.VideoID)
+
+	res, err := api.makeRequestContext(ctx, http.MethodPost, uri, params)
+
+	if err != nil {
+		return "", err
+	}
+	var streamSignedResponse StreamSignedURLResponse
+	if err := json.Unmarshal(res, &streamSignedResponse); err != nil {
+		return "", err
+	}
+	return streamSignedResponse.Result.Token, nil
+}


Test patch:
diff --git a/stream_test.go b/stream_test.go
new file mode 100644
index 000000000..2211d8977
--- /dev/null
+++ b/stream_test.go
@@ -0,0 +1,514 @@
+package cloudflare
+
+import (
+	"context"
+	"fmt"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"net/http"
+	"testing"
+	"time"
+)
+
+const (
+	singleStreamResponse = `
+{
+  "success": true,
+  "errors": [],
+  "messages": [],
+  "result": {
+    "allowedOrigins": [
+      "example.com"
+    ],
+    "created": "2014-01-02T02:20:00Z",
+    "duration": 300,
+    "input": {
+      "height": 1080,
+      "width": 1920
+    },
+    "maxDurationSeconds": 300,
+    "meta": {},
+    "modified": "2014-01-02T02:20:00Z",
+    "uploadExpiry": "2014-01-02T02:20:00Z",
+    "playback": {
+      "hls": "https://videodelivery.net/ea95132c15732412d22c1476fa83f27a/manifest/video.m3u8",
+      "dash": "https://videodelivery.net/ea95132c15732412d22c1476fa83f27a/manifest/video.mpd"
+    },
+    "preview": "https://watch.cloudflarestream.com/ea95132c15732412d22c1476fa83f27a",
+    "readyToStream": true,
+    "requireSignedURLs": true,
+    "size": 4190963,
+    "status": {
+      "state": "inprogress",
+      "pctComplete": 51,
+      "errorReasonCode": "ERR_NON_VIDEO",
+      "errorReasonText": "The file was not recognized as a valid video file."
+    },
+    "thumbnail": "https://videodelivery.net/ea95132c15732412d22c1476fa83f27a/thumbnails/thumbnail.jpg",
+    "thumbnailTimestampPct": 0.529241,
+    "uid": "ea95132c15732412d22c1476fa83f27a",
+    "creator": "creator-id_abcde12345",
+    "liveInput": "fc0a8dc887b16759bfd9ad922230a014",
+    "uploaded": "2014-01-02T02:20:00Z",
+    "watermark": {
+      "uid": "ea95132c15732412d22c1476fa83f27a",
+      "size": 29472,
+      "height": 600,
+      "width": 400,
+      "created": "2014-01-02T02:20:00Z",
+      "downloadedFrom": "https://company.com/logo.png",
+      "name": "Marketing Videos",
+      "opacity": 0.75,
+      "padding": 0.1,
+      "scale": 0.1,
+      "position": "center"
+    },
+    "nft": {
+      "contract": "0x57f1887a8bf19b14fc0d912b9b2acc9af147ea85",
+      "token": 5
+    }
+  }
+}
+`
+	testVideoID = "ea95132c15732412d22c1476fa83f27a"
+)
+
+var (
+	TestVideoStruct = createTestVideo()
+)
+
+func createTestVideo() StreamVideo {
+	created, _ := time.Parse(time.RFC3339, "2014-01-02T02:20:00Z")
+	modified, _ := time.Parse(time.RFC3339, "2014-01-02T02:20:00Z")
+	uploadexpiry, _ := time.Parse(time.RFC3339, "2014-01-02T02:20:00Z")
+	uploaded, _ := time.Parse(time.RFC3339, "2014-01-02T02:20:00Z")
+	return StreamVideo{
+		AllowedOrigins:     []string{"example.com"},
+		Created:            &created,
+		Duration:           300,
+		Input:              StreamVideoInput{Height: 1080, Width: 1920},
+		MaxDurationSeconds: 300,
+		Modified:           &modified,
+		UploadExpiry:       &uploadexpiry,
+		Playback:           StreamVideoPlayback{Dash: "https://videodelivery.net/ea95132c15732412d22c1476fa83f27a/manifest/video.mpd", HLS: "https://videodelivery.net/ea95132c15732412d22c1476fa83f27a/manifest/video.m3u8"},
+		Preview:            "https://watch.cloudflarestream.com/ea95132c15732412d22c1476fa83f27a",
+		ReadyToStream:      true,
+		RequireSignedURLs:  true,
+		Size:               4190963,
+		Status: StreamVideoStatus{
+			State:           "inprogress",
+			PctComplete:     51,
+			ErrorReasonCode: "ERR_NON_VIDEO",
+			ErrorReasonText: "The file was not recognized as a valid video file.",
+		},
+		Thumbnail:             "https://videodelivery.net/ea95132c15732412d22c1476fa83f27a/thumbnails/thumbnail.jpg",
+		ThumbnailTimestampPct: 0.529241,
+		UID:                   "ea95132c15732412d22c1476fa83f27a",
+		Creator:               "creator-id_abcde12345",
+		LiveInput:             "fc0a8dc887b16759bfd9ad922230a014",
+		Uploaded:              &uploaded,
+		Watermark: StreamVideoWatermark{
+			UID:            "ea95132c15732412d22c1476fa83f27a",
+			Size:           29472,
+			Height:         600,
+			Width:          400,
+			Created:        &created,
+			DownloadedFrom: "https://company.com/logo.png",
+			Name:           "Marketing Videos",
+			Opacity:        0.75,
+			Padding:        0.1,
+			Scale:          0.1,
+			Position:       "center",
+		},
+		Meta: map[string]interface{}{},
+		NFT: StreamVideoNFTParameters{
+			Token:    5,
+			Contract: "0x57f1887a8bf19b14fc0d912b9b2acc9af147ea85",
+		},
+	}
+}
+
+func TestStream_StreamUploadFromURL(t *testing.T) {
+	setup()
+	defer teardown()
+
+	mux.HandleFunc("/accounts/"+testAccountID+"/stream/copy", func(w http.ResponseWriter, r *http.Request) {
+		assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method)
+		w.Header().Set("content-type", "application/json")
+		fmt.Fprint(w, singleStreamResponse)
+	})
+
+	// Make sure missing account ID is thrown
+	_, err := client.StreamUploadFromURL(context.Background(), StreamUploadFromURLParameters{})
+	if assert.Error(t, err) {
+		assert.Equal(t, ErrMissingAccountID, err)
+	}
+
+	// Make sure missing upload URL is thrown
+	_, err = client.StreamUploadFromURL(context.Background(), StreamUploadFromURLParameters{AccountID: testAccountID})
+	if assert.Error(t, err) {
+		assert.Equal(t, ErrMissingUploadURL, err)
+	}
+
+	want := TestVideoStruct
+	input := StreamUploadFromURLParameters{
+		AccountID: testAccountID,
+		URL:       "https://example.com/myvideo.mp4",
+	}
+
+	out, err := client.StreamUploadFromURL(context.Background(), input)
+	if assert.NoError(t, err) {
+		assert.Equal(t, out, want, "structs not equal")
+	}
+}
+
+func TestStream_UploadVideoFile(t *testing.T) {
+	setup()
+	defer teardown()
+
+	mux.HandleFunc("/accounts/"+testAccountID+"/stream", func(w http.ResponseWriter, r *http.Request) {
+		assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method)
+		w.Header().Set("content-type", "application/json")
+		fmt.Fprint(w, singleStreamResponse)
+	})
+
+	// Make sure missing account ID is thrown
+	_, err := client.StreamUploadVideoFile(context.Background(), StreamUploadFileParameters{})
+	if assert.Error(t, err) {
+		assert.Equal(t, ErrMissingAccountID, err)
+	}
+
+	// Make sure missing file path is thrown
+	_, err = client.StreamUploadVideoFile(context.Background(), StreamUploadFileParameters{AccountID: testAccountID})
+	if assert.Error(t, err) {
+		assert.Equal(t, ErrMissingFilePath, err)
+	}
+
+	input := StreamUploadFileParameters{AccountID: testAccountID, VideoID: testVideoID, FilePath: "stream_test.go"}
+	out, err := client.StreamUploadVideoFile(context.Background(), input)
+
+	want := TestVideoStruct
+	if assert.NoError(t, err) {
+		assert.Equal(t, out, want, "structs not equal")
+	}
+}
+
+func TestStream_CreateVideoDirectURL(t *testing.T) {
+	setup()
+	defer teardown()
+
+	mux.HandleFunc("/accounts/"+testAccountID+"/stream/direct_upload", func(w http.ResponseWriter, r *http.Request) {
+		assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method)
+		w.Header().Set("content-type", "application/json")
+		fmt.Fprint(w, `
+{
+  "success": true,
+  "errors": [],
+  "messages": [],
+  "result": {
+    "uploadURL": "www.example.com/samplepath",
+    "uid": "ea95132c15732412d22c1476fa83f27a",
+    "watermark": {
+      "uid": "ea95132c15732412d22c1476fa83f27a",
+      "size": 29472,
+      "height": 600,
+      "width": 400,
+      "created": "2014-01-02T02:20:00Z",
+      "downloadedFrom": "https://company.com/logo.png",
+      "name": "Marketing Videos",
+      "opacity": 0.75,
+      "padding": 0.1,
+      "scale": 0.1,
+      "position": "center"
+    }
+  }
+}
+`)
+	})
+
+	// Make sure AccountID is required
+	_, err := client.StreamCreateVideoDirectURL(context.Background(), StreamCreateVideoParameters{})
+
+	if assert.Error(t, err) {
+		assert.Equal(t, ErrMissingAccountID, err)
+	}
+
+	// Make sure MaxDuration is required
+	_, err = client.StreamCreateVideoDirectURL(context.Background(), StreamCreateVideoParameters{AccountID: testAccountID})
+
+	if assert.Error(t, err) {
+		assert.Equal(t, ErrMissingMaxDuration, err)
+	}
+
+	input := StreamCreateVideoParameters{AccountID: testAccountID, MaxDurationSeconds: 300}
+	out, err := client.StreamCreateVideoDirectURL(context.Background(), input)
+
+	created, _ := time.Parse(time.RFC3339, "2014-01-02T02:20:00Z")
+	want := StreamVideoCreate{
+		UploadURL: "www.example.com/samplepath",
+		UID:       "ea95132c15732412d22c1476fa83f27a",
+		Watermark: StreamVideoWatermark{
+			UID:            "ea95132c15732412d22c1476fa83f27a",
+			Size:           29472,
+			Height:         600,
+			Width:          400,
+			Created:        &created,
+			DownloadedFrom: "https://company.com/logo.png",
+			Name:           "Marketing Videos",
+			Opacity:        0.75,
+			Padding:        0.1,
+			Scale:          0.1,
+			Position:       "center",
+		},
+	}
+
+	if assert.NoError(t, err) {
+		assert.Equal(t, out, want, "structs not equal")
+	}
+}
+
+func TestStream_ListVideos(t *testing.T) {
+	setup()
+	defer teardown()
+
+	mux.HandleFunc("/accounts/"+testAccountID+"/stream", func(w http.ResponseWriter, r *http.Request) {
+		assert.Equal(t, http.MethodGet, r.Method, "Expected method 'GET', got %s", r.Method)
+		w.Header().Set("content-type", "application/json")
+		fmt.Fprint(w, `
+{
+  "success": true,
+  "errors": [],
+  "messages": [],
+  "result": [{
+    "allowedOrigins": [
+      "example.com"
+    ],
+    "created": "2014-01-02T02:20:00Z",
+    "duration": 300,
+    "input": {
+      "height": 1080,
+      "width": 1920
+    },
+    "maxDurationSeconds": 300,
+    "meta": {},
+    "modified": "2014-01-02T02:20:00Z",
+    "uploadExpiry": "2014-01-02T02:20:00Z",
+    "playback": {
+      "hls": "https://videodelivery.net/ea95132c15732412d22c1476fa83f27a/manifest/video.m3u8",
+      "dash": "https://videodelivery.net/ea95132c15732412d22c1476fa83f27a/manifest/video.mpd"
+    },
+    "preview": "https://watch.cloudflarestream.com/ea95132c15732412d22c1476fa83f27a",
+    "readyToStream": true,
+    "requireSignedURLs": true,
+    "size": 4190963,
+    "status": {
+      "state": "inprogress",
+      "pctComplete": 51,
+      "errorReasonCode": "ERR_NON_VIDEO",
+      "errorReasonText": "The file was not recognized as a valid video file."
+    },
+    "thumbnail": "https://videodelivery.net/ea95132c15732412d22c1476fa83f27a/thumbnails/thumbnail.jpg",
+    "thumbnailTimestampPct": 0.529241,
+    "uid": "ea95132c15732412d22c1476fa83f27a",
+    "creator": "creator-id_abcde12345",
+    "liveInput": "fc0a8dc887b16759bfd9ad922230a014",
+    "uploaded": "2014-01-02T02:20:00Z",
+    "watermark": {
+      "uid": "ea95132c15732412d22c1476fa83f27a",
+      "size": 29472,
+      "height": 600,
+      "width": 400,
+      "created": "2014-01-02T02:20:00Z",
+      "downloadedFrom": "https://company.com/logo.png",
+      "name": "Marketing Videos",
+      "opacity": 0.75,
+      "padding": 0.1,
+      "scale": 0.1,
+      "position": "center"
+    },
+    "nft": {
+      "contract": "0x57f1887a8bf19b14fc0d912b9b2acc9af147ea85",
+      "token": 5
+    }
+  }]
+}
+`)
+	})
+
+	// Make sure AccountID is required
+	_, err := client.StreamListVideos(context.Background(), StreamListParameters{})
+
+	if assert.Error(t, err) {
+		assert.Equal(t, ErrMissingAccountID, err)
+	}
+
+	out, err := client.StreamListVideos(context.Background(), StreamListParameters{AccountID: testAccountID})
+	want := TestVideoStruct
+
+	if assert.NoError(t, err) {
+		assert.Equal(t, len(out), 1, "length of videos is not one")
+		assert.Equal(t, out[0], want, "structs not equal")
+	}
+}
+
+func TestStream_GetVideo(t *testing.T) {
+	setup()
+	defer teardown()
+	mux.HandleFunc("/accounts/"+testAccountID+"/stream/"+testVideoID, func(w http.ResponseWriter, r *http.Request) {
+		assert.Equal(t, http.MethodGet, r.Method, "Expected method 'GET', got %s", r.Method)
+		w.Header().Set("content-type", "application/json")
+		fmt.Fprint(w, singleStreamResponse)
+	})
+
+	// Make sure AccountID is required
+	_, err := client.StreamGetVideo(context.Background(), StreamParameters{})
+	if assert.Error(t, err) {
+		assert.Equal(t, ErrMissingAccountID, err)
+	}
+
+	// Make sure VideoID is required
+	_, err = client.StreamGetVideo(context.Background(), StreamParameters{AccountID: testAccountID})
+	if assert.Error(t, err) {
+		assert.Equal(t, ErrMissingVideoID, err)
+	}
+
+	input := StreamParameters{AccountID: testAccountID, VideoID: testVideoID}
+	out, err := client.StreamGetVideo(context.Background(), input)
+
+	want := TestVideoStruct
+
+	if assert.NoError(t, err) {
+		assert.Equal(t, want, out, "structs not equal")
+	}
+}
+
+func TestStream_DeleteVideo(t *testing.T) {
+	setup()
+	defer teardown()
+
+	mux.HandleFunc("/accounts/"+testAccountID+"/stream/"+testVideoID, func(w http.ResponseWriter, r *http.Request) {
+		assert.Equal(t, http.MethodDelete, r.Method, "Expected method 'DELETE', got %s", r.Method)
+		w.Header().Set("content-type", "application/json")
+		fmt.Fprint(w, `{
+			"success": true,
+			"errors": [],
+			"messages": [],
+			"result": {}
+		}`)
+	})
+
+	// Make sure AccountID is required
+	err := client.StreamDeleteVideo(context.Background(), StreamParameters{})
+	if assert.Error(t, err) {
+		assert.Equal(t, ErrMissingAccountID, err)
+	}
+
+	// Make sure VideoID is required
+	err = client.StreamDeleteVideo(context.Background(), StreamParameters{AccountID: testAccountID})
+	if assert.Error(t, err) {
+		assert.Equal(t, ErrMissingVideoID, err)
+	}
+
+	input := StreamParameters{AccountID: testAccountID, VideoID: testVideoID}
+	err = client.StreamDeleteVideo(context.Background(), input)
+	require.NoError(t, err)
+}
+
+func TestStream_EmbedHTML(t *testing.T) {
+	setup()
+	defer teardown()
+
+	streamHTML := `<stream id="ea95132c15732412d22c1476fa83f27a"></stream><script data-cfasync="false" defer type="text/javascript" src="https://embed.cloudflarestream.com/embed/we4g.fla9.latest.js"></script>`
+	mux.HandleFunc("/accounts/"+testAccountID+"/stream/"+testVideoID+"/embed", func(w http.ResponseWriter, r *http.Request) {
+		assert.Equal(t, http.MethodGet, r.Method, "Expected method 'GET', got %s", r.Method)
+		w.Header().Set("content-type", "text/html")
+		fmt.Fprint(w, streamHTML)
+	})
+
+	// Make sure AccountID is required
+	_, err := client.StreamEmbedHTML(context.Background(), StreamParameters{})
+	if assert.Error(t, err) {
+		assert.Equal(t, ErrMissingAccountID, err)
+	}
+
+	// Make sure VideoID is required
+	_, err = client.StreamEmbedHTML(context.Background(), StreamParameters{AccountID: testAccountID})
+	if assert.Error(t, err) {
+		assert.Equal(t, ErrMissingVideoID, err)
+	}
+
+	input := StreamParameters{AccountID: testAccountID, VideoID: testVideoID}
+	out, err := client.StreamEmbedHTML(context.Background(), input)
+	if assert.NoError(t, err) {
+		assert.Equal(t, streamHTML, out, "bad html output")
+	}
+}
+
+func TestStream_AssociateNFT(t *testing.T) {
+	setup()
+	defer teardown()
+
+	mux.HandleFunc("/accounts/"+testAccountID+"/stream/"+testVideoID, func(w http.ResponseWriter, r *http.Request) {
+		assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method)
+		fmt.Fprint(w, singleStreamResponse)
+	})
+
+	// Make sure AccountID is required
+	_, err := client.StreamAssociateNFT(context.Background(), StreamVideoNFTParameters{})
+	if assert.Error(t, err) {
+		assert.Equal(t, ErrMissingAccountID, err)
+	}
+
+	// Make sure VideoID is required
+	_, err = client.StreamAssociateNFT(context.Background(), StreamVideoNFTParameters{AccountID: testAccountID})
+	if assert.Error(t, err) {
+		assert.Equal(t, ErrMissingVideoID, err)
+	}
+
+	input := StreamVideoNFTParameters{AccountID: testAccountID, VideoID: testVideoID, Token: 5, Contract: "0x57f1887a8bf19b14fc0d912b9b2acc9af147ea85"}
+	out, err := client.StreamAssociateNFT(context.Background(), input)
+
+	want := TestVideoStruct
+
+	if assert.NoError(t, err) {
+		assert.Equal(t, want, out, "structs not equal")
+	}
+}
+
+func TestStream_CreateSignedURL(t *testing.T) {
+	setup()
+	defer teardown()
+
+	mux.HandleFunc("/accounts/"+testAccountID+"/stream/"+testVideoID+"/token", func(w http.ResponseWriter, r *http.Request) {
+		assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method)
+		fmt.Fprint(w, `{
+  "success": true,
+  "errors": [],
+  "messages": [],
+  "result": {
+    "token": "eyJhbGciOiJSUzI1NiIsImtpZCI6ImU5ZGI5OTBhODI2NjZkZDU3MWM3N2Y5NDRhNWM1YzhkIn0.eyJzdWIiOiJlYTk1MTMyYzE1NzMyNDEyZDIyYzE0NzZmYTgzZjI3YSIsImtpZCI6ImU5ZGI5OTBhODI2NjZkZDU3MWM3N2Y5NDRhNWM1YzhkIiwiZXhwIjoiMTUzNzQ2MDM2NSIsIm5iZiI6IjE1Mzc0NTMxNjUifQ.OZhqOARADn1iubK6GKcn25hN3nU-hCFF5q9w2C4yup0C4diG7aMIowiRpP-eDod8dbAJubsiFuTKrqPcmyCKWYsiv0TQueukqbQlF7HCO1TV-oF6El5-7ldJ46eD-ZQ0XgcIYEKrQOYFF8iDQbqPm3REWd6BnjKZdeVrLzuRaiSnZ9qqFpGu5dfxIY9-nZKDubJHqCr3Imtb211VIG_b9MdtO92JjvkDS-rxT_pkEfTZSafl1OU-98A7KBGtPSJHz2dHORIrUiTA6on4eIXTj9aFhGiir4rSn-rn0OjPRTtJMWIDMoQyE_fwrSYzB7MPuzL2t82BWaEbHZTfixBm5A"
+  }
+}`)
+	})
+
+	// Make sure AccountID is required
+	_, err := client.StreamCreateSignedURL(context.Background(), StreamSignedURLParameters{})
+	if assert.Error(t, err) {
+		assert.Equal(t, ErrMissingAccountID, err)
+	}
+
+	// Make sure VideoID is required
+	_, err = client.StreamCreateSignedURL(context.Background(), StreamSignedURLParameters{AccountID: testAccountID})
+	if assert.Error(t, err) {
+		assert.Equal(t, ErrMissingVideoID, err)
+	}
+
+	input := StreamSignedURLParameters{AccountID: testAccountID, VideoID: testVideoID}
+	out, err := client.StreamCreateSignedURL(context.Background(), input)
+
+	want := "eyJhbGciOiJSUzI1NiIsImtpZCI6ImU5ZGI5OTBhODI2NjZkZDU3MWM3N2Y5NDRhNWM1YzhkIn0.eyJzdWIiOiJlYTk1MTMyYzE1NzMyNDEyZDIyYzE0NzZmYTgzZjI3YSIsImtpZCI6ImU5ZGI5OTBhODI2NjZkZDU3MWM3N2Y5NDRhNWM1YzhkIiwiZXhwIjoiMTUzNzQ2MDM2NSIsIm5iZiI6IjE1Mzc0NTMxNjUifQ.OZhqOARADn1iubK6GKcn25hN3nU-hCFF5q9w2C4yup0C4diG7aMIowiRpP-eDod8dbAJubsiFuTKrqPcmyCKWYsiv0TQueukqbQlF7HCO1TV-oF6El5-7ldJ46eD-ZQ0XgcIYEKrQOYFF8iDQbqPm3REWd6BnjKZdeVrLzuRaiSnZ9qqFpGu5dfxIY9-nZKDubJHqCr3Imtb211VIG_b9MdtO92JjvkDS-rxT_pkEfTZSafl1OU-98A7KBGtPSJHz2dHORIrUiTA6on4eIXTj9aFhGiir4rSn-rn0OjPRTtJMWIDMoQyE_fwrSYzB7MPuzL2t82BWaEbHZTfixBm5A"
+
+	if assert.NoError(t, err) {
+		assert.Equal(t, want, out, "structs not equal")
+	}
+}