github.com/actions-on-google/gactions@v3.2.0+incompatible/api/request.go (about)

     1  // Copyright 2020 Google LLC
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //     https://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  // Package request represents an SDK request.
    16  package request
    17  
    18  import (
    19  	"encoding/base64"
    20  	"fmt"
    21  	"mime"
    22  	"path"
    23  	"path/filepath"
    24  	"sort"
    25  
    26  	"github.com/actions-on-google/gactions/api/yamlutils"
    27  	"github.com/actions-on-google/gactions/log"
    28  	"github.com/actions-on-google/gactions/project/studio"
    29  )
    30  
    31  const (
    32  	// MaxChunkSizeBytes specifies the max size limit on JSON payload for a single request/response in the stream.
    33  	// It's enforced by server.
    34  	MaxChunkSizeBytes = 10 * 1024 * 1024
    35  	// Padding accounts for bytes from surrounding JSON fields coming from the request schema;
    36  	// 512 Kb buffer is a very generous upper-bound and I don't think we'll hit it in practice.
    37  	Padding = 512 * 1024
    38  )
    39  
    40  // EncryptSecret returns a map representing a EncryptSecret request populated with clientSecret field.
    41  func EncryptSecret(secret string) map[string]interface{} {
    42  	return map[string]interface{}{
    43  		"clientSecret": secret,
    44  	}
    45  }
    46  
    47  // DecryptSecret returns a map representing a DecryptSecret request populated with encryptedClientSecret field.
    48  func DecryptSecret(secret string) map[string]interface{} {
    49  	return map[string]interface{}{
    50  		"encryptedClientSecret": secret,
    51  	}
    52  }
    53  
    54  // ReadDraft returns a map representing a ReadDraft request populated with name field.
    55  func ReadDraft(name, keyVersion string) map[string]interface{} {
    56  	req := map[string]interface{}{
    57  		"name": fmt.Sprintf("projects/%v/draft", name),
    58  	}
    59  	if keyVersion != "" {
    60  		req["clientSecretEncryptionKeyVersion"] = keyVersion
    61  	}
    62  	return req
    63  }
    64  
    65  // WriteDraft returns a map representing a WriteDraft request populated with name field.
    66  func WriteDraft(name string) map[string]interface{} {
    67  	return map[string]interface{}{
    68  		"parent": fmt.Sprintf("projects/%v", name),
    69  	}
    70  }
    71  
    72  // WritePreview returns a map representing a WriteDraft request populated with name and sandbox fields.
    73  func WritePreview(name string, sandbox bool) map[string]interface{} {
    74  	v := map[string]interface{}{}
    75  	v["parent"] = fmt.Sprintf("projects/%v", name)
    76  	v["previewSettings"] = map[string]interface{}{
    77  		"sandbox": sandbox,
    78  	}
    79  	return v
    80  }
    81  
    82  // CreateVersion returns a map representing a WriteVersion request populated with name and sandbox fields.
    83  func CreateVersion(name string, channel string) map[string]interface{} {
    84  	return map[string]interface{}{
    85  		"parent":          fmt.Sprintf("projects/%v", name),
    86  		"release_channel": channel,
    87  	}
    88  }
    89  
    90  // ReadVersion returns a map representing a ReadVersion request populated with name and versionId fields.
    91  func ReadVersion(name string, versionID string) map[string]interface{} {
    92  	return map[string]interface{}{
    93  		"name": fmt.Sprintf("projects/%v/versions/%v", name, versionID),
    94  	}
    95  }
    96  
    97  // ListReleaseChannels returns a list of release channels with current and pending versions on each channel.
    98  func ListReleaseChannels(name string) map[string]interface{} {
    99  	return map[string]interface{}{
   100  		"parent": fmt.Sprintf("projects/%v", name),
   101  	}
   102  }
   103  
   104  // ListVersions returns a map of versions and their metadata for the project.
   105  func ListVersions(name string) map[string]interface{} {
   106  	return map[string]interface{}{
   107  		"parent": fmt.Sprintf("projects/%v", name),
   108  	}
   109  }
   110  
   111  // addConfigFiles adds configFiles w/o a resource bundle to a request.
   112  func addConfigFiles(req map[string]interface{}, configFiles map[string][]byte, root string) error {
   113  	cfgs := make(map[string][]interface{})
   114  	var keys []string
   115  	for k := range configFiles {
   116  		keys = append(keys, k)
   117  	}
   118  	sort.Strings(keys)
   119  	for _, filename := range keys {
   120  		content := configFiles[filename]
   121  		log.Infof("Adding %v to configFiles request\n", filepath.Join(root, filename))
   122  		mp, err := yamlutils.UnmarshalYAMLToMap(content)
   123  		if err != nil {
   124  			return fmt.Errorf("%v has incorrect syntax: %v", filepath.Join(root, filename), err)
   125  		}
   126  		m := make(map[string]interface{})
   127  		m["filePath"] = filename
   128  		switch {
   129  		case studio.IsAccountLinkingSecret(filename):
   130  			m["accountLinkingSecret"] = mp
   131  		case studio.IsManifest(filename):
   132  			m["manifest"] = mp
   133  		case studio.IsSettings(filename):
   134  			m["settings"] = mp
   135  		case studio.IsActions(filename):
   136  			m["actions"] = mp
   137  		case studio.IsWebhookDefinition(filename):
   138  			m["webhook"] = mp
   139  		case studio.IsIntent(filename):
   140  			m["intent"] = mp
   141  		case studio.IsGlobal(filename):
   142  			m["globalIntentEvent"] = mp
   143  		case studio.IsType(filename):
   144  			m["type"] = mp
   145  		case studio.IsEntitySet(filename):
   146  			m["entitySet"] = mp
   147  		case studio.IsPrompt(filename):
   148  			m["staticPrompt"] = mp
   149  		case studio.IsScene(filename):
   150  			m["scene"] = mp
   151  		case studio.IsVertical(filename):
   152  			m["verticalSettings"] = mp
   153  		// Note: This value is not publicly available
   154  		case studio.IsDeviceFulfillment(filename):
   155  			m["deviceFulfillment"] = mp
   156  		case studio.IsResourceBundle(filename):
   157  			m["resourceBundle"] = mp
   158  		default:
   159  			return fmt.Errorf("failed to add %v to a request", filepath.Join(root, filename))
   160  		}
   161  		cfgs["configFiles"] = append(cfgs["configFiles"], m)
   162  	}
   163  	req["files"] = map[string]interface{}{
   164  		"configFiles": cfgs,
   165  	}
   166  	return nil
   167  }
   168  
   169  // addDataFiles adds a data files from the chunk to a request.
   170  func addDataFiles(req map[string]interface{}, chunk map[string][]byte, root string) error {
   171  	dfs := map[string][]interface{}{}
   172  	for filename, content := range chunk {
   173  		log.Infof("Adding %v to dataFiles request\n", filepath.Join(root, filename))
   174  		if path.Ext(filename) == ".zip" {
   175  			m := map[string]interface{}{
   176  				"filePath":    filename,
   177  				"contentType": "application/zip;zip_type=cloud_function",
   178  				"payload":     content,
   179  			}
   180  			dfs["dataFiles"] = append(dfs["dataFiles"], m)
   181  			continue
   182  		}
   183  		if path.Ext(filename) == ".flr" {
   184  			m := map[string]interface{}{
   185  				"filePath":    filename,
   186  				"contentType": "x-world/x-vrml",
   187  				"payload":     content,
   188  			}
   189  			dfs["dataFiles"] = append(dfs["dataFiles"], m)
   190  			continue
   191  		}
   192  		mime := mime.TypeByExtension(path.Ext(filename))
   193  		switch mime {
   194  		case "audio/mpeg", "image/jpeg", "image/png", "audio/wav", "audio/x-wav":
   195  			{
   196  				m := map[string]interface{}{
   197  					"filePath":    filename,
   198  					"contentType": mime,
   199  					"payload":     content,
   200  				}
   201  				dfs["dataFiles"] = append(dfs["dataFiles"], m)
   202  			}
   203  		default:
   204  			log.Warnf("Can't recognize an extension for %v. The supported extensions are audio/mpeg, image/jpeg, " +
   205  				"image/png, audio/wav, audio/x-wav found %v", filepath.Join(root, filename), mime)
   206  		}
   207  	}
   208  	if len(dfs) > 0 {
   209  		req["files"] = map[string]interface{}{
   210  			"dataFiles": dfs,
   211  		}
   212  	}
   213  	return nil
   214  }
   215  
   216  // SDKStreamer provides an interface to obtain the next JSON request that needs to be sent to
   217  // SDK server during HTTP stream. SDK, ESF and GFE each have their own requirements on the
   218  // payload and this type implements them.
   219  type SDKStreamer struct {
   220  	files           map[string][]byte
   221  	sizes           map[string]int // sizes contains a size that a file occupies in a JSON request
   222  	dataFilenames   []string
   223  	configFilenames []string
   224  	makeRequest     func() map[string]interface{}
   225  	root            string
   226  	i               int // index of current item in configFilesnames
   227  	j               int // index of current item in dataFilenames
   228  	chunkSize       int
   229  }
   230  
   231  // NewStreamer returns an instance of SDKStreamer, initialized with all of the variables
   232  // from its arguments. Function expects configFiles to have at least base settings and manifest files.
   233  func NewStreamer(configFiles map[string][]byte, dataFiles map[string][]byte, makeRequest func() map[string]interface{}, root string, chunkSize int) SDKStreamer {
   234  	files := map[string][]byte{}
   235  	sizes := map[string]int{}
   236  	var cfgnames, dfnames []string
   237  
   238  	for k, v := range configFiles {
   239  		files[k] = v
   240  		cfgnames = append(cfgnames, k)
   241  		sizes[k] = len(v)
   242  	}
   243  	for k, v := range dataFiles {
   244  		files[k] = v
   245  		dfnames = append(dfnames, k)
   246  		// Marshal function of JSON library (https://golang.org/pkg/encoding/json/#Marshal) encodes
   247  		// []byte as a base-64 encoded string. This adds an extra memory overhead when the map is
   248  		// converted to JSON. Each DataFile is []byte, so this is a good approximation.
   249  		sizes[k] = len(base64.StdEncoding.EncodeToString(v))
   250  	}
   251  	// We need to sort config files and datafiles based on their size in bytes.
   252  	// However, settings and manifest files must be inside of the first request,
   253  	// so these two files take precedence.
   254  	sortConfigFiles(cfgnames, files, sizes)
   255  	sort.Slice(dfnames, func(i int, j int) bool {
   256  		return sizes[dfnames[i]] < sizes[dfnames[j]]
   257  	})
   258  
   259  	return SDKStreamer{
   260  		files:           files,
   261  		dataFilenames:   dfnames,
   262  		configFilenames: cfgnames,
   263  		makeRequest:     makeRequest,
   264  		root:            root,
   265  		chunkSize:       chunkSize,
   266  		sizes:           sizes,
   267  	}
   268  }
   269  
   270  func sortConfigFiles(cfgnames []string, files map[string][]byte, sizes map[string]int) {
   271  	var pos []int
   272  	for i, v := range cfgnames {
   273  		if studio.IsSettings(v) || studio.IsManifest(v) {
   274  			pos = append(pos, i)
   275  		}
   276  	}
   277  	moveToFront(cfgnames, pos)
   278  	needSort := cfgnames[len(pos):]
   279  	sort.Slice(needSort, func(i int, j int) bool {
   280  		return sizes[needSort[i]] < sizes[needSort[j]]
   281  	})
   282  	for i, v := range needSort {
   283  		cfgnames[i+len(pos)] = v
   284  	}
   285  }
   286  
   287  func moveToFront(a []string, ps []int) {
   288  	for i := 0; i < len(ps); i++ {
   289  		a[i], a[ps[i]] = a[ps[i]], a[i]
   290  	}
   291  }
   292  
   293  // HasNext returns true if there is still another request in the stream.
   294  func (s SDKStreamer) HasNext() bool {
   295  	return (s.i + s.j) < len(s.files)
   296  }
   297  
   298  // nextChunk returns the next "chunk" of config files such that
   299  // the sum of the size of each individual config file in the chunk
   300  // is less than s.chunkSize.
   301  func (s *SDKStreamer) nextChunk(a []string, next int) map[string][]byte {
   302  	chunk := map[string][]byte{}
   303  	curSize := 0
   304  	i := 0
   305  	for curSize < s.chunkSize && i+next < len(a) {
   306  		name := a[next+i]
   307  		content := s.files[name]
   308  		curSize += s.sizes[name]
   309  		if curSize > s.chunkSize {
   310  			break
   311  		}
   312  		chunk[name] = content
   313  		i++
   314  	}
   315  	return chunk
   316  }
   317  
   318  func (s *SDKStreamer) nextConfigFiles(req map[string]interface{}) error {
   319  	if s.i == 0 {
   320  		log.Outln("Sending configuration files...")
   321  	}
   322  	chunk := s.nextChunk(s.configFilenames, s.i)
   323  	if len(chunk) == 0 {
   324  		return fmt.Errorf("%v exceeds the limit of %v bytes", s.configFilenames[s.i], s.chunkSize)
   325  	}
   326  	if err := addConfigFiles(req, chunk, s.root); err != nil {
   327  		return err
   328  	}
   329  	s.i += len(chunk)
   330  	return nil
   331  }
   332  
   333  func (s *SDKStreamer) nextDataFiles(req map[string]interface{}) error {
   334  	if s.j == 0 {
   335  		log.Outln("Sending resources...")
   336  	}
   337  	chunk := s.nextChunk(s.dataFilenames, s.j)
   338  	if len(chunk) == 0 {
   339  		return fmt.Errorf("%v exceeds the limit of %v bytes", s.dataFilenames[s.j], s.chunkSize)
   340  	}
   341  	if err := addDataFiles(req, chunk, s.root); err != nil {
   342  		return err
   343  	}
   344  	s.j += len(chunk)
   345  	return nil
   346  }
   347  
   348  // Next returns the next request to be sent to SDK server. It implements following requirements:
   349  // 1. Send all config files
   350  //   1a. First request will have manifest and all of the settings files (i.e. localized and base)
   351  //   1b. Each config file request is less than s.chunkSize.
   352  // 2. Send all of data files in one or several requests. Each request will be less than s.chunkSize.
   353  // It will return an error if the size of the payload is larger than s.chunkSize.
   354  func (s *SDKStreamer) Next() (map[string]interface{}, error) {
   355  	req := s.makeRequest()
   356  	if s.i < len(s.configFilenames) {
   357  		if err := s.nextConfigFiles(req); err != nil {
   358  			return nil, err
   359  		}
   360  	} else if s.j < len(s.dataFilenames) {
   361  		if err := s.nextDataFiles(req); err != nil {
   362  			return nil, err
   363  		}
   364  	}
   365  	return req, nil
   366  }