diff --git a/include/ccf/js/bundle.h b/include/ccf/js/bundle.h index e6ad93de8125..e2571164d0d2 100644 --- a/include/ccf/js/bundle.h +++ b/include/ccf/js/bundle.h @@ -12,8 +12,9 @@ namespace ccf::js { struct Metadata { + // Path -> {HTTP Method -> Properties} std::map< - std::string, + ccf::endpoints::URI, std::map> endpoints; }; diff --git a/include/ccf/js/registry.h b/include/ccf/js/registry.h index 1869ab02e04f..eac8f3e25cad 100644 --- a/include/ccf/js/registry.h +++ b/include/ccf/js/registry.h @@ -76,8 +76,32 @@ namespace ccf::js * Call this to populate the KV with JS endpoint definitions, so they can * later be dispatched to. */ - void install_custom_endpoints( - ccf::endpoints::EndpointContext& ctx, const ccf::js::Bundle& bundle); + ccf::ApiResult install_custom_endpoints_v1( + kv::Tx& tx, const ccf::js::Bundle& bundle); + + /** + * Retrieve all endpoint definitions currently in-use. This returns the same + * bundle written by a recent call to install_custom_endpoints. Note that + * some values (module paths, casing of HTTP methods) may differ slightly + * due to internal normalisation. + */ + ccf::ApiResult get_custom_endpoints_v1( + ccf::js::Bundle& bundle, kv::ReadOnlyTx& tx); + + /** + * Retrieve property definition for a single JS endpoint. + */ + ccf::ApiResult get_custom_endpoint_properties_v1( + ccf::endpoints::EndpointProperties& properties, + kv::ReadOnlyTx& tx, + const ccf::RESTVerb& verb, + const ccf::endpoints::URI& uri); + + /** + * Retrieve content of a single JS module. + */ + ccf::ApiResult get_custom_endpoint_module_v1( + std::string& code, kv::ReadOnlyTx& tx, const std::string& module_name); /// \defgroup Overrides for base EndpointRegistry functions, looking up JS /// endpoints before delegating to base implementation. diff --git a/samples/apps/basic/basic.cpp b/samples/apps/basic/basic.cpp index a15aa73a4d18..f078088a175e 100644 --- a/samples/apps/basic/basic.cpp +++ b/samples/apps/basic/basic.cpp @@ -156,7 +156,18 @@ namespace basicapp caller_identity.content.begin(), caller_identity.content.end()); const auto wrapper = j.get(); - install_custom_endpoints(ctx, wrapper); + result = install_custom_endpoints_v1(ctx.tx, wrapper); + if (result != ccf::ApiResult::OK) + { + ctx.rpc_ctx->set_error( + HTTP_STATUS_INTERNAL_SERVER_ERROR, + ccf::errors::InternalError, + fmt::format( + "Failed to install endpoints: {}", + ccf::api_result_to_str(result))); + return; + } + ctx.rpc_ctx->set_response_status(HTTP_STATUS_NO_CONTENT); }; @@ -167,6 +178,83 @@ namespace basicapp {ccf::user_cose_sign1_auth_policy}) .set_auto_schema() .install(); + + auto get_custom_endpoints = [this](ccf::endpoints::EndpointContext& ctx) { + ccf::js::Bundle bundle; + + auto result = get_custom_endpoints_v1(bundle, ctx.tx); + if (result != ccf::ApiResult::OK) + { + ctx.rpc_ctx->set_error( + HTTP_STATUS_INTERNAL_SERVER_ERROR, + ccf::errors::InternalError, + fmt::format( + "Failed to get endpoints: {}", ccf::api_result_to_str(result))); + return; + } + + ctx.rpc_ctx->set_response_status(HTTP_STATUS_OK); + ctx.rpc_ctx->set_response_header( + http::headers::CONTENT_TYPE, http::headervalues::contenttype::JSON); + ctx.rpc_ctx->set_response_body(nlohmann::json(bundle).dump(2)); + }; + + make_endpoint( + "/custom_endpoints", + HTTP_GET, + get_custom_endpoints, + {ccf::empty_auth_policy}) + .set_auto_schema() + .install(); + + auto get_custom_endpoints_module = + [this](ccf::endpoints::EndpointContext& ctx) { + std::string module_name; + + { + const auto parsed_query = + http::parse_query(ctx.rpc_ctx->get_request_query()); + + std::string error; + if (!http::get_query_value( + parsed_query, "module_name", module_name, error)) + { + ctx.rpc_ctx->set_error( + HTTP_STATUS_BAD_REQUEST, + ccf::errors::InvalidQueryParameterValue, + std::move(error)); + return; + } + } + + std::string code; + + auto result = + get_custom_endpoint_module_v1(code, ctx.tx, module_name); + if (result != ccf::ApiResult::OK) + { + ctx.rpc_ctx->set_error( + HTTP_STATUS_INTERNAL_SERVER_ERROR, + ccf::errors::InternalError, + fmt::format( + "Failed to get module: {}", ccf::api_result_to_str(result))); + return; + } + + ctx.rpc_ctx->set_response_status(HTTP_STATUS_OK); + ctx.rpc_ctx->set_response_header( + http::headers::CONTENT_TYPE, + http::headervalues::contenttype::JAVASCRIPT); + ctx.rpc_ctx->set_response_body(std::move(code)); + }; + + make_endpoint( + "/custom_endpoints/modules", + HTTP_GET, + get_custom_endpoints_module, + {ccf::empty_auth_policy}) + .add_query_parameter("module_name") + .install(); } }; } diff --git a/src/js/registry.cpp b/src/js/registry.cpp index 3769154261ae..50a8c7ba97e2 100644 --- a/src/js/registry.cpp +++ b/src/js/registry.cpp @@ -37,6 +37,16 @@ namespace ccf::js { + std::string normalised_module_path(std::string_view sv) + { + if (!sv.starts_with("/")) + { + return fmt::format("/{}", sv); + } + + return std::string(sv); + } + void DynamicJSEndpointRegistry::do_execute_request( const CustomJSEndpoint* endpoint, ccf::endpoints::EndpointContext& endpoint_ctx, @@ -450,74 +460,183 @@ namespace ccf::js }); } - void DynamicJSEndpointRegistry::install_custom_endpoints( - ccf::endpoints::EndpointContext& ctx, const ccf::js::Bundle& bundle) + ccf::ApiResult DynamicJSEndpointRegistry::install_custom_endpoints_v1( + kv::Tx& tx, const ccf::js::Bundle& bundle) { - auto endpoints = - ctx.tx.template rw(metadata_map); - endpoints->clear(); - for (const auto& [url, methods] : bundle.metadata.endpoints) + try { - for (const auto& [method, metadata] : methods) + auto endpoints = + tx.template rw(metadata_map); + endpoints->clear(); + for (const auto& [url, methods] : bundle.metadata.endpoints) { - std::string method_upper = method; - nonstd::to_upper(method_upper); - const auto key = ccf::endpoints::EndpointKey{url, method_upper}; - endpoints->put(key, metadata); + for (const auto& [method, metadata] : methods) + { + std::string method_upper = method; + nonstd::to_upper(method_upper); + const auto key = ccf::endpoints::EndpointKey{url, method_upper}; + endpoints->put(key, metadata); + } + } + + auto modules = tx.template rw(modules_map); + modules->clear(); + for (const auto& moduledef : bundle.modules) + { + modules->put(normalised_module_path(moduledef.name), moduledef.module); } + + // Trigger interpreter flush, in case interpreter reuse + // is enabled for some endpoints + auto interpreter_flush = + tx.template rw(interpreter_flush_map); + interpreter_flush->put(true); + + // Refresh app bytecode + ccf::js::core::Context jsctx(ccf::js::TxAccess::APP_RW); + jsctx.runtime().set_runtime_options( + tx.ro(runtime_options_map)->get(), + ccf::js::core::RuntimeLimitsPolicy::NO_LOWER_THAN_DEFAULTS); + + auto quickjs_version = + tx.wo(modules_quickjs_version_map); + auto quickjs_bytecode = + tx.wo(modules_quickjs_bytecode_map); + + quickjs_version->put(ccf::quickjs_version); + quickjs_bytecode->clear(); + jsctx.set_module_loader( + std::make_shared(modules)); + + modules->foreach([&](const auto& name, const auto& src) { + auto module_val = jsctx.eval( + src.c_str(), + src.size(), + name.c_str(), + JS_EVAL_TYPE_MODULE | JS_EVAL_FLAG_COMPILE_ONLY); + + uint8_t* out_buf; + size_t out_buf_len; + int flags = JS_WRITE_OBJ_BYTECODE; + out_buf = JS_WriteObject(jsctx, &out_buf_len, module_val.val, flags); + if (!out_buf) + { + throw std::runtime_error(fmt::format( + "Unable to serialize bytecode for JS module '{}'", name)); + } + + quickjs_bytecode->put(name, {out_buf, out_buf + out_buf_len}); + js_free(jsctx, out_buf); + + return true; + }); + + return ccf::ApiResult::OK; } + catch (const std::exception& e) + { + LOG_FAIL_FMT("{}", e.what()); + return ApiResult::InternalError; + } + } - auto modules = ctx.tx.template rw(modules_map); - modules->clear(); - for (const auto& module_def : bundle.modules) + ccf::ApiResult DynamicJSEndpointRegistry::get_custom_endpoints_v1( + ccf::js::Bundle& bundle, kv::ReadOnlyTx& tx) + { + try { - modules->put(fmt::format("/{}", module_def.name), module_def.module); + auto endpoints_handle = + tx.template ro(metadata_map); + endpoints_handle->foreach([&endpoints = bundle.metadata.endpoints]( + const auto& endpoint_key, + const auto& properties) { + using PropertiesMap = + std::map; + + auto it = endpoints.find(endpoint_key.uri_path); + if (it == endpoints.end()) + { + it = + endpoints.emplace_hint(it, endpoint_key.uri_path, PropertiesMap{}); + } + + PropertiesMap& method_properties = it->second; + + method_properties.emplace_hint( + method_properties.end(), endpoint_key.verb.c_str(), properties); + + return true; + }); + + auto modules_handle = tx.template ro(modules_map); + modules_handle->foreach( + [&modules = + bundle.modules](const auto& module_name, const auto& module_src) { + modules.push_back({module_name, module_src}); + return true; + }); + + return ApiResult::OK; } + catch (const std::exception& e) + { + LOG_FAIL_FMT("{}", e.what()); + return ApiResult::InternalError; + } + } - // Trigger interpreter flush, in case interpreter reuse - // is enabled for some endpoints - auto interpreter_flush = - ctx.tx.template rw(interpreter_flush_map); - interpreter_flush->put(true); - - // Refresh app bytecode - ccf::js::core::Context jsctx(ccf::js::TxAccess::APP_RW); - jsctx.runtime().set_runtime_options( - ctx.tx.ro(runtime_options_map)->get(), - ccf::js::core::RuntimeLimitsPolicy::NO_LOWER_THAN_DEFAULTS); - - auto quickjs_version = - ctx.tx.wo(modules_quickjs_version_map); - auto quickjs_bytecode = - ctx.tx.wo(modules_quickjs_bytecode_map); - - quickjs_version->put(ccf::quickjs_version); - quickjs_bytecode->clear(); - jsctx.set_module_loader( - std::make_shared(modules)); - - modules->foreach([&](const auto& name, const auto& src) { - auto module_val = jsctx.eval( - src.c_str(), - src.size(), - name.c_str(), - JS_EVAL_TYPE_MODULE | JS_EVAL_FLAG_COMPILE_ONLY); - - uint8_t* out_buf; - size_t out_buf_len; - int flags = JS_WRITE_OBJ_BYTECODE; - out_buf = JS_WriteObject(jsctx, &out_buf_len, module_val.val, flags); - if (!out_buf) + ccf::ApiResult DynamicJSEndpointRegistry::get_custom_endpoint_properties_v1( + ccf::endpoints::EndpointProperties& properties, + kv::ReadOnlyTx& tx, + const ccf::RESTVerb& verb, + const ccf::endpoints::URI& uri) + { + try + { + auto endpoints = tx.ro(metadata_map); + const auto key = ccf::endpoints::EndpointKey{uri, verb}; + + auto it = endpoints->get(key); + if (it.has_value()) { - throw std::runtime_error( - fmt::format("Unable to serialize bytecode for JS module '{}'", name)); + properties = it.value(); + return ApiResult::OK; } + else + { + return ApiResult::NotFound; + } + } + catch (const std::exception& e) + { + LOG_FAIL_FMT("{}", e.what()); + return ApiResult::InternalError; + } + } - quickjs_bytecode->put(name, {out_buf, out_buf + out_buf_len}); - js_free(jsctx, out_buf); + ccf::ApiResult DynamicJSEndpointRegistry::get_custom_endpoint_module_v1( + std::string& code, kv::ReadOnlyTx& tx, const std::string& module_name) + { + try + { + auto modules = tx.template ro(modules_map); - return true; - }); + auto it = modules->get(normalised_module_path(module_name)); + if (it.has_value()) + { + code = it.value(); + return ApiResult::OK; + } + else + { + return ApiResult::NotFound; + } + } + catch (const std::exception& e) + { + LOG_FAIL_FMT("{}", e.what()); + return ApiResult::InternalError; + } } ccf::endpoints::EndpointDefinitionPtr DynamicJSEndpointRegistry:: diff --git a/tests/programmability.py b/tests/programmability.py index 88ea229b1f45..02c2af0e8bb3 100644 --- a/tests/programmability.py +++ b/tests/programmability.py @@ -16,16 +16,24 @@ from loguru import logger as LOG TESTJS = """ +import { foo } from "./bar/baz.js"; + export function content(request) { return { statusCode: 200, body: { - payload: "Test content", + payload: foo(), }, }; } """ +FOOJS = """ +export function foo() { + return "Test content"; +} +""" + TESTJS_ROLE = """ export function content(request) { let raw_id = ccf.strToBuf(request.caller.id); @@ -77,7 +85,10 @@ def test_custom_endpoints(network, args): } } - modules = [{"name": "test.js", "module": TESTJS}] + modules = [ + {"name": "test.js", "module": TESTJS}, + {"name": "bar/baz.js", "module": FOOJS}, + ] bundle_with_content = { "metadata": {"endpoints": {"/content": content_endpoint_def}}, @@ -89,10 +100,46 @@ def test_custom_endpoints(network, args): "modules": modules, } + def upper_cased_keys(obj): + return {k.upper(): v for k, v in obj.items()} + + def prefixed_module_name(module_def): + if module_def["name"].startswith("/"): + return module_def + else: + return {**module_def, "name": f"/{module_def['name']}"} + + def same_modulo_normalisation(expected, actual): + # Normalise expected (in the same way that CCF will) so we can do direct comparison + expected["metadata"]["endpoints"] = { + path: upper_cased_keys(op) + for path, op in expected["metadata"]["endpoints"].items() + } + expected["modules"] = [ + prefixed_module_name(module_def) for module_def in expected["modules"] + ] + return expected == actual + + def test_getters(c, expected_body): + r = c.get("/app/custom_endpoints") + assert r.status_code == http.HTTPStatus.OK, r + assert same_modulo_normalisation( + expected_body, r.body.json() + ), f"Expected:\n{expected_body}\n\n\nActual:\n{r.body.json()}" + + for module_def in modules: + r = c.get(f"/app/custom_endpoints/modules?module_name={module_def['name']}") + assert r.status_code == http.HTTPStatus.OK, r + assert ( + r.body.text() == module_def["module"] + ), f"Expected:\n{module_def['module']}\n\n\nActual:\n{r.body.text()}" + with primary.client(None, None, user.local_id) as c: r = c.put("/app/custom_endpoints", body=bundle_with_content) assert r.status_code == http.HTTPStatus.NO_CONTENT.value, r.status_code + test_getters(c, bundle_with_content) + with primary.client() as c: r = c.get("/app/not_content") assert r.status_code == http.HTTPStatus.NOT_FOUND.value, r.status_code @@ -105,6 +152,8 @@ def test_custom_endpoints(network, args): r = c.put("/app/custom_endpoints", body=bundle_with_other_content) assert r.status_code == http.HTTPStatus.NO_CONTENT.value, r.status_code + test_getters(c, bundle_with_other_content) + with primary.client() as c: r = c.get("/app/other_content") assert r.status_code == http.HTTPStatus.OK.value, r.status_code