github.com/actions-on-google/gactions@v3.2.0+incompatible/api/sdk.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 sdk implements the adapter to talk to Actions SDK server. 16 package sdk 17 18 import ( 19 "archive/zip" 20 "bytes" 21 "context" 22 "encoding/json" 23 "errors" 24 "fmt" 25 "io" 26 "io/ioutil" 27 "net/http" 28 "net/url" 29 "os" 30 "path" 31 "path/filepath" 32 "regexp" 33 "runtime" 34 "strings" 35 "text/tabwriter" 36 "time" 37 38 "github.com/actions-on-google/gactions/api/apiutils" 39 "github.com/actions-on-google/gactions/api/request" 40 "github.com/actions-on-google/gactions/log" 41 "github.com/actions-on-google/gactions/project" 42 "github.com/actions-on-google/gactions/project/studio" 43 "github.com/actions-on-google/gactions/versions" 44 "gopkg.in/yaml.v2" 45 ) 46 47 const ( 48 actionsProdURL = "actions.googleapis.com" 49 actionsConsoleProdURL = "console.actions.google.com" 50 encryptEndpoint = "v2:encryptSecret" 51 decryptEndpoint = "v2:decryptSecret" 52 listSampleProjectsEndpoint = "v2/sampleProjects" 53 // Prod version of CurEnv 54 Prod = "prod" 55 // ProdChannel of AoG release 56 ProdChannel = "actions.channels.Production" 57 // AlphaChannel of AoG release 58 AlphaChannel = "actions.channels.Alpha" 59 // BetaChannel of AoG release 60 BetaChannel = "actions.channels.ClosedBeta" 61 ) 62 63 var ( 64 // CurEnv determines which version of the Actions API to call. 65 CurEnv = Prod 66 consoleAddr = "https://" + urlMap[CurEnv]["consoleURL"] 67 // Consumer holds the string identifying the caller to Google. This is based on a command line flag. 68 Consumer = "" 69 // responseBodyReadTimeout is a time limit to read body of HTTP response after response object is received. 70 responseBodyReadTimeout = 5 * time.Second 71 BuiltInReleaseChannels = map[string]string{ 72 ProdChannel: "prod", 73 } 74 ) 75 76 var urlMap = map[string]map[string]string{ 77 Prod: map[string]string{ 78 "apiURL": actionsProdURL, 79 "consoleURL": actionsConsoleProdURL, 80 }, 81 } 82 83 // CreateVersionHTTPResponse represents the expected fields the CLI expects from the CreateVersion API. 84 // CLI will use those fields to print an output message to a user. All other fields from an API 85 // response will be ignored. 86 type CreateVersionHTTPResponse struct { 87 Name string `json:"name"` 88 } 89 90 // WriteDraftHTTPResponse represents the expected fields the CLI expects from the WriteDraft API. 91 // CLI will use those fields to print an output message to a user. All other fields from an API 92 // response will be ignored. 93 type WriteDraftHTTPResponse struct { 94 Name string `json:"name"` 95 ValidationResults struct { 96 Results []validationResult `json:"results"` 97 } `json:"validationResults"` 98 } 99 100 type validationResult struct { 101 ValidationMessage string `json:"validationMessage"` 102 ValidationContext struct { 103 LanguageCode string `json:"languageCode"` 104 } `json:"validationContext"` 105 } 106 107 // WritePreviewHTTPResponse represents the expected fields the CLI expects from the WritePreview 108 // API. CLI will use those fields to print an output message to a user. All other fields from 109 // an API response will be ignored. 110 type WritePreviewHTTPResponse struct { 111 Name string `json:"name"` 112 ValidationResults struct { 113 Results []validationResult `json:"results"` 114 } `json:"validationResults"` 115 SimulatorURL string `json:"simulatorUrl"` 116 } 117 118 // EncryptSecretHTTPResponse represents the expected fields the CLI expects from the EncryptSecret endpoint. 119 type EncryptSecretHTTPResponse struct { 120 AccountLinkingSecret map[string]interface{} `json:"accountLinkingSecret"` 121 } 122 123 // PublicError represents a public error structure inside of an API response. All other fields 124 // (for example, containing the internal details) will be omitted. 125 type PublicError struct { 126 Error struct { 127 Code int `json:"code,omitempty"` 128 Message string `json:"message,omitempty"` 129 Details []map[string]interface{} `json:"details,omitempty"` 130 } `json:"error,omitempty"` 131 } 132 133 type configFiles struct { 134 ConfigFiles []map[string]interface{} `json:"configFiles"` 135 } 136 137 type dataFiles struct { 138 DataFiles []struct { 139 Filepath string `json:"filePath"` 140 Payload []byte `json:"payload"` 141 ContentType string `json:"contentType"` 142 } `json:"dataFiles"` 143 } 144 145 type streamRecord struct { 146 Files struct { 147 ConfigFiles *configFiles `json:"configFiles"` 148 DataFiles *dataFiles `json:"dataFiles"` 149 } `json:"files"` 150 } 151 152 func httpAddr(endpoint string) string { 153 return "https://" + urlMap[CurEnv]["apiURL"] + "/" + endpoint 154 } 155 156 func writeDraftHTTPEndpoint(projectID string) string { 157 return fmt.Sprintf("v2/projects/%s/draft:write", projectID) 158 } 159 160 func previewHTTPEndpoint(projectID string) string { 161 return fmt.Sprintf("v2/projects/%s/preview:write", projectID) 162 } 163 164 func versionHTTPEndpoint(projectID string) string { 165 return fmt.Sprintf("v2/projects/%s/versions:create", projectID) 166 } 167 168 func readDraftHTTPEndpoint(projectID string) string { 169 return fmt.Sprintf("v2/projects/%s/draft:read", projectID) 170 } 171 172 func readVersionHTTPEndpoint(projectID, versionID string) string { 173 return fmt.Sprintf("v2/projects/%s/versions/%s:read", projectID, versionID) 174 } 175 176 func listReleaseChannelsHTTPEndpoint(projectID string) string { 177 return fmt.Sprintf("v2/projects/%s/releaseChannels", projectID) 178 } 179 180 func listVersionsHTTPEndpoint(projectID string) string { 181 return fmt.Sprintf("v2/projects/%s/versions", projectID) 182 } 183 184 func check(cfgs map[string][]byte) error { 185 if len(cfgs) == 0 { 186 return errors.New("configuration files for your Action were not found") 187 } 188 // base settings file 189 if _, ok := cfgs["settings/settings.yaml"]; !ok { 190 return errors.New("settings/settings.yaml for your Action was not found") 191 } 192 if _, ok := cfgs["manifest.yaml"]; !ok { 193 return errors.New("manifest.yaml for your Action was not found") 194 } 195 return nil 196 } 197 198 func printSize(req map[string]interface{}) { 199 b, err := json.Marshal(req) 200 if err != nil { 201 log.Infof("Tried marshalling request into JSON: %v\n", err) 202 return 203 } 204 log.Infof("Total request size is %v bytes.", len(b)) 205 } 206 207 // sendFilesToServerJSON will stream series of requests based on proj to w. 208 // The function performs client-side streaming via HTTP/JSON. This is done by 209 // sending an array of JSON requests. 210 func sendFilesToServerJSON(p project.Project, w *io.PipeWriter, makeRequest func() map[string]interface{}) (err error) { 211 // Important - must close w to avoid deadlock for the reader end of the pipe. 212 defer func() { 213 // Don't want to overwrite other errors raised in the func. 214 // If any other error happened, then the PipeWriter error is not significant. 215 err2 := w.Close() 216 if err == nil { 217 err = err2 218 } 219 }() 220 files, err := p.Files() 221 if err != nil { 222 return err 223 } 224 configFiles := studio.ConfigFiles(files) 225 dataFiles, err := studio.DataFiles(p) 226 if err != nil { 227 return err 228 } 229 if err := check(configFiles); err != nil { 230 return err 231 } 232 encoder := json.NewEncoder(w) 233 _, err = w.Write([]byte("[")) 234 if err != nil { 235 return err 236 } 237 streamer := request.NewStreamer(configFiles, dataFiles, makeRequest, p.ProjectRoot(), request.MaxChunkSizeBytes-request.Padding) 238 for streamer.HasNext() { 239 req, err := streamer.Next() 240 if err != nil { 241 return err 242 } 243 printSize(req) 244 if err = encoder.Encode(req); err != nil { 245 // Ignore this error because it's possible for this error 246 // to happen when server closed the connection (i.e. the read end of the pipe gets closed) 247 // due to a failing internal server logic after processing of configuration files. 248 log.Infof("Failed to send previous request: %v\n", err) 249 return nil 250 } 251 if streamer.HasNext() { 252 if _, err = w.Write([]byte(",")); err != nil { 253 // Ignore this error because it's possible for this error 254 // to happen when server closed the connection (i.e. the read end of the pipe gets closed) 255 // due to a failing internal server logic after processing of configuration files. 256 log.Infof("Failed to send previous request: %v\n", err) 257 return nil 258 } 259 } 260 } 261 if _, err = w.Write([]byte("]")); err != nil { 262 // Ignore this error because it's possible for this error 263 // to happen when server closed the connection (i.e. the read end of the pipe gets closed) 264 // due to a failing internal server logic after processing of the last data file. 265 log.Infof("Failed to send previous request: %v\n", err) 266 return nil 267 } 268 return err 269 } 270 271 // readBodyWithTimeout reads content from body until EOF is encountered, or timer expired. 272 // Timer starts when this function starts execution. 273 func readBodyWithTimeout(body io.Reader, timeout time.Duration) ([]byte, error) { 274 // buf is initialized with 1 character to ensure a caller (Read) doesn't wait 275 // for EOF to be sent from server. 276 buf := make([]byte, 1) 277 jsonString := "" 278 // Buffered channels should protect against leaked go-routines. 279 errCh := make(chan error, 1) 280 go func() { 281 for { 282 n, err := body.Read(buf) 283 if n > 0 { 284 jsonString += string(buf) 285 } 286 if err != nil { 287 errCh <- err 288 break 289 } 290 } 291 }() 292 select { 293 case <-time.After(timeout): 294 return []byte(jsonString), nil 295 case err := <-errCh: 296 if err == io.EOF { 297 return []byte(jsonString), nil 298 } 299 return nil, err 300 } 301 } 302 303 // postprocessJSONResponse performs error handling of the JSON response, and also processes 304 // specific fields from the response body based on a callback function. 305 func postprocessJSONResponse(resp *http.Response, errCh chan error, proc func(body []byte) error) { 306 body, err := readBodyWithTimeout(resp.Body, responseBodyReadTimeout) 307 if err != nil { 308 errCh <- err 309 return 310 } 311 if resp.StatusCode != 200 { 312 errCh <- parseError(body) 313 return 314 } 315 // proc should perform a response specific processing; e.g. extracting specific fields. Only relevant if 316 // if response code is 200. 317 if err := proc(body); err != nil { 318 errCh <- err 319 } 320 errCh <- nil 321 } 322 323 func parseError(body []byte) error { 324 log.Debugln(string(body)) 325 publicError := &PublicError{} 326 if err := json.NewDecoder(bytes.NewReader(body)).Decode(publicError); err != nil { 327 // This means the error is not a JSON. This happens when the API URL is malformed, and 328 // one platform returns an HTML response. In this case, we print the HTML and disregard the json decoding error. 329 return fmt.Errorf(string(body)) 330 } 331 return fmt.Errorf("Server did not return HTTP 200.\n%v", errorMessage(publicError)) 332 } 333 334 func errorMessage(in *PublicError) string { 335 out := PublicError{} 336 // Only allow details to be surfaced if the error code is 400. 337 // 400 corresponds to gRPC FAILED_PRECONDITION and INVALID_ARGUMENT 338 switch in.Error.Code { 339 case 400: 340 out.Error = in.Error 341 // 403 is returned when user denied the permission to use API, which 342 // is the case when they need to enable the API. The error message 343 // contains a helpful info, including the link to the API manager. 344 case 403, 404: 345 out.Error.Message = in.Error.Message 346 out.Error.Code = in.Error.Code 347 default: 348 out.Error.Message = "Internal error occurred" 349 out.Error.Code = in.Error.Code 350 } 351 b, err := json.MarshalIndent(out, "", " ") 352 if err != nil { 353 log.Warnf("%v\n", err) 354 return "" 355 } 356 return string(b) 357 } 358 359 func printValidationResults(results []validationResult) { 360 w := new(tabwriter.Writer) 361 w.Init(os.Stdout, 2, 4, 2, ' ', 0) 362 fmt.Fprintln(w, " Locale\tValidation Result\t") 363 for _, v := range results { 364 fmt.Fprintf(w, " %v\t%v\t\n", v.ValidationContext.LanguageCode, v.ValidationMessage) 365 } 366 fmt.Fprint(w) 367 w.Flush() 368 } 369 370 func procWriteDraftResponse(body []byte) error { 371 resp := &WriteDraftHTTPResponse{} 372 if err := json.NewDecoder(bytes.NewReader(body)).Decode(resp); err != nil { 373 return errors.New(string(body)) 374 } 375 if len(resp.ValidationResults.Results) > 0 { 376 log.Warnln("Server found validation issues (however, your files were still pushed):") 377 printValidationResults(resp.ValidationResults.Results) 378 } 379 return nil 380 } 381 382 // WriteDraftJSON implements WriteDraft functionality of the SDK server via HTTP/JSON streaming. 383 func WriteDraftJSON(ctx context.Context, proj project.Project) error { 384 clientSecret, err := proj.ClientSecretJSON() 385 if err != nil { 386 return err 387 } 388 client, err := apiutils.NewHTTPClient( 389 ctx, 390 clientSecret, 391 "", 392 ) 393 if err != nil { 394 return err 395 } 396 projectID := proj.ProjectID() 397 log.Outf("Pushing files in the project %q to Actions Console. This may take a few minutes.\n", projectID) 398 requestURL := httpAddr(writeDraftHTTPEndpoint(projectID)) 399 r, w := io.Pipe() 400 errCh := make(chan error, 1) 401 // This goroutine will exit after HTTP call is finished. 402 // The sendFilesToServerJSON below and client.Post communicate via the pipe 403 // and former will keep writing stream of bytes, which client post will 404 // keep reading in a blocking fashion. sendFilesToServerJSON is guaranteed 405 // to close the writer end of the pipe, thus unblocking the reader and allowing 406 // the goroutine to exit. 407 go func() { 408 req, err := http.NewRequest("POST", requestURL, r) 409 if err != nil { 410 errCh <- err 411 return 412 } 413 req.Header.Add("Content-Type", "application/json") 414 // This is done to help server to select the quota attributed to a 415 // projectID (i.e. developer's project), instead of the CLI project. 416 req.Header.Add("X-Goog-User-Project", projectID) 417 addClientHeaders(req) 418 419 resp, err := client.Do(req) 420 421 if err != nil { 422 errCh <- err 423 return 424 } 425 defer resp.Body.Close() 426 postprocessJSONResponse(resp, errCh, func(body []byte) error { 427 return procWriteDraftResponse(body) 428 }) 429 }() 430 if err := sendFilesToServerJSON(proj, w, func() map[string]interface{} { 431 return request.WriteDraft(projectID) 432 }); err != nil { 433 return err 434 } 435 log.Outf("Waiting for server to respond...") 436 err = <-errCh 437 if err != nil { 438 return err 439 } 440 log.DoneMsgln(fmt.Sprintf(`Files were pushed to Actions Console, and you can now view your project with this URL: %v/project/%v/overview. If you want to test your changes, run "gactions deploy preview", or navigate to the Test section in the Console.`, consoleAddr, projectID)) 441 return nil 442 } 443 444 func procWritePreviewResponse(body []byte) (string, error) { 445 resp := &WritePreviewHTTPResponse{} 446 if err := json.NewDecoder(bytes.NewReader(body)).Decode(resp); err != nil { 447 return "", errors.New(string(body)) 448 } 449 if len(resp.ValidationResults.Results) > 0 { 450 log.Warnln("Server found validation issues (however, your files were still pushed):") 451 printValidationResults(resp.ValidationResults.Results) 452 } 453 simulatorURL := resp.SimulatorURL 454 if simulatorURL == "" { 455 log.Warnf("The API response body doesn't contain the simulator link.") 456 } 457 return simulatorURL, nil 458 } 459 460 // WritePreviewJSON implements WritePreview functionality of the SDK server via HTTP/JSON streaming. 461 func WritePreviewJSON(ctx context.Context, proj project.Project, sandbox bool) error { 462 clientSecret, err := proj.ClientSecretJSON() 463 if err != nil { 464 return err 465 } 466 client, err := apiutils.NewHTTPClient(ctx, clientSecret, "") 467 if err != nil { 468 return err 469 } 470 projectID := proj.ProjectID() 471 log.Outf("Deploying files in the project %q to Actions Console for preview. This may take a few minutes.\n", projectID) 472 requestURL := httpAddr(previewHTTPEndpoint(projectID)) 473 r, w := io.Pipe() 474 errCh := make(chan error, 1) 475 var simulatorURL string 476 // This goroutine will exit after HTTP call is finished. 477 // The sendFilesToServerJSON below and client.Post communicate via the pipe 478 // and former will keep writing stream of bytes, which client post will 479 // keep reading in a blocking fashion. sendFilesToServerJSON is guaranteed 480 // to close the writer end of the pipe, thus unblocking the reader and allowing 481 // the goroutine to exit. 482 go func() { 483 req, err := http.NewRequest("POST", requestURL, r) 484 if err != nil { 485 errCh <- err 486 return 487 } 488 req.Header.Add("Content-Type", "application/json") 489 // This is done to help server select the quota attributed to a 490 // projectID (i.e. developer's project), instead of the CLI project. 491 // https://cloud.google.com/storage/docs/xml-api/reference-headers#xgooguserproject 492 req.Header.Add("X-Goog-User-Project", projectID) 493 // Sets timeout because Cloud Function deployment can take 1-2 minutes. 494 const timeoutSec = "180" 495 req.Header.Add("X-Server-Timeout", fmt.Sprintf("%v", timeoutSec)) 496 addClientHeaders(req) 497 498 resp, err := client.Do(req) 499 if err != nil { 500 errCh <- err 501 return 502 } 503 defer resp.Body.Close() 504 postprocessJSONResponse(resp, errCh, func(body []byte) error { 505 v, err := procWritePreviewResponse(body) 506 simulatorURL = v 507 return err 508 }) 509 }() 510 if err := sendFilesToServerJSON(proj, w, func() map[string]interface{} { 511 return request.WritePreview(projectID, sandbox) 512 }); err != nil { 513 return err 514 } 515 log.Outf("Waiting for server to respond. It could take up to 1 minute if your cloud function needs to be redeployed.") 516 err = <-errCh 517 if err != nil { 518 return err 519 } 520 log.DoneMsgln(fmt.Sprintf("You can now test your changes in Simulator with this URL: %s", simulatorURL)) 521 return nil 522 } 523 524 func procCreateVersionResponse(channel string, body []byte) (string, error) { 525 resp := &CreateVersionHTTPResponse{} 526 if err := json.NewDecoder(bytes.NewReader(body)).Decode(resp); err != nil { 527 return "", errors.New(string(body)) 528 } 529 versionIDRegExp := regexp.MustCompile("^projects/[^//]+/versions/(?P<versionID>[^//]+)$") 530 if versionIDMatch := versionIDRegExp.FindStringSubmatch(resp.Name); versionIDMatch == nil { 531 log.Debugln(fmt.Sprintf("version id absent in the response %s returned from the server ", resp.Name)) 532 return "", nil 533 } 534 return versionIDRegExp.FindStringSubmatch(resp.Name)[versionIDRegExp.SubexpIndex("versionID")], nil 535 } 536 537 // CreateVersionJSON implements CreateVersion functionality of the SDK server via HTTP/JSON streaming. 538 func CreateVersionJSON(ctx context.Context, proj project.Project, channel string) error { 539 clientSecret, err := proj.ClientSecretJSON() 540 if err != nil { 541 return err 542 } 543 client, err := apiutils.NewHTTPClient(ctx, clientSecret, "") 544 if err != nil { 545 return err 546 } 547 projectID := proj.ProjectID() 548 log.Outf("Deploying files in the project %q to the %q release channel...", projectID, channel) 549 requestURL := httpAddr(versionHTTPEndpoint(projectID)) 550 r, w := io.Pipe() 551 errCh := make(chan error, 1) 552 var versionID string 553 // This goroutine will exit after HTTP call is finished. 554 // The sendFilesToServerJSON below and client.Post communicate via the pipe 555 // and former will keep writing stream of bytes, which client post will 556 // keep reading in a blocking fashion. sendFilesToServerJSON is guaranteed 557 // to close the writer end of the pipe, thus unblocking the reader and allowing 558 // the goroutine to exit. 559 go func() { 560 req, err := http.NewRequest("POST", requestURL, r) 561 if err != nil { 562 errCh <- err 563 return 564 } 565 req.Header.Add("Content-Type", "application/json") 566 // This is done to help server select the quota attributed to a 567 // projectID (i.e. developer's project), instead of the CLI project. 568 // https://cloud.google.com/storage/docs/xml-api/reference-headers#xgooguserproject 569 req.Header.Add("X-Goog-User-Project", projectID) 570 addClientHeaders(req) 571 572 resp, err := client.Do(req) 573 if err != nil { 574 errCh <- err 575 return 576 } 577 defer resp.Body.Close() 578 // TODO: Change signature of postProcessJSONResponse to return an error, and pipe that error to channel here. 579 postprocessJSONResponse(resp, errCh, func(body []byte) error { 580 v, err := procCreateVersionResponse(channel, body) 581 versionID = v 582 return err 583 }) 584 }() 585 if err := sendFilesToServerJSON(proj, w, func() map[string]interface{} { 586 return request.CreateVersion(projectID, channel) 587 }); err != nil { 588 return err 589 } 590 log.Outf("Waiting for server to respond...") 591 if err := <-errCh; err != nil { 592 return err 593 } 594 if _, ok := BuiltInReleaseChannels[channel]; ok { 595 channel = BuiltInReleaseChannels[channel] 596 } 597 598 log.DoneMsgln(fmt.Sprintf("Version %s has been successfully created and submitted for deployment to %s channel. ", versionID, channel)) 599 return nil 600 } 601 602 func keyInConfigResp(path string) (string, error) { 603 var k string 604 switch { 605 case studio.IsWebhookDefinition(path): 606 k = "webhook" 607 case studio.IsVertical(path): 608 k = "verticalSettings" 609 case studio.IsManifest(path): 610 k = "manifest" 611 case studio.IsActions(path): 612 k = "actions" 613 case studio.IsIntent(path): 614 k = "intent" 615 case studio.IsGlobal(path): 616 k = "globalIntentEvent" 617 case studio.IsScene(path): 618 k = "scene" 619 case studio.IsType(path): 620 k = "type" 621 case studio.IsEntitySet(path): 622 k = "entitySet" 623 case studio.IsPrompt(path): 624 k = "staticPrompt" 625 case studio.IsDeviceFulfillment(path): 626 k = "deviceFulfillment" 627 case studio.IsResourceBundle(path): 628 k = "resourceBundle" 629 case studio.IsSettings(path): 630 k = "settings" 631 case studio.IsAccountLinkingSecret(path): 632 k = "accountLinkingSecret" 633 default: 634 return k, fmt.Errorf("%v is unknown config file type to CLI", path) 635 } 636 return k, nil 637 } 638 639 func receiveConfigFiles(proj project.Project, cfgs *configFiles, force bool, seen map[string]bool) error { 640 for _, cfg := range cfgs.ConfigFiles { 641 p, ok := cfg["filePath"] 642 if !ok { 643 return fmt.Errorf("%v doesn't have required filePath field", cfg) 644 } 645 path, ok := p.(string) 646 if !ok { 647 return fmt.Errorf("%v has a key of %v of incorrect type %T, want string", cfg, p, p) 648 } 649 k, err := keyInConfigResp(path) 650 if err != nil { 651 return err 652 } 653 v := cfg[k] 654 // Transform v into YAML. 655 mp, ok := v.(map[string]interface{}) 656 if !ok { 657 return fmt.Errorf("%v has a key %v of incorrect type %T", cfg, v, v) 658 } 659 b, err := yaml.Marshal(mp) 660 if err != nil { 661 return err 662 } 663 // TODO: Can be spun as go-routine. 664 if err := studio.WriteToDisk(proj, path, "", b, force); err != nil { 665 return err 666 } 667 seen[path] = true 668 } 669 return nil 670 } 671 672 func receiveDataFiles(proj project.Project, dfs *dataFiles, force bool, seen map[string]bool) error { 673 for _, df := range dfs.DataFiles { 674 if err := studio.WriteToDisk(proj, df.Filepath, df.ContentType, df.Payload, force); err != nil { 675 return err 676 } 677 if df.ContentType != "application/zip;zip_type=cloud_function" { 678 seen[df.Filepath] = true 679 continue 680 } 681 // WriteToDisk will unzip cloud function folder. Need to record the names of extracted files. 682 names, err := namesFromZip(df.Payload) 683 if err != nil { 684 return err 685 } 686 for _, v := range names { 687 fp := path.Join(df.Filepath[:len(df.Filepath)-len(".zip")], v) 688 seen[fp] = true 689 } 690 } 691 return nil 692 } 693 694 func receiveStream(proj project.Project, body io.Reader, force bool, seen map[string]bool) error { 695 dec := json.NewDecoder(body) 696 log.Debugln("Starts processing the stream") 697 // Reads "[". 698 t, err := dec.Token() 699 if err != nil { 700 return err 701 } 702 if t != json.Delim('[') { 703 return fmt.Errorf("expected [ got %v", t) 704 } 705 for dec.More() { 706 var rec streamRecord 707 if err := dec.Decode(&rec); err != nil { 708 return err 709 } 710 if rec.Files.ConfigFiles != nil { 711 if err := receiveConfigFiles(proj, rec.Files.ConfigFiles, force, seen); err != nil { 712 return err 713 } 714 } 715 if rec.Files.DataFiles != nil { 716 if err := receiveDataFiles(proj, rec.Files.DataFiles, force, seen); err != nil { 717 return err 718 } 719 } 720 } 721 // Reads "]". 722 t, err = dec.Token() 723 if err != nil { 724 return err 725 } 726 if t != json.Delim(']') { 727 return fmt.Errorf("expected ] got %v", t) 728 } 729 log.Debugln("Finished processing the stream") 730 return nil 731 } 732 733 func namesFromZip(content []byte) ([]string, error) { 734 r, err := zip.NewReader(bytes.NewReader(content), int64(len(content))) 735 if err != nil { 736 return nil, err 737 } 738 var names []string 739 for _, f := range r.File { 740 names = append(names, f.Name) 741 } 742 return names, nil 743 } 744 745 func findExtra(a map[string][]byte, b map[string]bool) []string { 746 u := map[string]bool{} 747 for k := range a { 748 u[k] = true 749 } 750 for k := range b { 751 u[k] = true 752 } 753 // flag f in a, that are not in b 754 var extra []string 755 for k := range u { 756 if _, ok := b[k]; !ok { 757 extra = append(extra, k) 758 } 759 } 760 return extra 761 } 762 763 func addClientHeaders(req *http.Request) { 764 if Consumer != "" { 765 req.Header.Add("Gactions-Consumer", Consumer) 766 } 767 ua := fmt.Sprintf("gactions/%s (%s %s)", versions.CliVersion, runtime.GOOS, runtime.GOARCH) 768 req.Header.Add("User-Agent", ua) 769 } 770 771 func parseEncryptionKeyVersion(files map[string][]byte) string { 772 type secretFile struct { 773 EncryptionKeyVersion string `yaml:"encryptionKeyVersion"` 774 } 775 in, ok := files["settings/accountLinkingSecret.yaml"] 776 if !ok { 777 return "" 778 } 779 f := secretFile{} 780 if err := yaml.Unmarshal(in, &f); err != nil { 781 return "" 782 } 783 return f.EncryptionKeyVersion 784 } 785 786 // ReadDraftJSON implements ReadDraft functionality of SDK server. 787 func ReadDraftJSON(ctx context.Context, proj project.Project, force bool, clean bool) error { 788 client, err := setupClient(ctx, proj) 789 if err != nil { 790 return err 791 } 792 projectID := proj.ProjectID() 793 log.Outf("Pulling files in the project %q from Actions Console...\n", projectID) 794 requestURL := httpAddr(readDraftHTTPEndpoint(projectID)) 795 warn := "%v is not present in the draft of your Action" 796 files, err := proj.Files() 797 if err != nil { 798 return err 799 } 800 body, err := json.Marshal(request.ReadDraft(projectID, parseEncryptionKeyVersion(files))) 801 if err != nil { 802 return err 803 } 804 return sendRequest(client, requestURL, body, files, proj, warn, force, clean) 805 } 806 807 func procEncryptSecretResponse(proj project.Project, body []byte) error { 808 r := EncryptSecretHTTPResponse{} 809 if err := json.Unmarshal(body, &r); err != nil { 810 return err 811 } 812 b, err := yaml.Marshal(r.AccountLinkingSecret) 813 if err != nil { 814 return err 815 } 816 if err := studio.WriteToDisk(proj, "settings/accountLinkingSecret.yaml", "", b, false); err != nil { 817 return err 818 } 819 log.DoneMsgln(fmt.Sprintf("Encrypted secret is in %s", filepath.Join(proj.ProjectRoot(), "settings", "accountLinkingSecret.yaml"))) 820 return nil 821 } 822 823 // EncryptSecretJSON implements Encrypt functionality of SDK server. 824 func EncryptSecretJSON(ctx context.Context, proj project.Project, secret string) error { 825 clientSecret, err := proj.ClientSecretJSON() 826 if err != nil { 827 return err 828 } 829 client, err := apiutils.NewHTTPClient(ctx, clientSecret, "") 830 if err != nil { 831 return err 832 } 833 log.Outf("Encrypting your client secret...") 834 // Using a channel and goroutine is not ideal here, but this allows one to 835 // reuse postprocessJSONResponse function. 836 // Should to refactor postprocessJSONResponse to avoid channels. 837 errCh := make(chan error, 1) 838 go func() { 839 requestURL := httpAddr(encryptEndpoint) 840 body, err := json.Marshal(request.EncryptSecret(secret)) 841 if err != nil { 842 errCh <- err 843 } 844 req, err := http.NewRequest("POST", requestURL, bytes.NewReader(body)) 845 if err != nil { 846 errCh <- err 847 } 848 req.Header.Add("Content-Type", "application/json") 849 addClientHeaders(req) 850 resp, err := client.Do(req) 851 if err != nil { 852 errCh <- err 853 } 854 defer resp.Body.Close() 855 postprocessJSONResponse(resp, errCh, func(body []byte) error { 856 return procEncryptSecretResponse(proj, body) 857 }) 858 }() 859 if err := <-errCh; err != nil { 860 return err 861 } 862 return nil 863 } 864 865 func procDecryptSecretResponse(proj project.Project, body []byte, out string) error { 866 type resp struct { 867 ClientSecret string `json:"clientSecret"` 868 } 869 r := resp{} 870 if err := json.Unmarshal(body, &r); err != nil { 871 return err 872 } 873 rel, err := filepath.Rel(proj.ProjectRoot(), out) 874 if err != nil { 875 return err 876 } 877 if err := studio.WriteToDisk(proj, rel, "", []byte(r.ClientSecret), false); err != nil { 878 return err 879 } 880 log.Warnf("Decrypted key will be stored at %s. Committing this file to source control is not recommend.\n", out) 881 log.DoneMsgln(fmt.Sprintf("Decrypted client secret key is in %s.", out)) 882 return nil 883 } 884 885 // DecryptSecretJSON implements Decrypt functionality of SDK server. 886 func DecryptSecretJSON(ctx context.Context, proj project.Project, secret string, out string) error { 887 clientSecret, err := proj.ClientSecretJSON() 888 if err != nil { 889 return err 890 } 891 client, err := apiutils.NewHTTPClient(ctx, clientSecret, "") 892 if err != nil { 893 return err 894 } 895 log.Outf("Decrypting your client secret...") 896 requestURL := httpAddr(decryptEndpoint) 897 body, err := json.Marshal(request.DecryptSecret(secret)) 898 if err != nil { 899 return err 900 } 901 req, err := http.NewRequest("POST", requestURL, bytes.NewReader(body)) 902 if err != nil { 903 return err 904 } 905 req.Header.Add("Content-Type", "application/json") 906 addClientHeaders(req) 907 resp, err := client.Do(req) 908 if err != nil { 909 return err 910 } 911 defer resp.Body.Close() 912 // Using a channel and goroutine is not ideal here, but this allows one to 913 // reuse postprocessJSONResponse function. 914 // Should to refactor postprocessJSONResponse to avoid channels. 915 errCh := make(chan error, 1) 916 postprocessJSONResponse(resp, errCh, func(body []byte) error { 917 return procDecryptSecretResponse(proj, body, out) 918 }) 919 return <-errCh 920 } 921 922 func sendListRequest(pageToken, requestURL string, client *http.Client) ([]byte, error) { 923 // List API must not have a body, so encoding request fields into a URL. 924 u, err := url.Parse(requestURL) 925 if err != nil { 926 return nil, err 927 } 928 q := u.Query() 929 q.Set("pageToken", pageToken) 930 u.RawQuery = q.Encode() 931 requestURL = u.String() 932 req, err := http.NewRequest("GET", requestURL, nil) 933 if err != nil { 934 return nil, err 935 } 936 addClientHeaders(req) 937 resp, err := client.Do(req) 938 if err != nil { 939 return nil, err 940 } 941 defer resp.Body.Close() 942 body, err := ioutil.ReadAll(resp.Body) 943 if err != nil { 944 return nil, err 945 } 946 if resp.StatusCode != 200 { 947 return nil, parseError(body) 948 } 949 return body, nil 950 } 951 952 // ListSampleProjectsJSON implements ListSampleProjects endpoint of SDK server. 953 func ListSampleProjectsJSON(ctx context.Context, proj project.Project) ([]project.SampleProject, error) { 954 clientSecret, err := proj.ClientSecretJSON() 955 if err != nil { 956 return nil, err 957 } 958 client, err := apiutils.NewHTTPClient(ctx, clientSecret, "") 959 if err != nil { 960 return nil, err 961 } 962 requestURL := httpAddr(listSampleProjectsEndpoint) 963 var res []project.SampleProject 964 pageToken := "" 965 966 for { 967 body, err := sendListRequest(pageToken, requestURL, client) 968 if err != nil { 969 return nil, err 970 } 971 type listSampleProjectsResponse struct { 972 SampleProjects []project.SampleProject `json:"sampleProjects"` 973 NextPageToken string `json:"nextPageToken"` 974 } 975 r := listSampleProjectsResponse{} 976 if err = json.Unmarshal(body, &r); err != nil { 977 return nil, err 978 } 979 pageToken = r.NextPageToken 980 for _, v := range r.SampleProjects { 981 // API returns sampleProjects/{sampleName}. 982 v.Name = strings.TrimPrefix(v.Name, "sampleProjects/") 983 res = append(res, v) 984 } 985 if pageToken == "" { 986 break 987 } 988 } 989 return res, nil 990 } 991 992 // ReadVersionJSON implements ReadVersion functionality of SDK server. 993 func ReadVersionJSON(ctx context.Context, proj project.Project, force bool, clean bool, versionID string) error { 994 client, err := setupClient(ctx, proj) 995 if err != nil { 996 return err 997 } 998 999 projectID := proj.ProjectID() 1000 log.Outf("Pulling version %q of the project %q from Actions Console...\n", versionID, projectID) 1001 requestURL := httpAddr(readVersionHTTPEndpoint(projectID, versionID)) 1002 warning := "%v is not present in the version of your Action" 1003 1004 files, err := proj.Files() 1005 if err != nil { 1006 return err 1007 } 1008 body, err := json.Marshal(request.ReadVersion(projectID, parseEncryptionKeyVersion(files))) 1009 if err != nil { 1010 return err 1011 } 1012 1013 return sendRequest(client, requestURL, body, files, proj, warning, force, clean) 1014 } 1015 1016 func setupClient(ctx context.Context, proj project.Project) (*http.Client, error) { 1017 clientSecret, err := proj.ClientSecretJSON() 1018 if err != nil { 1019 return nil, err 1020 } 1021 client, err := apiutils.NewHTTPClient(ctx, clientSecret, "") 1022 if err != nil { 1023 return nil, err 1024 } 1025 return client, nil 1026 } 1027 1028 func sendRequest(client *http.Client, requestURL string, body []byte, files map[string][]byte, proj project.Project, warning string, force, clean bool) error { 1029 projectID := proj.ProjectID() 1030 1031 req, err := http.NewRequest("POST", requestURL, bytes.NewReader(body)) 1032 if err != nil { 1033 return err 1034 } 1035 req.Header.Add("Content-Type", "application/json") 1036 // This is done to help server select the quota attributed to a 1037 // projectID (i.e. developer's project), instead of the CLI project. 1038 // https://cloud.google.com/storage/docs/xml-api/reference-headers#xgooguserproject 1039 req.Header.Add("X-Goog-User-Project", projectID) 1040 addClientHeaders(req) 1041 resp, err := client.Do(req) 1042 if err != nil { 1043 return err 1044 } 1045 defer resp.Body.Close() 1046 if resp.StatusCode != 200 { 1047 // In case of an error, it's okay to read entire response body because 1048 // it will be small. 1049 body, err := readBodyWithTimeout(resp.Body, responseBodyReadTimeout) 1050 if err != nil { 1051 return err 1052 } 1053 log.Debugln(string(body)) 1054 publicErrors := []PublicError{} 1055 if err := json.NewDecoder(bytes.NewReader(body)).Decode(&publicErrors); err != nil { 1056 // This means the error is not a JSON. This happens when the API URL is malformed, and 1057 // one platform returns an HTML response. In this case, we print the HTML and disregard the json decoding error. 1058 return fmt.Errorf(string(body)) 1059 } 1060 if len(publicErrors) > 0 { 1061 return fmt.Errorf("server did not return HTTP 200\n%v", errorMessage(&publicErrors[0])) 1062 } 1063 return errors.New("server did not return HTTP 200") 1064 } 1065 seen := map[string]bool{} 1066 if err := receiveStream(proj, resp.Body, force, seen); err != nil { 1067 return err 1068 } 1069 extra := findExtra(files, seen) 1070 for _, v := range extra { 1071 fp := filepath.Join(proj.ProjectRoot(), filepath.FromSlash(v)) 1072 warn := fmt.Sprintf(warning, fp) 1073 if clean { 1074 log.Warnf("%v. Removing %v.\n", warn, fp) 1075 if err := os.RemoveAll(fp); err != nil { 1076 return err 1077 } 1078 } else { 1079 log.Warnf("%v. To remove, run pull with --clean flag.\n", warn) 1080 } 1081 } 1082 return nil 1083 } 1084 1085 // ListReleaseChannelsJSON implements ListReleaseChannels endpoint of SDK server. 1086 func ListReleaseChannelsJSON(ctx context.Context, proj project.Project) ([]project.ReleaseChannel, error) { 1087 clientSecret, err := proj.ClientSecretJSON() 1088 if err != nil { 1089 return nil, err 1090 } 1091 client, err := apiutils.NewHTTPClient(ctx, clientSecret, "") 1092 if err != nil { 1093 return nil, err 1094 } 1095 requestURL := httpAddr(listReleaseChannelsHTTPEndpoint(proj.ProjectID())) 1096 var res []project.ReleaseChannel 1097 pageToken := "" 1098 1099 for { 1100 body, err := sendListRequest(pageToken, requestURL, client) 1101 if err != nil { 1102 return nil, err 1103 } 1104 type listReleaseChannelsResponse struct { 1105 ReleaseChannels []project.ReleaseChannel `json:"releaseChannels"` 1106 NextPageToken string `json:"nextPageToken"` 1107 } 1108 r := listReleaseChannelsResponse{} 1109 if err = json.Unmarshal(body, &r); err != nil { 1110 return nil, err 1111 } 1112 pageToken = r.NextPageToken 1113 for _, v := range r.ReleaseChannels { 1114 // API returns releaseChannels/{releaseChannelName}. 1115 v.Name = strings.TrimPrefix(v.Name, "releaseChannels/") 1116 res = append(res, v) 1117 } 1118 if pageToken == "" { 1119 break 1120 } 1121 } 1122 return res, nil 1123 } 1124 1125 // ListVersionsJSON implements ListVersions endpoint of SDK server. 1126 func ListVersionsJSON(ctx context.Context, proj project.Project) ([]project.Version, error) { 1127 clientSecret, err := proj.ClientSecretJSON() 1128 if err != nil { 1129 return nil, err 1130 } 1131 client, err := apiutils.NewHTTPClient(ctx, clientSecret, "") 1132 if err != nil { 1133 return nil, err 1134 } 1135 requestURL := httpAddr(listVersionsHTTPEndpoint(proj.ProjectID())) 1136 var res []project.Version 1137 pageToken := "" 1138 1139 for { 1140 body, err := sendListRequest(pageToken, requestURL, client) 1141 if err != nil { 1142 return nil, err 1143 } 1144 type listVersionsResponse struct { 1145 Versions []project.Version `json:"versions"` 1146 NextPageToken string `json:"nextPageToken"` 1147 } 1148 r := listVersionsResponse{} 1149 if err := json.Unmarshal(body, &r); err != nil { 1150 return nil, err 1151 } 1152 pageToken = r.NextPageToken 1153 for _, v := range r.Versions { 1154 // API returns versions/{versionName}. 1155 v.ID = strings.TrimPrefix(v.ID, "versions/") 1156 res = append(res, v) 1157 } 1158 if pageToken == "" { 1159 break 1160 } 1161 } 1162 return res, nil 1163 }