зеркало из https://github.com/microsoft/CCF.git
ETag demo in logging sample (#6110)
This commit is contained in:
Родитель
dac39e3c56
Коммит
c6815f30d0
|
@ -1080,6 +1080,11 @@ if(BUILD_TESTS)
|
|||
)
|
||||
target_link_libraries(http_test PRIVATE http_parser.host)
|
||||
|
||||
add_unit_test(
|
||||
http_etag_test
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/src/http/test/http_etag_test.cpp
|
||||
)
|
||||
|
||||
add_unit_test(
|
||||
frontend_test
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/src/js/wrap.cpp
|
||||
|
|
|
@ -148,3 +148,10 @@ JavaScript FFI Plugins
|
|||
|
||||
.. doxygenfunction:: ccfapp::get_js_plugins
|
||||
:project: CCF
|
||||
|
||||
HTTP Entity Tags Matching
|
||||
-------------------------
|
||||
|
||||
.. doxygenclass:: ccf::http::Matcher
|
||||
:project: CCF
|
||||
:members:
|
||||
|
|
|
@ -315,4 +315,53 @@ Receipts from this endpoint will then look like:
|
|||
{'left': 'abc9bcbeff670930c34ebdab0f2d57b56e9d393e4dccdccf2db59b5e34507422'}],
|
||||
'signature': 'MGUCMHYBgZ3gySdkJ+STUL13EURVBd8354ULC11l/kjx20IwpXrg/aDYLWYf7tsGwqUxPwIxAMH2wJDd9wpwbQrULpaAx5XEifpUfOriKtYo7XiFr05J+BV10U39xa9GBS49OK47QA=='}}
|
||||
|
||||
Note that the ``claims_digest`` is deliberately omitted from ``leaf_components``, and must be re-computed by digesting the ``msg``.
|
||||
Note that the ``claims_digest`` is deliberately omitted from ``leaf_components``, and must be re-computed by digesting the ``msg``.
|
||||
|
||||
Client-side Concurrency Control
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Clients of a CCF application submit transactions concurrently.
|
||||
Single transactions are always handled atomically by the service, transparently for clients and for application writers. But some clients may wish to submit sequences of multiple, dependent transactions. For example:
|
||||
|
||||
1. Client reads value at key ``K``
|
||||
2. Performs some client-side processing
|
||||
3. Writes a new value at key ``K``, but only if the server-side value has not changed
|
||||
|
||||
Implementing `If-Match` and `If-None-Match` HTTP headers in endpoint logic is a common pattern for this use case. The following endpoints of the C++ logging app demonstrate this:
|
||||
|
||||
POST /app/log/public
|
||||
~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
.. literalinclude:: ../../samples/apps/logging/logging.cpp
|
||||
:language: cpp
|
||||
:start-after: SNIPPET_START: public_table_post_match
|
||||
:end-before: SNIPPET_END: public_table_post_match
|
||||
:dedent:
|
||||
|
||||
And before returning ``200 OK``:
|
||||
|
||||
.. literalinclude:: ../../samples/apps/logging/logging.cpp
|
||||
:language: cpp
|
||||
:start-after: SNIPPET_START: public_table_post_etag
|
||||
:end-before: SNIPPET_END: public_table_post_etag
|
||||
:dedent:
|
||||
|
||||
GET /app/log/public/{idx}
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
.. literalinclude:: ../../samples/apps/logging/logging.cpp
|
||||
:language: cpp
|
||||
:start-after: SNIPPET_START: public_table_get_match
|
||||
:end-before: SNIPPET_END: public_table_get_match
|
||||
:dedent:
|
||||
|
||||
DELETE /app/log/public/{idx}
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
.. literalinclude:: ../../samples/apps/logging/logging.cpp
|
||||
:language: cpp
|
||||
:start-after: SNIPPET_START: public_table_delete_match
|
||||
:end-before: SNIPPET_END: public_table_delete_match
|
||||
:dedent:
|
||||
|
||||
The framework provides a :cpp:class:`ccf::http::Matcher` class, which can be used to evaluate these conditions.
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
namespace ccf
|
||||
{
|
||||
enum FrameFormat : uint8_t
|
||||
enum class FrameFormat : uint8_t
|
||||
{
|
||||
http = 0
|
||||
};
|
||||
|
|
|
@ -0,0 +1,76 @@
|
|||
// Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
// Licensed under the Apache 2.0 License.
|
||||
#pragma once
|
||||
|
||||
#define FMT_HEADER_ONLY
|
||||
#include <regex>
|
||||
#include <set>
|
||||
#include <string>
|
||||
|
||||
namespace ccf
|
||||
{
|
||||
namespace http
|
||||
{
|
||||
/** Utility class to resolve If-Match and If-None-Match as described
|
||||
* in https://www.rfc-editor.org/rfc/rfc9110#field.if-match
|
||||
*/
|
||||
class Matcher
|
||||
{
|
||||
private:
|
||||
/// If-Match header is present and has the value "*"
|
||||
bool any_value = false;
|
||||
/// If-Match header is present and has specific etag values
|
||||
std::set<std::string> if_etags;
|
||||
|
||||
public:
|
||||
/** Construct a Matcher from a match header
|
||||
*
|
||||
* Note: Weak tags are not supported.
|
||||
*/
|
||||
Matcher(const std::string& match_header)
|
||||
{
|
||||
if (match_header == "*")
|
||||
{
|
||||
any_value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
std::regex etag_rx("\\\"([0-9a-f]+)\\\",?\\s*");
|
||||
auto etags_begin = std::sregex_iterator(
|
||||
match_header.begin(), match_header.end(), etag_rx);
|
||||
auto etags_end = std::sregex_iterator();
|
||||
ssize_t last_matched = 0;
|
||||
|
||||
for (std::sregex_iterator i = etags_begin; i != etags_end; ++i)
|
||||
{
|
||||
if (i->position() != last_matched)
|
||||
{
|
||||
throw std::runtime_error("Invalid If-Match header");
|
||||
}
|
||||
std::smatch match = *i;
|
||||
if_etags.insert(match[1].str());
|
||||
last_matched = match.position() + match.length();
|
||||
}
|
||||
|
||||
ssize_t last_index_in_header = match_header.size();
|
||||
|
||||
if (last_matched != last_index_in_header || if_etags.empty())
|
||||
{
|
||||
throw std::runtime_error("Invalid If-Match header");
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a given ETag matches the If-Match/If-None-Match header
|
||||
bool matches(const std::string& etag) const
|
||||
{
|
||||
return any_value || if_etags.contains(etag);
|
||||
}
|
||||
|
||||
/// Check if the header will match any ETag (*)
|
||||
bool is_any() const
|
||||
{
|
||||
return any_value;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
|
@ -62,7 +62,8 @@ namespace ccf
|
|||
*/
|
||||
namespace jsonhandler
|
||||
{
|
||||
using JsonAdapterResponse = std::variant<ErrorDetails, nlohmann::json>;
|
||||
using JsonAdapterResponse =
|
||||
std::variant<ErrorDetails, RedirectDetails, nlohmann::json>;
|
||||
|
||||
char const* pack_to_content_type(serdes::Pack p);
|
||||
|
||||
|
@ -93,6 +94,8 @@ namespace ccf
|
|||
jsonhandler::JsonAdapterResponse make_error(
|
||||
http_status status, const std::string& code, const std::string& msg);
|
||||
|
||||
jsonhandler::JsonAdapterResponse make_redirect(http_status status);
|
||||
|
||||
using HandlerJsonParamsAndForward =
|
||||
std::function<jsonhandler::JsonAdapterResponse(
|
||||
endpoints::EndpointContext& ctx, nlohmann::json&& params)>;
|
||||
|
|
|
@ -82,6 +82,7 @@ namespace ccf
|
|||
ERROR(UnsupportedContentType)
|
||||
ERROR(RequestBodyTooLarge)
|
||||
ERROR(RequestHeaderTooLarge)
|
||||
ERROR(PreconditionFailed)
|
||||
|
||||
// CCF-specific errors
|
||||
// client-facing:
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
// Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
// Licensed under the Apache 2.0 License.
|
||||
#pragma once
|
||||
|
||||
#include "ccf/http_status.h"
|
||||
|
||||
namespace ccf
|
||||
{
|
||||
struct RedirectDetails
|
||||
{
|
||||
http_status status;
|
||||
};
|
||||
}
|
|
@ -8,6 +8,7 @@
|
|||
#include "ccf/http_header_map.h"
|
||||
#include "ccf/http_responder.h"
|
||||
#include "ccf/odata_error.h"
|
||||
#include "ccf/redirect.h"
|
||||
#include "ccf/rest_verb.h"
|
||||
#include "ccf/service/signed_req.h"
|
||||
#include "ccf/tx_id.h"
|
||||
|
@ -99,7 +100,7 @@ namespace ccf
|
|||
/// Returns value associated with named header, or nullopt of this header
|
||||
/// was not present.
|
||||
virtual std::optional<std::string> get_request_header(
|
||||
const std::string_view& name) = 0;
|
||||
const std::string_view& name) const = 0;
|
||||
|
||||
/// Returns full URL provided in request, rather than split into path +
|
||||
/// query.
|
||||
|
|
|
@ -11,6 +11,7 @@
|
|||
#include "ccf/ds/hash.h"
|
||||
#include "ccf/endpoints/authentication/all_of_auth.h"
|
||||
#include "ccf/historical_queries_adapter.h"
|
||||
#include "ccf/http_etag.h"
|
||||
#include "ccf/http_query.h"
|
||||
#include "ccf/indexing/strategies/seqnos_by_key_bucketed.h"
|
||||
#include "ccf/indexing/strategy.h"
|
||||
|
@ -44,6 +45,27 @@ namespace loggingapp
|
|||
};
|
||||
// SNIPPET_END: custom_identity
|
||||
|
||||
struct MatchHeaders
|
||||
{
|
||||
std::optional<std::string> if_match;
|
||||
std::optional<std::string> if_none_match;
|
||||
|
||||
MatchHeaders(const std::shared_ptr<ccf::RpcContext>& rpc_ctx) :
|
||||
if_match(rpc_ctx->get_request_header("if-match")),
|
||||
if_none_match(rpc_ctx->get_request_header("if-none-match"))
|
||||
{}
|
||||
|
||||
bool conflict() const
|
||||
{
|
||||
return if_match.has_value() && if_none_match.has_value();
|
||||
}
|
||||
|
||||
bool empty() const
|
||||
{
|
||||
return !if_match.has_value() && !if_none_match.has_value();
|
||||
}
|
||||
};
|
||||
|
||||
// SNIPPET_START: custom_auth_policy
|
||||
class CustomAuthPolicy : public ccf::AuthnPolicy
|
||||
{
|
||||
|
@ -560,7 +582,7 @@ namespace loggingapp
|
|||
}
|
||||
|
||||
return ccf::make_error(
|
||||
HTTP_STATUS_BAD_REQUEST,
|
||||
HTTP_STATUS_NOT_FOUND,
|
||||
ccf::errors::ResourceNotFound,
|
||||
fmt::format("No such record: {}.", id));
|
||||
};
|
||||
|
@ -760,6 +782,57 @@ namespace loggingapp
|
|||
ctx.tx.template rw<RecordsMap>(public_records(ctx));
|
||||
// SNIPPET_END: public_table_access
|
||||
const auto id = params["id"].get<size_t>();
|
||||
|
||||
// SNIPPET_START: public_table_post_match
|
||||
MatchHeaders match_headers(ctx.rpc_ctx);
|
||||
if (match_headers.conflict())
|
||||
{
|
||||
return ccf::make_error(
|
||||
HTTP_STATUS_BAD_REQUEST,
|
||||
ccf::errors::InvalidHeaderValue,
|
||||
"Cannot have both If-Match and If-None-Match headers.");
|
||||
}
|
||||
|
||||
// The presence of a Match header requires a read dependency
|
||||
// to check the value matches the constraint
|
||||
if (!match_headers.empty())
|
||||
{
|
||||
auto current_value = records_handle->get(id);
|
||||
if (current_value.has_value())
|
||||
{
|
||||
crypto::Sha256Hash value_digest(current_value.value());
|
||||
auto etag = value_digest.hex_str();
|
||||
|
||||
// On a POST operation, If-Match failing or If-None-Match passing
|
||||
// both return a 412 Precondition Failed to be returned, and no
|
||||
// side-effect.
|
||||
if (match_headers.if_match.has_value())
|
||||
{
|
||||
ccf::http::Matcher matcher(match_headers.if_match.value());
|
||||
if (!matcher.matches(etag))
|
||||
{
|
||||
return ccf::make_error(
|
||||
HTTP_STATUS_PRECONDITION_FAILED,
|
||||
ccf::errors::PreconditionFailed,
|
||||
"Resource has changed.");
|
||||
}
|
||||
}
|
||||
|
||||
if (match_headers.if_none_match.has_value())
|
||||
{
|
||||
ccf::http::Matcher matcher(match_headers.if_none_match.value());
|
||||
if (matcher.matches(etag))
|
||||
{
|
||||
return ccf::make_error(
|
||||
HTTP_STATUS_PRECONDITION_FAILED,
|
||||
ccf::errors::PreconditionFailed,
|
||||
"Resource has changed.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// SNIPPET_END: public_table_post_match
|
||||
|
||||
records_handle->put(id, in.msg);
|
||||
// SNIPPET_START: set_claims_digest
|
||||
if (in.record_claim)
|
||||
|
@ -768,6 +841,13 @@ namespace loggingapp
|
|||
}
|
||||
// SNIPPET_END: set_claims_digest
|
||||
CCF_APP_INFO("Storing {} = {}", id, in.msg);
|
||||
|
||||
// SNIPPET_START: public_table_post_etag
|
||||
crypto::Sha256Hash value_digest(in.msg);
|
||||
// Succesful calls set an ETag
|
||||
ctx.rpc_ctx->set_response_header("ETag", value_digest.hex_str());
|
||||
// SNIPPET_END: public_table_post_etag
|
||||
|
||||
return ccf::make_success(true);
|
||||
};
|
||||
// SNIPPET_END: record_public
|
||||
|
@ -799,15 +879,57 @@ namespace loggingapp
|
|||
ctx.tx.template ro<RecordsMap>(public_records(ctx));
|
||||
auto record = public_records_handle->get(id);
|
||||
|
||||
// SNIPPET_START: public_table_get_match
|
||||
// If there is not value, the response is always Not Found
|
||||
// regardless of Match headers
|
||||
if (record.has_value())
|
||||
{
|
||||
MatchHeaders match_headers(ctx.rpc_ctx);
|
||||
if (match_headers.conflict())
|
||||
{
|
||||
return ccf::make_error(
|
||||
HTTP_STATUS_BAD_REQUEST,
|
||||
ccf::errors::InvalidHeaderValue,
|
||||
"Cannot have both If-Match and If-None-Match headers.");
|
||||
}
|
||||
|
||||
// If a record is present, compute an Entity Tag, and apply
|
||||
// If-Match and If-None-Match.
|
||||
crypto::Sha256Hash value_digest(record.value());
|
||||
const auto etag = value_digest.hex_str();
|
||||
|
||||
if (match_headers.if_match.has_value())
|
||||
{
|
||||
ccf::http::Matcher matcher(match_headers.if_match.value());
|
||||
if (!matcher.matches(etag))
|
||||
{
|
||||
return ccf::make_error(
|
||||
HTTP_STATUS_PRECONDITION_FAILED,
|
||||
ccf::errors::PreconditionFailed,
|
||||
"Resource has changed.");
|
||||
}
|
||||
}
|
||||
|
||||
// On a GET, If-None-Match passing returns 304 Not Modified
|
||||
if (match_headers.if_none_match.has_value())
|
||||
{
|
||||
ccf::http::Matcher matcher(match_headers.if_none_match.value());
|
||||
if (matcher.matches(etag))
|
||||
{
|
||||
return ccf::make_redirect(HTTP_STATUS_NOT_MODIFIED);
|
||||
}
|
||||
}
|
||||
|
||||
// Succesful calls set an ETag
|
||||
ctx.rpc_ctx->set_response_header("ETag", etag);
|
||||
CCF_APP_INFO("Fetching {} = {}", id, record.value());
|
||||
return ccf::make_success(LoggingGet::Out{record.value()});
|
||||
}
|
||||
// SNIPPET_END: public_table_get_match
|
||||
|
||||
CCF_APP_INFO("Fetching - no entry for {}", id);
|
||||
return ccf::make_error(
|
||||
HTTP_STATUS_BAD_REQUEST,
|
||||
HTTP_STATUS_NOT_FOUND,
|
||||
ccf::errors::ResourceNotFound,
|
||||
fmt::format("No such record: {}.", id));
|
||||
};
|
||||
|
@ -838,10 +960,56 @@ namespace loggingapp
|
|||
|
||||
auto records_handle =
|
||||
ctx.tx.template rw<RecordsMap>(public_records(ctx));
|
||||
auto had = records_handle->has(id);
|
||||
records_handle->remove(id);
|
||||
auto current_value = records_handle->get(id);
|
||||
|
||||
return ccf::make_success(LoggingRemove::Out{had});
|
||||
// SNIPPET_START: public_table_delete_match
|
||||
// If there is no value, we don't need to look at the Match
|
||||
// headers to report that the value is deleted (200 OK)
|
||||
if (current_value.has_value())
|
||||
{
|
||||
MatchHeaders match_headers(ctx.rpc_ctx);
|
||||
if (match_headers.conflict())
|
||||
{
|
||||
return ccf::make_error(
|
||||
HTTP_STATUS_BAD_REQUEST,
|
||||
ccf::errors::InvalidHeaderValue,
|
||||
"Cannot have both If-Match and If-None-Match headers.");
|
||||
}
|
||||
|
||||
if (!match_headers.empty())
|
||||
{
|
||||
// If a Match header is present, we need to compute the ETag
|
||||
// to resolve the constraints
|
||||
crypto::Sha256Hash value_digest(current_value.value());
|
||||
const auto etag = value_digest.hex_str();
|
||||
|
||||
if (match_headers.if_match.has_value())
|
||||
{
|
||||
ccf::http::Matcher matcher(match_headers.if_match.value());
|
||||
if (!matcher.matches(etag))
|
||||
{
|
||||
return ccf::make_error(
|
||||
HTTP_STATUS_PRECONDITION_FAILED,
|
||||
ccf::errors::PreconditionFailed,
|
||||
"Resource has changed.");
|
||||
}
|
||||
}
|
||||
|
||||
if (match_headers.if_none_match.has_value())
|
||||
{
|
||||
ccf::http::Matcher matcher(match_headers.if_none_match.value());
|
||||
if (matcher.matches(etag))
|
||||
{
|
||||
return ccf::make_redirect(HTTP_STATUS_NOT_MODIFIED);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// SNIPPET_END: public_table_delete_match
|
||||
|
||||
// Succesful calls remove the value, and therefore do not set an ETag
|
||||
records_handle->remove(id);
|
||||
return ccf::make_success(LoggingRemove::Out{current_value.has_value()});
|
||||
};
|
||||
make_endpoint(
|
||||
"/log/public",
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
#include "ccf/http_consts.h"
|
||||
#include "ccf/odata_error.h"
|
||||
#include "ccf/redirect.h"
|
||||
#include "ccf/rpc_context.h"
|
||||
#include "http/http_accept.h"
|
||||
#include "node/rpc/rpc_exception.h"
|
||||
|
@ -144,35 +145,44 @@ namespace ccf
|
|||
}
|
||||
else
|
||||
{
|
||||
const auto body = std::get_if<nlohmann::json>(&res);
|
||||
if (body->is_null())
|
||||
auto redirect = std::get_if<RedirectDetails>(&res);
|
||||
if (redirect != nullptr)
|
||||
{
|
||||
ctx->set_response_status(HTTP_STATUS_NO_CONTENT);
|
||||
ctx->set_response_status(redirect->status);
|
||||
}
|
||||
else
|
||||
{
|
||||
ctx->set_response_status(HTTP_STATUS_OK);
|
||||
const auto packing = get_response_pack(ctx, request_packing);
|
||||
switch (packing)
|
||||
const auto body = std::get_if<nlohmann::json>(&res);
|
||||
if (body->is_null())
|
||||
{
|
||||
case serdes::Pack::Text:
|
||||
{
|
||||
const auto s = body->dump();
|
||||
ctx->set_response_body(std::vector<uint8_t>(s.begin(), s.end()));
|
||||
break;
|
||||
}
|
||||
case serdes::Pack::MsgPack:
|
||||
{
|
||||
ctx->set_response_body(nlohmann::json::to_msgpack(*body));
|
||||
break;
|
||||
}
|
||||
default:
|
||||
{
|
||||
throw std::logic_error("Unhandled serdes::Pack");
|
||||
}
|
||||
ctx->set_response_status(HTTP_STATUS_NO_CONTENT);
|
||||
}
|
||||
else
|
||||
{
|
||||
ctx->set_response_status(HTTP_STATUS_OK);
|
||||
const auto packing = get_response_pack(ctx, request_packing);
|
||||
switch (packing)
|
||||
{
|
||||
case serdes::Pack::Text:
|
||||
{
|
||||
const auto s = body->dump();
|
||||
ctx->set_response_body(
|
||||
std::vector<uint8_t>(s.begin(), s.end()));
|
||||
break;
|
||||
}
|
||||
case serdes::Pack::MsgPack:
|
||||
{
|
||||
ctx->set_response_body(nlohmann::json::to_msgpack(*body));
|
||||
break;
|
||||
}
|
||||
default:
|
||||
{
|
||||
throw std::logic_error("Unhandled serdes::Pack");
|
||||
}
|
||||
}
|
||||
ctx->set_response_header(
|
||||
http::headers::CONTENT_TYPE, pack_to_content_type(packing));
|
||||
}
|
||||
ctx->set_response_header(
|
||||
http::headers::CONTENT_TYPE, pack_to_content_type(packing));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -202,6 +212,11 @@ namespace ccf
|
|||
return ErrorDetails{status, code, msg};
|
||||
}
|
||||
|
||||
jsonhandler::JsonAdapterResponse make_redirect(http_status status)
|
||||
{
|
||||
return RedirectDetails{status};
|
||||
}
|
||||
|
||||
endpoints::EndpointFunction json_adapter(const HandlerJsonParamsAndForward& f)
|
||||
{
|
||||
return [f](endpoints::EndpointContext& ctx) {
|
||||
|
|
|
@ -192,7 +192,7 @@ namespace http
|
|||
}
|
||||
|
||||
virtual std::optional<std::string> get_request_header(
|
||||
const std::string_view& name) override
|
||||
const std::string_view& name) const override
|
||||
{
|
||||
const auto it = request_headers.find(name);
|
||||
if (it != request_headers.end())
|
||||
|
|
|
@ -0,0 +1,75 @@
|
|||
// Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
// Licensed under the Apache 2.0 License.
|
||||
|
||||
#include "ccf/http_etag.h"
|
||||
|
||||
#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN
|
||||
#include <doctest/doctest.h>
|
||||
#include <string>
|
||||
#define FMT_HEADER_ONLY
|
||||
#include <fmt/format.h>
|
||||
|
||||
TEST_CASE("If-Match: *")
|
||||
{
|
||||
ccf::http::Matcher im("*");
|
||||
REQUIRE(im.is_any());
|
||||
REQUIRE(im.matches(""));
|
||||
REQUIRE(im.matches("abc"));
|
||||
}
|
||||
|
||||
TEST_CASE("If-Match: \"abc\"")
|
||||
{
|
||||
ccf::http::Matcher im("\"abc\"");
|
||||
REQUIRE(!im.is_any());
|
||||
REQUIRE(!im.matches(""));
|
||||
REQUIRE(im.matches("abc"));
|
||||
REQUIRE(!im.matches("def"));
|
||||
}
|
||||
|
||||
TEST_CASE("If-Match: \"abc\", \"def\"")
|
||||
{
|
||||
ccf::http::Matcher im("\"abc\", \"def\"");
|
||||
REQUIRE(!im.is_any());
|
||||
REQUIRE(!im.matches(""));
|
||||
REQUIRE(im.matches("abc"));
|
||||
REQUIRE(im.matches("def"));
|
||||
REQUIRE(!im.matches("ghi"));
|
||||
}
|
||||
|
||||
TEST_CASE("If-Match invalid inputs")
|
||||
{
|
||||
REQUIRE_THROWS_AS_MESSAGE(
|
||||
ccf::http::Matcher im(""), std::runtime_error, "Invalid If-Match header");
|
||||
REQUIRE_THROWS_AS_MESSAGE(
|
||||
ccf::http::Matcher im("not etags"),
|
||||
std::runtime_error,
|
||||
"Invalid If-Match header");
|
||||
REQUIRE_THROWS_AS_MESSAGE(
|
||||
ccf::http::Matcher im("\"abc\", not etags"),
|
||||
std::runtime_error,
|
||||
"Invalid If-Match header");
|
||||
REQUIRE_THROWS_AS_MESSAGE(
|
||||
ccf::http::Matcher im("not etags, \"abc\""),
|
||||
std::runtime_error,
|
||||
"Invalid If-Match header");
|
||||
REQUIRE_THROWS_AS_MESSAGE(
|
||||
ccf::http::Matcher im("W/\"abc\""),
|
||||
std::runtime_error,
|
||||
"Invalid If-Match header");
|
||||
REQUIRE_THROWS_AS_MESSAGE(
|
||||
ccf::http::Matcher im("W/\"abc\", \"def\""),
|
||||
std::runtime_error,
|
||||
"Invalid If-Match header");
|
||||
REQUIRE_THROWS_AS_MESSAGE(
|
||||
ccf::http::Matcher im("\"abc\", \"def"),
|
||||
std::runtime_error,
|
||||
"Invalid If-Match header");
|
||||
REQUIRE_THROWS_AS_MESSAGE(
|
||||
ccf::http::Matcher im("\"abc\",, \"def\""),
|
||||
std::runtime_error,
|
||||
"Invalid If-Match header");
|
||||
REQUIRE_THROWS_AS_MESSAGE(
|
||||
ccf::http::Matcher im(",\"abc\""),
|
||||
std::runtime_error,
|
||||
"Invalid If-Match header");
|
||||
}
|
|
@ -32,10 +32,10 @@ import infra.crypto
|
|||
from infra.runner import ConcurrentRunner
|
||||
from hashlib import sha256
|
||||
from infra.member import AckException
|
||||
import e2e_common_endpoints
|
||||
from types import MappingProxyType
|
||||
import threading
|
||||
import copy
|
||||
import e2e_common_endpoints
|
||||
|
||||
from loguru import logger as LOG
|
||||
|
||||
|
@ -410,7 +410,7 @@ def test_remove(network, args):
|
|||
else:
|
||||
check(
|
||||
r,
|
||||
error=lambda status, msg: status == http.HTTPStatus.BAD_REQUEST.value
|
||||
error=lambda status, msg: status == http.HTTPStatus.NOT_FOUND.value
|
||||
and msg.json()["error"]["code"] == "ResourceNotFound",
|
||||
)
|
||||
|
||||
|
@ -455,7 +455,7 @@ def test_clear(network, args):
|
|||
check(
|
||||
get_r,
|
||||
error=lambda status, msg: status
|
||||
== http.HTTPStatus.BAD_REQUEST.value,
|
||||
== http.HTTPStatus.NOT_FOUND.value,
|
||||
)
|
||||
|
||||
# Make sure no-one else is still looking for these
|
||||
|
@ -1754,7 +1754,7 @@ def test_committed_index(network, args, timeout=5):
|
|||
network.txs.delete(log_id, priv=True)
|
||||
|
||||
r = network.txs.request(log_id, priv=True)
|
||||
assert r.status_code == http.HTTPStatus.BAD_REQUEST.value, r.status_code
|
||||
assert r.status_code == http.HTTPStatus.NOT_FOUND.value, r.status_code
|
||||
assert r.body.json()["error"]["message"] == f"No such record: {log_id}."
|
||||
assert r.body.json()["error"]["code"] == "ResourceNotFound"
|
||||
|
||||
|
@ -1798,6 +1798,172 @@ def test_basic_constraints(network, args):
|
|||
assert basic_constraints.value.ca is False
|
||||
|
||||
|
||||
def test_etags(network, args):
|
||||
primary, _ = network.find_primary()
|
||||
|
||||
with primary.client("user0") as c:
|
||||
doc = {"id": 999999, "msg": "hello world"}
|
||||
etag = sha256(doc["msg"].encode()).hexdigest()
|
||||
|
||||
# POST ETag matches value
|
||||
r = c.post("/app/log/public", doc)
|
||||
assert r.status_code == http.HTTPStatus.OK
|
||||
assert r.headers["ETag"] == etag, r.headers["ETag"]
|
||||
|
||||
# GET ETag matches value
|
||||
r = c.get(f"/app/log/public?id={doc['id']}")
|
||||
assert r.status_code == http.HTTPStatus.OK
|
||||
assert r.headers["ETag"] == etag, r.headers["ETag"]
|
||||
|
||||
# GET If-Match: * for missing resource still returns 404
|
||||
r = c.get("/app/log/public?id=999998", headers={"If-Match": "*"})
|
||||
assert r.status_code == http.HTTPStatus.NOT_FOUND
|
||||
|
||||
# GET If-Match: * for existing resource returns 200
|
||||
r = c.get(f"/app/log/public?id={doc['id']}", headers={"If-Match": "*"})
|
||||
assert r.status_code == http.HTTPStatus.OK
|
||||
assert r.headers["ETag"] == etag, r.headers["ETag"]
|
||||
|
||||
# GET If-Match: mismatching ETag returns 412
|
||||
r = c.get(f"/app/log/public?id={doc['id']}", headers={"If-Match": '"abc"'})
|
||||
assert r.status_code == http.HTTPStatus.PRECONDITION_FAILED
|
||||
assert r.body.json()["error"]["code"] == "PreconditionFailed"
|
||||
|
||||
# GET If-Match: matching ETag returns 200
|
||||
r = c.get(f"/app/log/public?id={doc['id']}", headers={"If-Match": f'"{etag}"'})
|
||||
assert r.status_code == http.HTTPStatus.OK
|
||||
assert r.body.json() == {"msg": doc["msg"]}
|
||||
|
||||
# GET If-Match: multiple ETags including matching returns 200
|
||||
r = c.get(
|
||||
f"/app/log/public?id={doc['id']}", headers={"If-Match": f'"{etag}", "abc"'}
|
||||
)
|
||||
assert r.status_code == http.HTTPStatus.OK
|
||||
assert r.body.json() == {"msg": doc["msg"]}
|
||||
|
||||
doc = {"id": 999999, "msg": "saluton mondo"}
|
||||
|
||||
# POST If-Match: mismatching ETag returns 412
|
||||
r = c.post("/app/log/public", doc, headers={"If-Match": '"abc"'})
|
||||
assert r.status_code == http.HTTPStatus.PRECONDITION_FAILED
|
||||
|
||||
# POST If-Match: matching ETag returns 200
|
||||
r = c.post("/app/log/public", doc, headers={"If-Match": f'"{etag}"'})
|
||||
assert r.status_code == http.HTTPStatus.OK
|
||||
etag = sha256(doc["msg"].encode()).hexdigest()
|
||||
assert r.headers["ETag"] == etag, r.headers["ETag"]
|
||||
|
||||
# POST If-Match: mutiple ETags, first one matching returns 200
|
||||
r = c.post("/app/log/public", doc, headers={"If-Match": f'"{etag}", "abc"'})
|
||||
assert r.status_code == http.HTTPStatus.OK
|
||||
etag = sha256(doc["msg"].encode()).hexdigest()
|
||||
assert r.headers["ETag"] == etag, r.headers["ETag"]
|
||||
|
||||
# POST If-Match: mutiple ETags, one, not the first, matching returns 200
|
||||
r = c.post("/app/log/public", doc, headers={"If-Match": f'"abc", "{etag}"'})
|
||||
assert r.status_code == http.HTTPStatus.OK
|
||||
etag = sha256(doc["msg"].encode()).hexdigest()
|
||||
assert r.headers["ETag"] == etag, r.headers["ETag"]
|
||||
|
||||
# POST If-Match: multiple, none matching, returns 412
|
||||
r = c.post("/app/log/public", doc, headers={"If-Match": '"abc", "def"'})
|
||||
assert r.status_code == http.HTTPStatus.PRECONDITION_FAILED
|
||||
|
||||
# POST If-None-Match: * on existing resource returns 412
|
||||
r = c.post("/app/log/public", doc, headers={"If-None-Match": "*"})
|
||||
assert r.status_code == http.HTTPStatus.PRECONDITION_FAILED
|
||||
etag = sha256(doc["msg"].encode()).hexdigest()
|
||||
|
||||
# POST If-None-Match: matching ETag on existing resource returns 412
|
||||
r = c.post("/app/log/public", doc, headers={"If-None-Match": "*"})
|
||||
assert r.status_code == http.HTTPStatus.PRECONDITION_FAILED
|
||||
etag = sha256(doc["msg"].encode()).hexdigest()
|
||||
|
||||
# DELETE If-Match: mismatching ETag returns 412
|
||||
r = c.delete(f"/app/log/public?id={doc['id']}", headers={"If-Match": '"abc"'})
|
||||
assert r.status_code == http.HTTPStatus.PRECONDITION_FAILED
|
||||
|
||||
# DELETE If-Match: matching ETag returns 200
|
||||
r = c.delete(
|
||||
f"/app/log/public?id={doc['id']}", headers={"If-Match": f'"{etag}"'}
|
||||
)
|
||||
assert r.status_code == http.HTTPStatus.OK
|
||||
|
||||
# DELETE If-Match: missing resource still returns 200
|
||||
r = c.delete(
|
||||
f"/app/log/public?id={doc['id']}", headers={"If-Match": f'"{etag}"'}
|
||||
)
|
||||
assert r.status_code == http.HTTPStatus.OK
|
||||
|
||||
# DELETE If-Match: mismatching ETag for missing resouce still returns 200
|
||||
r = c.delete(f"/app/log/public?id={doc['id']}", headers={"If-Match": '"abc"'})
|
||||
assert r.status_code == http.HTTPStatus.OK
|
||||
|
||||
# Restore resource
|
||||
r = c.post("/app/log/public", doc)
|
||||
assert r.status_code == http.HTTPStatus.OK
|
||||
assert r.headers["ETag"] == etag, r.headers["ETag"]
|
||||
|
||||
# GET If-None-Match: * for existing resource returns 304
|
||||
r = c.get("/app/log/public?id=999999", headers={"If-None-Match": "*"})
|
||||
assert r.status_code == http.HTTPStatus.NOT_MODIFIED
|
||||
|
||||
# GET If-None-Match: matching ETag for existing resource returns 304
|
||||
r = c.get("/app/log/public?id=999999", headers={"If-None-Match": f'"{etag}"'})
|
||||
assert r.status_code == http.HTTPStatus.NOT_MODIFIED
|
||||
|
||||
# GET If-None-Match: mismatching ETag for existing resource returns 200
|
||||
r = c.get("/app/log/public?id=999999", headers={"If-None-Match": '"abc"'})
|
||||
assert r.status_code == http.HTTPStatus.OK
|
||||
assert r.body.json() == {"msg": doc["msg"]}
|
||||
assert r.headers["ETag"] == etag, r.headers["ETag"]
|
||||
|
||||
# DELETE If-None-Match: * on missing resource returns 304
|
||||
r = c.delete("/app/log/public?id=999998", headers={"If-None-Match": "*"})
|
||||
assert r.status_code == http.HTTPStatus.OK
|
||||
|
||||
# DELETE If-None-Match: on mismatching ETag for missing resource 200
|
||||
r = c.delete("/app/log/public?id=999998", headers={"If-None-Match": '"abc"'})
|
||||
assert r.status_code == http.HTTPStatus.OK
|
||||
|
||||
# DELETE If-None-Match: * on existing resource is 304
|
||||
r = c.delete(f"/app/log/public?id={doc['id']}", headers={"If-None-Match": "*"})
|
||||
assert r.status_code == http.HTTPStatus.NOT_MODIFIED
|
||||
r = c.get(f"/app/log/public?id={doc['id']}")
|
||||
assert r.status_code == http.HTTPStatus.OK
|
||||
|
||||
# DELETE If-None-Match: matching ETag on existing resource is 304
|
||||
r = c.delete(
|
||||
f"/app/log/public?id={doc['id']}", headers={"If-None-Match": f'"{etag}"'}
|
||||
)
|
||||
assert r.status_code == http.HTTPStatus.NOT_MODIFIED
|
||||
r = c.get(f"/app/log/public?id={doc['id']}")
|
||||
assert r.status_code == http.HTTPStatus.OK
|
||||
|
||||
# DELETE If-None-Match: mismatching ETag on existing resource is 200
|
||||
r = c.delete(
|
||||
f"/app/log/public?id={doc['id']}", headers={"If-None-Match": '"abc"'}
|
||||
)
|
||||
assert r.status_code == http.HTTPStatus.OK
|
||||
r = c.get(f"/app/log/public?id={doc['id']}")
|
||||
assert r.status_code == http.HTTPStatus.NOT_FOUND
|
||||
|
||||
# POST If-None-Match: * on deleted returns 200
|
||||
r = c.post("/app/log/public", doc, headers={"If-None-Match": "*"})
|
||||
assert r.status_code == http.HTTPStatus.OK
|
||||
etag = sha256(doc["msg"].encode()).hexdigest()
|
||||
|
||||
r = c.get(f"/app/log/public?id={doc['id']}")
|
||||
assert r.status_code == http.HTTPStatus.OK
|
||||
|
||||
# POST If-None-Match: mismatching ETag returns 200
|
||||
r = c.post("/app/log/public", doc, headers={"If-None-Match": '"abc"'})
|
||||
assert r.status_code == http.HTTPStatus.OK
|
||||
etag = sha256(doc["msg"].encode()).hexdigest()
|
||||
|
||||
return network
|
||||
|
||||
|
||||
def run_udp_tests(args):
|
||||
# Register secondary interface as an UDP socket on all nodes
|
||||
udp_interface = infra.interfaces.make_secondary_interface("udp", "udp_interface")
|
||||
|
@ -1882,6 +2048,8 @@ def run(args):
|
|||
test_historical_receipts(network, args)
|
||||
test_historical_receipts_with_claims(network, args)
|
||||
test_genesis_receipt(network, args)
|
||||
if args.package == "samples/apps/logging/liblogging":
|
||||
test_etags(network, args)
|
||||
|
||||
|
||||
def run_parsing_errors(args):
|
||||
|
|
|
@ -274,7 +274,7 @@ class Response:
|
|||
return (
|
||||
f"<{status_color}>{self.status_code}</> "
|
||||
+ (
|
||||
f"<yellow>[Redirect to -> {self.headers['location']}]</> "
|
||||
f"<yellow>[Redirect to -> {self.headers.get('location')}]</> "
|
||||
if redirect
|
||||
else ""
|
||||
)
|
||||
|
|
Загрузка…
Ссылка в новой задаче