Integrate Cloud path resolver (#9662)

- Closes #9363
- Cleans up the Cloud mock as it got a bit messy. It still implements the bare minimum to be able to test basic secret and auth handling logic 'offline' (added very simple path resolution, only handling the minimum set of cases for the tests to work).
- Adds first implementation of caching Cloud replies.
- Currently only caching the `Enso_User.current`. This is a simple one to cache because we do not expect it to ever change, so it can be safely cached for a long period of time (I chose 2h to make it still refresh from time to time while not being noticeable).
- We may try using this for caching other values in future PRs.
This commit is contained in:
Radosław Waśko 2024-04-12 15:03:09 +02:00 committed by GitHub
parent 4dc7992ab5
commit bdda1830b7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 400 additions and 193 deletions

View File

@ -96,13 +96,17 @@ type Enso_Secret
case is_path of
True ->
if parent != Nothing then Error.throw (Illegal_Argument.Error "Parent argument must be Nothing when resolving by `enso://` path.") else
parsed_path = Enso_Path.parse name
if parsed_path.path_segments.is_empty then Error.throw (Illegal_Argument.Error "The secret path must consist of at least the organization name and the secret name.") else
parsed_name = parsed_path.path_segments.last
parent = Enso_File.Value parsed_path.parent
Enso_Secret.get parsed_name parent
Enso_Secret.resolve_path name
False ->
Enso_Secret.list parent . find s-> s.name == name
effective_parent = parent.if_nothing Enso_File.current_working_directory
secret_path = effective_parent.enso_path.resolve name . to_text
Enso_Secret.resolve_path secret_path
## PRIVATE
resolve_path (path:Text) -> Enso_Secret ! Not_Found =
asset = Existing_Enso_Asset.resolve_path path if_not_found=(Error.throw Not_Found)
if asset.asset_type != Enso_Asset_Type.Secret then Error.throw (Illegal_Argument.Error "The provided path points to "+asset.asset_type.to_text+", not a Secret.") else
Enso_Secret.Value asset.title asset.id
## GROUP Metadata
ICON metadata

View File

@ -1,5 +1,6 @@
import project.Data.Json.JS_Object
import project.Data.Text.Text
import project.Data.Time.Duration.Duration
import project.Data.Vector.Vector
import project.Enso_Cloud.Enso_File.Enso_Asset_Type
import project.Enso_Cloud.Enso_File.Enso_File
@ -30,9 +31,9 @@ type Enso_User
Fetch the current user.
current : Enso_User
current =
# TODO this should be cached for a longer period of time
json = Utils.http_request_as_json HTTP_Method.Get (Utils.cloud_root_uri + "users/me")
Enso_User.from json
Utils.get_cached "users/me" cache_duration=(Duration.new minutes=120) <|
json = Utils.http_request_as_json HTTP_Method.Get (Utils.cloud_root_uri + "users/me")
Enso_User.from json
## ICON people
Lists all known users.

View File

@ -1,6 +1,7 @@
private
import project.Data.Json.JS_Object
import project.Data.Map.Map
import project.Data.Text.Text
import project.Data.Text.Text_Sub_Range.Text_Sub_Range
import project.Data.Time.Date_Time.Date_Time
@ -16,6 +17,7 @@ import project.Errors.File_Error.File_Error
import project.Errors.Illegal_Argument.Illegal_Argument
import project.Errors.Unimplemented.Unimplemented
import project.Network.HTTP.HTTP_Method.HTTP_Method
import project.Runtime.Context
from project.Data.Boolean import Boolean, False, True
from project.Data.Text.Extensions import all
from project.Enso_Cloud.Public_Utils import get_required_field
@ -71,15 +73,23 @@ type Existing_Enso_Asset
Fetches the basic information about an existing file from the Cloud.
It will fail if the file does not exist.
get_asset_reference_for (file : Enso_File) -> Existing_Enso_Asset ! File_Error =
# TODO we will get this from the `resolve_path` endpoint, for now though we use manual resolution
current_user = Enso_User.current
if file.enso_path.organization_name != current_user.organization_name then Unimplemented.throw "Currently only resolving paths for the current user is supported." else
root = Existing_Enso_Asset.Value "" current_user.root_directory_id Enso_Asset_Type.Directory
file.enso_path.path_segments.fold root current_dir-> segment->
children = current_dir.list_directory . catch Illegal_Argument _->
Error.throw (Illegal_Argument.Error current_dir.title+" is not a directory.")
(children.find c-> c.title == segment) . catch Not_Found _->
Error.throw (File_Error.Not_Found file)
# TODO remove workaround for bug https://github.com/enso-org/cloud-v2/issues/1173
path = if file.enso_path.is_root then file.enso_path.to_text + "/" else file.enso_path.to_text
Existing_Enso_Asset.resolve_path path if_not_found=(Error.throw (File_Error.Not_Found file))
## PRIVATE
Resolves a path to an existing asset in the cloud.
resolve_path (path : Text) ~if_not_found =
handle_not_found _ = if_not_found
error_handlers = Map.from_vector [["resource_missing", handle_not_found]]
uri = Utils.cloud_root_uri+"path/resolve"
payload = JS_Object.from_pairs [["path", path]]
# TODO remove workaround - this should be a Get endpoint, not Post
response = Context.Output.with_enabled <|
Utils.http_request_as_json HTTP_Method.Post uri payload error_handlers=error_handlers
Existing_Enso_Asset.from_json response
## PRIVATE
is_directory self = self.asset_type == Enso_Asset_Type.Directory

View File

@ -5,6 +5,7 @@ import project.Data.Json.Invalid_JSON
import project.Data.Map.Map
import project.Data.Numbers.Integer
import project.Data.Text.Text
import project.Data.Time.Duration.Duration
import project.Data.Vector.Vector
import project.Enso_Cloud.Errors.Enso_Cloud_Error
import project.Enso_Cloud.Errors.Not_Logged_In
@ -26,6 +27,7 @@ from project.Data.Boolean import Boolean, False, True
from project.Data.Text.Extensions import all
polyglot java import org.enso.base.enso_cloud.CloudAPI
polyglot java import org.enso.base.enso_cloud.CloudRequestCache
## PRIVATE
cloud_root_uri = "" + CloudAPI.getAPIRootURI
@ -109,3 +111,9 @@ http_request (method : HTTP_Method) (url : URI) (body : Request_Body = Request_B
case handler of
Nothing -> Error.throw (Enso_Cloud_Error.Unexpected_Service_Error response.code payload)
_ : Function -> handler json_payload
## PRIVATE
Returns the cached value for the given key, or computes it using the given
action and caches it for future use.
get_cached (key : Text) ~action (cache_duration : Duration = Duration.new seconds=60) =
CloudRequestCache.getOrCompute key (_->action) cache_duration

View File

@ -2,7 +2,7 @@ package org.enso.base.enso_cloud;
import org.enso.base.Environment_Utils;
public class CloudAPI {
public final class CloudAPI {
public static String getAPIRootURI() {
var envUri = Environment_Utils.get_environment_variable("ENSO_CLOUD_API_URI");
var effectiveUri =
@ -12,6 +12,7 @@ public class CloudAPI {
}
public static void flushCloudCaches() {
CloudRequestCache.clear();
AuthenticationProvider.reset();
EnsoSecretReader.flushCache();
}

View File

@ -0,0 +1,34 @@
package org.enso.base.enso_cloud;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.function.Function;
/**
* A cache that can be used to save results of cloud requests to avoid re-fetching them every time.
*
* <p>The cache is supposed to store the already processed (parsed etc.) result. If the result is
* not cached or the cache entry is expired, the cache will recompute the value using the provided
* callback.
*/
public final class CloudRequestCache {
private static final HashMap<String, CacheEntry> cache = new HashMap<>();
public static void clear() {
cache.clear();
}
public static Object getOrCompute(String key, Function<String, Object> compute, Duration ttl) {
var entry = cache.get(key);
if (entry != null && entry.expiresAt.isAfter(LocalDateTime.now())) {
return entry.value;
} else {
var value = compute.apply(key);
cache.put(key, new CacheEntry(value, LocalDateTime.now().plus(ttl)));
return value;
}
}
private record CacheEntry(Object value, LocalDateTime expiresAt) {}
}

View File

@ -20,15 +20,15 @@ import project.Network.Enso_Cloud.Cloud_Tests_Setup.Mock_Credentials
polyglot java import org.enso.base.enso_cloud.EnsoSecretAccessDenied
polyglot java import org.enso.base.enso_cloud.ExternalLibrarySecretHelper
add_specs suite_builder setup:Cloud_Tests_Setup = setup.with_prepared_environment <|
add_specs suite_builder setup:Cloud_Tests_Setup =
suite_builder.group "Enso Cloud Secrets" pending=setup.pending group_builder->
group_builder.specify "should be able to list existing secrets" <|
group_builder.specify "should be able to list existing secrets" <| setup.with_prepared_environment <|
# This should work regardless of Output context setting:
run_with_and_without_output <|
# We cannot test much more because we do not know what secrets are already there, further tests will check more by creating and deleting secrets
Enso_Secret.list . should_be_a Vector
group_builder.specify "should allow to create, list and delete secrets" <|
group_builder.specify "should allow to create, list and delete secrets" <| setup.with_prepared_environment <|
my_secret = Enso_Secret.create "my_test_secret" "my_secret_value"
my_secret.should_succeed
my_secret.name . should_equal "my_test_secret"
@ -42,7 +42,7 @@ add_specs suite_builder setup:Cloud_Tests_Setup = setup.with_prepared_environmen
Test.with_retries <|
Enso_Secret.list . should_not_contain my_secret
group_builder.specify "should allow to get a secret by name or path" <|
group_builder.specify "should allow to get a secret by name or path" <| setup.with_prepared_environment <|
created_secret = Enso_Secret.create "my_test_secret-2" "my_secret_value"
created_secret.should_succeed
Panic.with_finalizer created_secret.delete <|
@ -53,10 +53,10 @@ add_specs suite_builder setup:Cloud_Tests_Setup = setup.with_prepared_environmen
path_secret = Enso_Secret.get "enso://"+Enso_User.current.organization_name+"/my_test_secret-2"
path_secret . should_equal created_secret
group_builder.specify "does not allow both parent and path in Enso_Secret.get" <|
group_builder.specify "does not allow both parent and path in Enso_Secret.get" <| setup.with_prepared_environment <|
Enso_Secret.get "enso://"+Enso_User.current.organization_name+"/SOME-SECRET" parent=Enso_File.root . should_fail_with Illegal_Argument
group_builder.specify "should fail to create a secret if it already exists" <|
group_builder.specify "should fail to create a secret if it already exists" <| setup.with_prepared_environment <|
created_secret = Enso_Secret.create "my_test_secret-3" "my_secret_value"
created_secret.should_succeed
wait_until_secret_is_propagated created_secret
@ -71,7 +71,7 @@ add_specs suite_builder setup:Cloud_Tests_Setup = setup.with_prepared_environmen
r1.should_fail_with Illegal_Argument
r1.catch.to_display_text . should_contain "already exists"
group_builder.specify "should allow to use secrets in HTTPS request headers" pending=setup.httpbin_pending <|
group_builder.specify "should allow to use secrets in HTTPS request headers" pending=setup.httpbin_pending <| setup.with_prepared_environment <|
secret1 = Enso_Secret.create "my_test_secret-6" "Yet another Mystery"
secret1.should_succeed
@ -80,7 +80,7 @@ add_specs suite_builder setup:Cloud_Tests_Setup = setup.with_prepared_environmen
response = https.request (Request.get (setup.httpbin_secure_uri / "get") headers=[Header.new "X-My-Secret" secret1])
response.decode_as_json.at "headers" . at "X-My-Secret" . should_equal "Yet another Mystery"
group_builder.specify "should allow to derive values from secrets in Header.authorization_bearer" pending=setup.httpbin_pending <|
group_builder.specify "should allow to derive values from secrets in Header.authorization_bearer" pending=setup.httpbin_pending <| setup.with_prepared_environment <|
secret_token = Enso_Secret.create "my_test_secret-7" "MySecretToken"
secret_token.should_succeed
@ -90,7 +90,7 @@ add_specs suite_builder setup:Cloud_Tests_Setup = setup.with_prepared_environmen
response_json = response.decode_as_json
response_json.at "headers" . at "Authorization" . should_equal "Bearer MySecretToken"
group_builder.specify "should allow to derive values from secrets in Header.authorization_basic" pending=setup.httpbin_pending <|
group_builder.specify "should allow to derive values from secrets in Header.authorization_basic" pending=setup.httpbin_pending <| setup.with_prepared_environment <|
secret_username = Enso_Secret.create "my_test_secret-8" "MyUsername"
secret_username.should_succeed
Panic.with_finalizer secret_username.delete <|
@ -104,7 +104,7 @@ add_specs suite_builder setup:Cloud_Tests_Setup = setup.with_prepared_environmen
response_json = response.decode_as_json
response_json.at "headers" . at "Authorization" . should_equal expected
group_builder.specify "should allow to derive values from secrets" <|
group_builder.specify "should allow to derive values from secrets" <| setup.with_prepared_environment <|
secret1 = Enso_Secret.create "my_test_secret-10" "Something"
secret1.should_succeed
Panic.with_finalizer secret1.delete <| Test.with_retries <|
@ -130,7 +130,7 @@ add_specs suite_builder setup:Cloud_Tests_Setup = setup.with_prepared_environmen
b1.to_text . should_equal "WFk="
b2.to_text . should_equal "base64(X__SECRET__)"
group_builder.specify "does not allow secrets in HTTP headers" pending=setup.httpbin_pending <|
group_builder.specify "does not allow secrets in HTTP headers" pending=setup.httpbin_pending <| setup.with_prepared_environment <|
secret1 = Enso_Secret.create "my_test_secret-11" "Something"
secret1.should_succeed
Panic.with_finalizer secret1.delete <| Test.with_retries <|
@ -139,7 +139,7 @@ add_specs suite_builder setup:Cloud_Tests_Setup = setup.with_prepared_environmen
r1.should_fail_with Illegal_Argument
r1.catch.to_display_text . should_contain "Secrets are not allowed in HTTP connections, use HTTPS instead."
group_builder.specify "API exposing secrets to external libraries should not be accessible from unauthorized code" <|
group_builder.specify "API exposing secrets to external libraries should not be accessible from unauthorized code" <| setup.with_prepared_environment <|
secret1 = Enso_Secret.create "my_test_secret-12" "Something"
secret1.should_succeed
Panic.with_finalizer secret1.delete <| Test.with_retries <|
@ -147,7 +147,7 @@ add_specs suite_builder setup:Cloud_Tests_Setup = setup.with_prepared_environmen
Test.expect_panic EnsoSecretAccessDenied <|
ExternalLibrarySecretHelper.resolveValue java_repr
group_builder.specify "should allow to create and delete secrets in a sub-directory" pending=setup.real_cloud_pending <|
group_builder.specify "should allow to create and delete secrets in a sub-directory" pending=setup.real_cloud_pending <| setup.with_prepared_environment <|
subdirectory = Enso_File.root.create_directory "my_test_subdirectory-1"
subdirectory.should_succeed
Panic.with_finalizer subdirectory.delete <|
@ -173,7 +173,7 @@ add_specs suite_builder setup:Cloud_Tests_Setup = setup.with_prepared_environmen
Enso_Secret.exists "my-nested-secret-1" parent=subdirectory . should_be_false
Enso_Secret.get "my-nested-secret-1" parent=subdirectory . should_fail_with Not_Found
group_builder.specify "should allow to use secrets from a sub-directory" pending=(setup.real_cloud_pending.if_nothing setup.httpbin_pending) <|
group_builder.specify "should allow to use secrets from a sub-directory" pending=(setup.real_cloud_pending.if_nothing setup.httpbin_pending) <| setup.with_prepared_environment <|
subdirectory = Enso_File.root.create_directory "my_test_subdirectory-2"
subdirectory.should_succeed
Panic.with_finalizer subdirectory.delete <|
@ -185,7 +185,7 @@ add_specs suite_builder setup:Cloud_Tests_Setup = setup.with_prepared_environmen
response = https.request (Request.get (setup.httpbin_secure_uri / "get") headers=[Header.new "X-My-Nested-Secret" nested_secret])
response.decode_as_json.at "headers" . at "X-My-Nested-Secret" . should_equal "NESTED_secret_value"
group_builder.specify "should allow to update secrets within a sub-directory" pending=(setup.real_cloud_pending.if_nothing setup.httpbin_pending) <|
group_builder.specify "should allow to update secrets within a sub-directory" pending=(setup.real_cloud_pending.if_nothing setup.httpbin_pending) <| setup.with_prepared_environment <|
subdirectory = Enso_File.root.create_directory "my_test_subdirectory-3"
subdirectory.should_succeed
Panic.with_finalizer subdirectory.delete <|
@ -207,7 +207,7 @@ add_specs suite_builder setup:Cloud_Tests_Setup = setup.with_prepared_environmen
response = https.request (Request.get (setup.httpbin_secure_uri / "get") headers=[Header.new "X-My-Nested-Secret" nested_secret])
response.decode_as_json.at "headers" . at "X-My-Nested-Secret" . should_equal "Value-New-B"
group_builder.specify "should NOT be able to create/update/delete secrets with disabled Output Context" <|
group_builder.specify "should NOT be able to create/update/delete secrets with disabled Output Context" <| setup.with_prepared_environment <|
Context.Output.with_disabled <|
Enso_Secret.create "foo" "bar" . should_fail_with Forbidden_Operation
@ -222,7 +222,7 @@ add_specs suite_builder setup:Cloud_Tests_Setup = setup.with_prepared_environmen
# Get should still work
Test.with_retries <| Enso_Secret.get "my_test_secret-13" . should_equal secret1
group_builder.specify "should be able to retry fetching a secret if the token is expired" pending=setup.httpbin_pending <|
group_builder.specify "should be able to retry fetching a secret if the token is expired" pending=setup.httpbin_pending <| setup.with_prepared_environment <|
mock_setup = Cloud_Tests_Setup.prepare_mock_setup
mock_setup.with_prepared_environment <|
secret1 = Enso_Secret.create "my_test_secret-"+Random.uuid "Something123"

View File

@ -0,0 +1,68 @@
package org.enso.shttp.cloud_mock;
import java.util.LinkedList;
import java.util.List;
public class AssetStore {
static final String ROOT_DIRECTORY_ID = "directory-27xJM00p8jWoL2qByTo6tQfciWC";
private final List<Secret> secrets = new LinkedList<>();
String createSecret(String parentDirectoryId, String title, String value) {
if (!parentDirectoryId.equals(ROOT_DIRECTORY_ID)) {
throw new IllegalArgumentException(
"In Cloud Mock secrets can only be created in the root directory");
}
if (exists(parentDirectoryId, title)) {
throw new IllegalArgumentException(
"Secret with title " + title + " already exists in the directory");
}
String id = "secret-" + java.util.UUID.randomUUID().toString();
secrets.add(new Secret(id, title, value, parentDirectoryId));
return id;
}
boolean exists(String parentDirectoryId, String title) {
return secrets.stream()
.anyMatch(
secret ->
secret.title.equals(title) && secret.parentDirectoryId.equals(parentDirectoryId));
}
boolean deleteSecret(String id) {
return secrets.removeIf(secret -> secret.id.equals(id));
}
Secret findSecretById(String id) {
return secrets.stream().filter(secret -> secret.id.equals(id)).findFirst().orElse(null);
}
List<Secret> listAssets(String parentDirectoryId) {
if (!parentDirectoryId.equals(ROOT_DIRECTORY_ID)) {
throw new IllegalArgumentException(
"In Cloud Mock secrets can only be listed in the root directory");
}
return List.copyOf(secrets);
}
Secret findAssetInRootByTitle(String subPath) {
return secrets.stream()
.filter(
secret ->
secret.title.equals(subPath) && secret.parentDirectoryId.equals(ROOT_DIRECTORY_ID))
.findFirst()
.orElse(null);
}
record Secret(String id, String title, String value, String parentDirectoryId) {
Asset asAsset() {
return new Asset(id, title, parentDirectoryId);
}
}
public record Asset(String id, String title, String parentId) {}
final Asset rootDirectory = new Asset(ROOT_DIRECTORY_ID, "", null);
}

View File

@ -0,0 +1,35 @@
package org.enso.shttp.cloud_mock;
import java.io.IOException;
import org.enso.shttp.HttpMethod;
public class AssetsHandler implements CloudHandler {
private static final String ASSETS = "assets";
private final AssetStore assetStore;
public AssetsHandler(AssetStore assetStore) {
this.assetStore = assetStore;
}
@Override
public boolean canHandle(String subPath) {
return subPath.startsWith(ASSETS);
}
@Override
public void handleCloudAPI(CloudExchange exchange) throws IOException {
;
if (exchange.getMethod() == HttpMethod.DELETE) {
String id = exchange.subPath().substring(ASSETS.length() + 1);
boolean existed = assetStore.deleteSecret(id);
if (existed) {
exchange.sendResponse(200, "");
} else {
exchange.sendResponse(404, "Secret not found: " + id);
}
} else {
exchange.sendResponse(
405, "Method not allowed: " + exchange.getMethod() + " - mock only allows DELETE");
}
}
}

View File

@ -2,6 +2,7 @@ package org.enso.shttp.cloud_mock;
import com.sun.net.httpserver.HttpExchange;
import java.io.IOException;
import org.enso.shttp.HttpMethod;
public interface CloudHandler {
boolean canHandle(String subPath);
@ -16,5 +17,7 @@ public interface CloudHandler {
void sendResponse(int code, String response) throws IOException;
String decodeBodyAsText() throws IOException;
HttpMethod getMethod();
}
}

View File

@ -3,14 +3,23 @@ package org.enso.shttp.cloud_mock;
import com.sun.net.httpserver.HttpExchange;
import java.io.IOException;
import java.net.URI;
import org.enso.shttp.HttpMethod;
import org.enso.shttp.auth.HandlerWithTokenAuth;
public class CloudRoot extends HandlerWithTokenAuth {
public final String prefix = "/enso-cloud-mock/";
private final ExpiredTokensCounter expiredTokensCounter;
private final AssetStore assetStore = new AssetStore();
private final CloudHandler[] handlers =
new CloudHandler[] {new UsersHandler(), new SecretsHandler()};
new CloudHandler[] {
new UsersHandler(),
new SecretsHandler(assetStore),
new HiddenSecretsHandler(assetStore),
new AssetsHandler(assetStore),
new PathResolver(assetStore),
new DirectoriesHandler(assetStore)
};
public CloudRoot(ExpiredTokensCounter expiredTokensCounter) {
this.expiredTokensCounter = expiredTokensCounter;
@ -74,6 +83,11 @@ public class CloudRoot extends HandlerWithTokenAuth {
public String decodeBodyAsText() throws IOException {
return CloudRoot.this.decodeBodyAsText(exchange);
}
@Override
public HttpMethod getMethod() throws IllegalArgumentException {
return HttpMethod.valueOf(exchange.getRequestMethod());
}
};
}
}

View File

@ -0,0 +1,51 @@
package org.enso.shttp.cloud_mock;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.util.List;
import org.enso.shttp.HttpMethod;
/**
* Lists assets in a directory.
*
* <p>In the mock, only root directory can be listed, and it may only contain secrets.
*/
public class DirectoriesHandler implements CloudHandler {
private static final String DIRECTORIES = "directories";
private final AssetStore assetStore;
private final ObjectMapper jsonMapper = new ObjectMapper();
public DirectoriesHandler(AssetStore assetStore) {
this.assetStore = assetStore;
}
@Override
public boolean canHandle(String subPath) {
return subPath.startsWith(DIRECTORIES);
}
@Override
public void handleCloudAPI(CloudExchange exchange) throws IOException {
if (exchange.getMethod() == HttpMethod.GET) {
listDirectory(exchange.subPath().substring(DIRECTORIES.length() + 1), exchange);
} else {
exchange.sendResponse(
405, "Method not allowed: " + exchange.getMethod() + " - mock only allows GET");
}
}
private void listDirectory(String parentId, CloudExchange exchange) throws IOException {
final String effectiveParentId = parentId.isEmpty() ? AssetStore.ROOT_DIRECTORY_ID : parentId;
ListDirectoryResponse response =
new ListDirectoryResponse(
assetStore.listAssets(effectiveParentId).stream()
.map(AssetStore.Secret::asAsset)
.toList());
String asJson = jsonMapper.writeValueAsString(response);
exchange.sendResponse(200, asJson);
}
private record ListDirectoryResponse(List<AssetStore.Asset> assets) {}
}

View File

@ -0,0 +1,44 @@
package org.enso.shttp.cloud_mock;
import com.fasterxml.jackson.databind.json.JsonMapper;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import org.enso.shttp.HttpMethod;
public class HiddenSecretsHandler implements CloudHandler {
private static final String HIDDEN_SECRETS = "s3cr3tz";
private final AssetStore assetStore;
private final JsonMapper jsonMapper = new JsonMapper();
public HiddenSecretsHandler(AssetStore assetStore) {
this.assetStore = assetStore;
}
@Override
public boolean canHandle(String subPath) {
return subPath.startsWith(HIDDEN_SECRETS);
}
@Override
public void handleCloudAPI(CloudExchange exchange) throws IOException {
if (exchange.getMethod() == HttpMethod.GET) {
getSecret(exchange.subPath().substring(HIDDEN_SECRETS.length() + 1), exchange);
} else {
exchange.sendResponse(404, "Not found: " + exchange.subPath());
}
}
private void getSecret(String id, CloudExchange exchange) throws IOException {
var secret = assetStore.findSecretById(id);
if (secret == null) {
exchange.sendResponse(404, "Secret not found: " + id);
} else {
String encoded =
java.util.Base64.getEncoder()
.encodeToString(secret.value().getBytes(StandardCharsets.UTF_8));
String asJson = jsonMapper.writeValueAsString(encoded);
exchange.sendResponse(200, asJson);
}
}
}

View File

@ -0,0 +1,70 @@
package org.enso.shttp.cloud_mock;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.util.regex.Pattern;
public class PathResolver implements CloudHandler {
private static final String PATH_RESOLVE = "path/resolve";
private final String ORGANIZATION_NAME = "Test.ORG";
private final AssetStore assetStore;
private final ObjectMapper jsonMapper = new ObjectMapper();
public PathResolver(AssetStore assetStore) {
this.assetStore = assetStore;
}
@Override
public boolean canHandle(String subPath) {
return subPath.startsWith(PATH_RESOLVE);
}
private final Pattern pathPattern = Pattern.compile("enso://(.+?)/(.+)");
@Override
public void handleCloudAPI(CloudExchange exchange) throws IOException {
JsonNode root = jsonMapper.readTree(exchange.decodeBodyAsText());
String path = root.get("path").asText();
var matcher = pathPattern.matcher(path);
if (!matcher.matches()) {
exchange.sendResponse(400, "Invalid path: " + path);
return;
}
String organization = matcher.group(1);
String subPath = matcher.group(2);
if (!organization.equals(ORGANIZATION_NAME)) {
exchange.sendResponse(404, "Organization not found: " + organization);
return;
}
// The latter condition is a workaround for https://github.com/enso-org/cloud-v2/issues/1173 and
// it may be removed once that is fixed
boolean isRoot = subPath.isEmpty() || subPath.equals("/");
if (isRoot) {
String asJson = jsonMapper.writeValueAsString(assetStore.rootDirectory);
exchange.sendResponse(200, asJson);
return;
}
if (subPath.contains("/")) {
exchange.sendResponse(
400, "Invalid subpath: " + subPath + " - mock does not support subdirectories");
return;
}
AssetStore.Secret asset = assetStore.findAssetInRootByTitle(subPath);
if (asset == null) {
exchange.sendResponse(404, "Asset not found: " + subPath);
return;
}
String asJson = jsonMapper.writeValueAsString(asset.asAsset());
exchange.sendResponse(200, asJson);
}
}

View File

@ -3,104 +3,30 @@ package org.enso.shttp.cloud_mock;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.List;
import java.util.UUID;
import org.enso.shttp.HttpMethod;
public class SecretsHandler implements CloudHandler {
private static final String SECRETS = "secrets";
private static final String ROOT = "directory-27xJM00p8jWoL2qByTo6tQfciWC";
private final String SECRETS = "secrets";
private final String HIDDEN_SECRETS = "s3cr3tz";
// Temporary mock until we are back to `list_secrets` for `Enso_Secret.list`.
private final String DIRECTORIES = "directories";
// Secrets are now being deleted through assets endpoint
private final String ASSETS = "assets/secret-";
private final AssetStore assetStore;
private final ObjectMapper jsonMapper = new ObjectMapper();
// <root> -> <secret_id> -> <secret_value>
private HashMap<String, HashMap<String, Secret>> mapping = new HashMap<>();
public SecretsHandler(AssetStore assetStore) {
this.assetStore = assetStore;
}
@Override
public boolean canHandle(String subPath) {
return subPath.startsWith(SECRETS)
|| subPath.startsWith(HIDDEN_SECRETS)
|| subPath.startsWith(DIRECTORIES)
|| subPath.startsWith(ASSETS);
return subPath.startsWith(SECRETS);
}
@Override
public void handleCloudAPI(CloudExchange exchange) throws IOException {
HttpMethod method;
try {
method = HttpMethod.valueOf(exchange.getHttpExchange().getRequestMethod());
} catch (IllegalArgumentException e) {
exchange.sendResponse(
400, "Invalid method: " + exchange.getHttpExchange().getRequestMethod());
return;
}
if (exchange.subPath().startsWith(DIRECTORIES)) {
if (method == HttpMethod.GET) {
listDirectory(exchange.subPath().substring(DIRECTORIES.length() + 1), exchange);
} else {
exchange.sendResponse(405, "Method not allowed: " + method);
}
return;
}
if (exchange.subPath().startsWith(ASSETS)) {
if (method == HttpMethod.DELETE) {
String idOnly = exchange.subPath().substring(ASSETS.length());
String fullId = "secret-" + idOnly;
deleteSecret(fullId, exchange);
} else {
exchange.sendResponse(405, "Method not allowed: " + method);
}
return;
}
if (exchange.subPath().equals(SECRETS)) {
handleTopLevel(method, exchange);
} else if (exchange.subPath().startsWith(SECRETS)) {
handleSecretSpecific(method, exchange.subPath().substring(SECRETS.length() + 1), exchange);
} else if (exchange.subPath().startsWith(HIDDEN_SECRETS)) {
if (method == HttpMethod.GET) {
getSecret(exchange.subPath().substring(HIDDEN_SECRETS.length() + 1), exchange);
} else {
exchange.sendResponse(404, "Not found: " + exchange.subPath());
}
if (exchange.getMethod() == HttpMethod.POST) {
createSecret(exchange);
} else {
exchange.sendResponse(404, "Not found: " + exchange.subPath());
}
}
private void handleSecretSpecific(HttpMethod method, String name, CloudExchange exchange)
throws IOException {
switch (method) {
case DELETE:
deleteSecret(name, exchange);
break;
default:
exchange.sendResponse(405, "Method not allowed: " + method);
}
}
private void handleTopLevel(HttpMethod method, CloudExchange exchange) throws IOException {
switch (method) {
case GET:
listSecrets(exchange);
break;
case POST:
createSecret(exchange);
break;
default:
exchange.sendResponse(405, "Method not allowed: " + method);
exchange.sendResponse(
405, "Method not allowed: " + exchange.getMethod() + " - mock only allows POST");
}
}
@ -108,80 +34,18 @@ public class SecretsHandler implements CloudHandler {
JsonNode root = jsonMapper.readTree(exchange.decodeBodyAsText());
String name = root.get("name").asText();
String value = root.get("value").asText();
String parentId = root.has("parentDirectoryId") ? root.get("parentDirectoryId").asText() : ROOT;
String secretId = "secret-" + UUID.randomUUID();
accessRoot(parentId).put(secretId, new Secret(name, value));
String asJson = jsonMapper.writeValueAsString(secretId);
exchange.sendResponse(200, asJson);
}
String parentId =
root.has("parentDirectoryId")
? root.get("parentDirectoryId").asText()
: AssetStore.ROOT_DIRECTORY_ID;
private void listSecrets(CloudExchange exchange) throws IOException {
// TODO currently the cloud API does not seem to handle a parent_id parameter, so we always rely
// on ROOT
String parentId = ROOT;
ListSecretsResponse response =
new ListSecretsResponse(
accessRoot(parentId).entrySet().stream()
.map(
entry -> new ListSecretsResponse.Element(entry.getKey(), entry.getValue().name))
.toList());
String asJson = jsonMapper.writeValueAsString(response);
exchange.sendResponse(200, asJson);
}
/**
* This is a workaround because Enso_Secret.list currently relies on `list_directory` instead of
* `list_secrets`, as `list_secrets` was unable to handle sub-directories. Once `list_secrets` is
* fixed, this temporary workaround may be removed from the mock.
*/
private void listDirectory(String parentId, CloudExchange exchange) throws IOException {
final String effectiveParentId = parentId.isEmpty() ? ROOT : parentId;
ListDirectoryResponse response =
new ListDirectoryResponse(
accessRoot(parentId).entrySet().stream()
.map(
entry ->
new ListDirectoryResponse.Element(
entry.getKey(), entry.getValue().name, effectiveParentId))
.toList());
String asJson = jsonMapper.writeValueAsString(response);
exchange.sendResponse(200, asJson);
}
private void getSecret(String id, CloudExchange exchange) throws IOException {
String parentId = ROOT;
Secret secret = accessRoot(parentId).get(id);
if (secret == null) {
exchange.sendResponse(404, "Secret not found: " + id);
} else {
String encoded =
java.util.Base64.getEncoder()
.encodeToString(secret.value.getBytes(StandardCharsets.UTF_8));
exchange.sendResponse(200, '"' + encoded + '"');
if (assetStore.exists(parentId, name)) {
exchange.sendResponse(400, "{\"code\": \"resource_already_exists\"}");
return;
}
}
private void deleteSecret(String name, CloudExchange exchange) throws IOException {
String parentId = ROOT;
boolean existed = accessRoot(parentId).remove(name) != null;
if (existed) {
exchange.sendResponse(200, "");
} else {
exchange.sendResponse(404, "Secret not found: " + name);
}
String createdSecretId = assetStore.createSecret(parentId, name, value);
String asJson = jsonMapper.writeValueAsString(createdSecretId);
exchange.sendResponse(200, asJson);
}
private HashMap<String, Secret> accessRoot(String rootId) {
return mapping.computeIfAbsent(rootId, k -> new HashMap<>());
}
private record ListSecretsResponse(List<Element> secrets) {
public record Element(String id, String name) {}
}
private record ListDirectoryResponse(List<Element> assets) {
public record Element(String id, String title, String parentId) {}
}
private record Secret(String name, String value) {}
}