Repository: robocorp/rpaframework

Issue:
Control Room: Workspace Assets Storage library support
Adds support for the Assets Storage API of Control Room, currently WIP [PR](https://github.com/robocorp/robocloud-logic/pull/3534).

### How to display OpenAPI docs

1. Checkout `CLOUD-3364/api-spec` branch from API spec [PR](https://github.com/robocorp/robocloud-logic/pull/3530).
2. `npm ci`, `npm run openapi:preview` -> surf `:8080`

### New library and keywords

- [x] Library name: `RPA.Robocorp.Storage`
- [x] Create/Update: `${id} =    Set Asset    <name|id>    values={"key": "val"}    files=["path/to/file.ext"]`
- [x] Retrieve:
   - [x] `@{ids} =    List Assets`
   - [x] `$|&{value(s)} =    Get Asset Value(s)    ${id}`
   - [ ] `${path} =    Get Asset File    ${id}    <name>    path=path/to/file.ext`
- [x] Delete: `Delete Asset    ${id}`

#### Notes

- The library will share the philosophy and common grounds of `RPA.Robocorp.WorkItems`.
   - The separation of values vs. files under the keywords.
   - The API requests handler support. (+ env vars to have API access)
   - We'll have two adapters as usual: the `Robocorp` (works with API) and the `File` one (simulates local assets).
      - The `FileAdapter` will be added in a future sister PR. (@fabioz / @kariharju  How difficult is it to add local **Assets Storage** support in VSCode?)
- We operate on IDs only for simplicity and clarity.
   - Only exception is when creating a new asset with `Set Asset    <name>` as we'll get an `${id}` in return.

#### Brainstorming

1. I saw that the "Update" part is still missing from the API, would that look any different from the proposed library functionality? (I'd assume it would be similar to "Create" but operating on an existing resource with `PUT` (so we'd update only what we need))
2. Currently we support key-value setting. What about files and those `* Asset Upload` APIs? (will be left for another PR)
3. How names are supposed to work under the Assets (display purposes only or retrieving capability) and are they required to be unique (as long as we have unique IDs already)?


Discussion:
@cmin764 In VSCode it's usually a matter of passing the proper environment variables in the launch (the groundwork for that it already done for the other features, so, it should be straightforward).

Resolving patch:
diff --git a/packages/main/src/RPA/Robocorp/Storage.py b/packages/main/src/RPA/Robocorp/Storage.py
new file mode 100644
--- /dev/null
+++ b/packages/main/src/RPA/Robocorp/Storage.py
@@ -0,0 +1,152 @@
+from typing import List
+
+from robocorp import storage
+from robot.api.deco import keyword, library
+
+
+@library
+class Storage:
+    """Control Room `Storage Asset` library operating with the cloud built-in key-value
+    store.
+
+    **Usage**
+
+    .. code-block:: robotframework
+
+        *** Tasks ***
+        Manage Assets
+            @{assets} =    List Assets
+            Log List    ${assets}
+
+            Set Asset    my-asset    My string asset value
+            ${value} =      Get Asset       my-asset
+            Log     Asset value: ${value}
+
+            Delete Asset    my-asset
+
+    .. code-block:: python
+
+        import logging
+        from RPA.Robocorp.Storage import Storage
+
+        storage = Storage()
+
+        def manage_assets():
+            assets = storage.list_assets()
+            logging.info(assets)
+
+            storage.set_asset("my-asset", "My string asset value")
+            value = storage.get_asset("my-asset")
+            logging.info("Asset value: %s", value)
+
+            storage.delete_asset("my-asset")
+
+    **Caveats**
+
+    We currently support text values only, therefore you need to serialize other types
+    of values first. (e.g.: dumping a dictionary into a JSON string before setting it)
+    """
+
+    ROBOT_LIBRARY_SCOPE = "GLOBAL"
+    ROBOT_LIBRARY_DOC_FORMAT = "REST"
+
+    @keyword
+    def list_assets(self) -> List[storage.AssetMeta]:
+        """List all the existing assets.
+
+        :returns: A list of assets where each asset is a dictionary with fields like
+            'id' and 'name'
+
+        **Example: Robot Framework**
+
+        .. code-block:: robotframework
+
+            *** Tasks ***
+            Print All Assets
+                @{assets} =    List Assets
+                Log List    ${assets}
+
+        **Example: Python**
+
+        .. code-block:: python
+
+            def print_all_assets():
+                print(storage.list_assets())
+        """
+        return storage.list_assets()
+
+    @keyword
+    def get_asset(self, name: str) -> str:
+        """Get an asset's value by providing its `name`.
+
+        :param name: Name of the asset
+        :raises AssetNotFound: Asset with the given name does not exist
+        :returns: The previously set value of this asset, or empty string if not set
+
+        **Example: Robot Framework**
+
+        .. code-block:: robotframework
+
+            *** Tasks ***
+            Retrieve An Asset
+                ${value} =      Get Asset       my-asset
+                Log     Asset value: ${value}
+
+        **Example: Python**
+
+        .. code-block:: python
+
+            def retrieve_an_asset():
+                value = storage.get_asset("my-asset")
+                print("Asset value:", value)
+        """
+        return storage.get_asset(name)
+
+    @keyword
+    def set_asset(self, name: str, value: str, wait: bool = True):
+        """Creates or updates an asset named `name` with the provided `value`.
+
+        :param name: Name of the existing or new asset to create (if missing)
+        :param value: The new value to set within the asset
+        :param wait: Wait for value to be set successfully
+
+        **Example: Robot Framework**
+
+        .. code-block:: robotframework
+
+            *** Tasks ***
+            Set An Asset
+                Set Asset    my-asset    My string asset value
+
+        **Example: Python**
+
+        .. code-block:: python
+
+            def set_an_asset():
+                storage.set_asset("my-asset", "My string asset value")
+        """
+        storage.set_asset(name, value, wait=wait)
+
+    @keyword
+    def delete_asset(self, name: str):
+        """Delete an asset by providing its `name`.
+
+        :param name: Name of the asset to delete
+        :raises AssetNotFound: Asset with the given name does not exist
+
+        **Example: Robot Framework**
+
+        .. code-block:: robotframework
+
+            *** Tasks ***
+            Remove An Asset
+                Delete Asset    my-asset
+
+        **Example: Python**
+
+        .. code-block:: python
+
+            def remove_an_asset():
+                storage.delete_asset("my-asset")
+        """
+        storage.delete_asset(name)
diff --git a/packages/main/src/RPA/Robocorp/WorkItems.py b/packages/main/src/RPA/Robocorp/WorkItems.py
--- a/packages/main/src/RPA/Robocorp/WorkItems.py
+++ b/packages/main/src/RPA/Robocorp/WorkItems.py
@@ -788,7 +788,7 @@ class WorkItems:
     .. code-block:: robotframework
 
         *** Tasks ***
-        Using Prased Emails
+        Using Parsed Emails
             ${mail} =    Get Work Item Variable    email
             Set Work Item Variables    &{mail}[body]
             ${message} =     Get Work Item Variable     message


Test patch:
diff --git a/packages/main/tests/resources/secrets.yaml b/packages/main/tests/resources/secrets.yaml
--- a/packages/main/tests/resources/secrets.yaml
+++ b/packages/main/tests/resources/secrets.yaml
@@ -1,49 +1,54 @@
 credentials:
-    google:
-        apikey: '1234567890'
-    sap:
-        login: robot
-        password: secret
-
-robocorp:
+  google:
+    apikey: '1234567890'
+  sap:
     login: robot
     password: secret
-    url: http://robocorp.com/
+
+robocorp:
+  login: robot
+  password: secret
+  url: http://robocorp.com/
 
 swaglabs:
-    password: secret_sauce
-    username: standard_user
+  password: secret_sauce
+  username: standard_user
 
 windows:
-    domain: windows
-    login: robot
-    password: secret
+  domain: windows
+  login: robot
+  password: secret
 
 # Learn more about credentials setup: https://robocorp.com/portal/robot/robocorp/example-oauth-email
 gmail:
-    account: cosmin@robocorp.com
-    password: e*** **** **** ***s  # App Password (is_oauth: false)
-#    password: dXNlc*********tSTm1BM0dRQkIBAQ==  # OAuth2 string (is_oauth: true)
-    is_oauth: false
+  account: cosmin@robocorp.com
+  password: e*** **** **** ***s  # App Password (is_oauth: false)
+  #    password: dXNlc*********tSTm1BM0dRQkIBAQ==  # OAuth2 string (is_oauth: true)
+  is_oauth: false
 exchange:
-    account: cosmin-robocorp@ztzvn.onmicrosoft.com
-    password: w*********z
-    client_id: 7*******-****-****-****-********8
-    client_secret: K7v8****************dpS
-    token:
-        access_token: eyJ0e**********WEBtSA6dDw
-        expires_at: 1657035118.5243561
-        expires_in: 5051
-        ext_expires_in: 5051
-        refresh_token: 0.A********************6-OOIwKR
-        scope:
-            - https://outlook.office365.com/EWS.AccessAsUser.All
-            - https://outlook.office365.com/Mail.Read
-            - https://outlook.office365.com/Mail.Send
-            - https://outlook.office365.com/User.Read
-            - https://outlook.office365.com/User.Read.All
-        token_type: Bearer
+  account: cosmin-robocorp@ztzvn.onmicrosoft.com
+  password: w*********z
+  client_id: 7*******-****-****-****-********8
+  client_secret: K7v8****************dpS
+  token:
+    access_token: eyJ0e**********WEBtSA6dDw
+    expires_at: 1657035118.5243561
+    expires_in: 5051
+    ext_expires_in: 5051
+    refresh_token: 0.A********************6-OOIwKR
+    scope:
+      - https://outlook.office365.com/EWS.AccessAsUser.All
+      - https://outlook.office365.com/Mail.Read
+      - https://outlook.office365.com/Mail.Send
+      - https://outlook.office365.com/User.Read
+      - https://outlook.office365.com/User.Read.All
+    token_type: Bearer
 
 document_ai:
   base64ai: cosmin@robocorp.com,17**********c44
   nanonets: Zs**************OEF
+
+asset_storage:
+  api_url: https://api.ci.robocorp.dev/v1/
+  api_key: 1lOn***************************I6As7KciE
+  workspace_id: 4f107208-5f4b-47f2-a510-79936169aa8e
diff --git a/packages/main/tests/robot/test_browser.robot b/packages/main/tests/robot/test_browser.robot
--- a/packages/main/tests/robot/test_browser.robot
+++ b/packages/main/tests/robot/test_browser.robot
@@ -87,6 +87,7 @@ Print page as PDF document
     ...    RPA.FileSystem.Remove File    ${output_path}
 
 Download PDF in custom Chrome directory
+    [Tags]    skip    # flaky test in CI, mainly on Windows with Python 3.7, 3.8
     Download With Specific Browser    Chrome
 
 Download PDF in custom Firefox directory
diff --git a/packages/main/tests/robot/test_robocorp_storage.robot b/packages/main/tests/robot/test_robocorp_storage.robot
new file mode 100644
--- /dev/null
+++ b/packages/main/tests/robot/test_robocorp_storage.robot
@@ -0,0 +1,41 @@
+*** Settings ***
+Library     Collections
+Library     OperatingSystem
+Library     RPA.Robocorp.Storage
+
+Suite Setup     Load Mocked Library
+
+
+*** Variables ***
+${RESOURCES}        ${CURDIR}${/}..${/}resources
+
+
+*** Keywords ***
+Use Local Vault
+    Set Environment Variable    RPA_SECRET_MANAGER    RPA.Robocorp.Vault.FileSecrets
+    Set Environment Variable    RPA_SECRET_FILE       ${RESOURCES}${/}secrets.yaml
+
+    Import Library      RPA.Robocorp.Vault
+
+Load Mocked Library
+    Use Local Vault
+    ${secret} =    Get Secret    asset_storage
+
+    Set Environment Variable    RC_API_URL_V1   ${secret}[api_url]
+    Set Environment Variable    RC_API_KEY      ${secret}[api_key]
+    Set Environment Variable    RC_WORKSPACE_ID     ${secret}[workspace_id]
+
+
+*** Tasks ***
+Manage Assets
+    [Tags]    manual  # since it relies on a real working API key not provided in CI
+    [Setup]     Set Asset   cosmin      cosmin@robocorp.com
+
+    @{assets} =    List Assets
+    @{asset_names} =    Evaluate    [asset['name'] for asset in ${assets}]
+    List Should Contain Value    ${asset_names}    cosmin
+
+    ${value} =      Get Asset   cosmin
+    Should Be Equal As Strings    ${value}    cosmin@robocorp.com
+
+    [Teardown]      Delete Asset    cosmin