userver: Easy - library for single file prototyping
Loading...
Searching...
No Matches
Easy - library for single file prototyping

Quality: Silver Tier.

Easy is the userver library for easy prototyping. Service functionality is described in code in a short and declarative way. Static configs and database schema are embedded into the binary to simplify deployment and are applied automatically at service start.

Migration of a service on easy library to a more functional pg_service_template is straightforward.

Hello world service with the easy library

Let's write a service that responds Hello world to any request by URL /hello. To do that, just describe the handler in main.cpp file:

#include <userver/utest/using_namespace_userver.hpp> // Note: this is for the purposes of samples only
#include <userver/easy.hpp>
int main(int argc, char* argv[]) {
easy::HttpWith<>(argc, argv)
.DefaultContentType(http::content_type::kTextPlain)
.Route("/hello", [](const server::http::HttpRequest& /*req*/) {
return "Hello world"; // Just return the string as a response body
});
}

Build it with a trivial CMakeLists.txt:

project(userver-easy-samples-hello-world CXX)
add_executable(${PROJECT_NAME} "main.cpp")
target_link_libraries(${PROJECT_NAME} userver::easy)
userver_testsuite_add_simple(DUMP_CONFIG True)

Note the userver_testsuite_add_simple(DUMP_CONFIG True) usage. It automatically adds the testsuite directory and tells the testsuite to retrieve static config from the binary itself, so that the new service can be easily tested from python. Just add a testsuite/conftest.py file:

pytest_plugins = ['pytest_userver.plugins.core'] # Use only the core plugin

And put the tests in any other file, for example testsuite/test_basic.py:

async def test_hello_simple(service_client):
response = await service_client.get('/hello')
assert response.status == 200
assert 'text/plain' in response.headers['Content-Type']
assert response.text == 'Hello world'

Tests can be run in a usual way, for example

# bash
make -j11 userver-easy-samples-hello-world && (cd libraries/easy/samples/0_hello_world && ctest -V)

The easy library works well with any callables, so feel free to move the logic out of lambdas to functions or functional objects:

#include <userver/utest/using_namespace_userver.hpp> // Note: this is for the purposes of samples only
#include <userver/easy.hpp>
std::string Greet(const server::http::HttpRequest& req) {
const auto& username = req.GetPathArg("user");
return "Hello, " + username;
}
struct Hi {
std::string operator()(const server::http::HttpRequest& req) const { return "Hi, " + req.GetArg("name"); }
};
int main(int argc, char* argv[]) {
easy::HttpWith<>(argc, argv)
.DefaultContentType(http::content_type::kTextPlain)
.Get("/hi/{user}", &Greet)
.Get("/hi", Hi{});
}
Note
Each callable for a route in the easy library actually creates and configures a component derived from server::handlers::HttpHandlerBase. See Writing your first HTTP server for more insight on what is going on under the hood of the easy library.

Key-Value storage service with the easy library

Let's write a service that stores a key if it was HTTP POSTed by URL /kv with URL arguments key and value. For example HTTP POST /kv?key=the_key&value=the_value store the the_value by key the_key. To retrieve a key, an HTTP GET request for the URL /kv?key=KEY is done.

For this service we will need a database. To add a database to the service an easy::PgDep dependency should be added to the easy::HttpWith. After that, the dependency can be retrieved in the handlers:

#include <userver/utest/using_namespace_userver.hpp> // Note: this is for the purposes of samples only
#include <userver/easy.hpp>
constexpr std::string_view kSchema = R"~(
CREATE TABLE IF NOT EXISTS key_value_table (
key VARCHAR PRIMARY KEY,
value VARCHAR
)
)~";
int main(int argc, char* argv[]) {
.DbSchema(kSchema)
.Get(
"/kv",
[](const server::http::HttpRequest& req, const easy::PgDep& dep) {
auto res = dep.pg().Execute(
"SELECT value FROM key_value_table WHERE key=$1",
req.GetArg("key")
);
return res[0][0].As<std::string>();
}
)
.Post(
"/kv",
[](const server::http::HttpRequest& req, const auto& dep) {
dep.pg().Execute(
"INSERT INTO key_value_table(key, value) VALUES($1, $2) ON CONFLICT (key) DO UPDATE SET value = $2",
req.GetArg("key"),
req.GetArg("value")
);
return std::string{};
}
)
.DefaultContentType(http::content_type::kTextPlain);
}

Note the easy::HttpWith::DbSchema usage. PostgreSQL database requires schema, so it is provided in place.

To test the service we should instruct the testsuite to retrieve the schema from the service. Content of testsuite/conftest.py file is:

import pytest
from testsuite.databases.pgsql import discover
pytest_plugins = ['pytest_userver.plugins.postgresql']
@pytest.fixture(scope='session')
def pgsql_local(db_dump_schema_path, pgsql_local_create):
databases = discover.find_schemas('admin', [db_dump_schema_path])
return pgsql_local_create(list(databases.values()))

After that tests can be written in a usual way, for example testsuite/test_basic.py:

async def test_kv(service_client):
response = await service_client.post('/kv?key=1&value=one')
assert response.status == 200
response = await service_client.get('/kv?key=1')
assert response.status == 200
assert response.text == 'one'
response = await service_client.post('/kv?key=1&value=again_1')
assert response.status == 200
response = await service_client.get('/kv?key=1')
assert response.status == 200
assert response.text == 'again_1'

JSON service with the easy library

As you may noticed from a previous example, passing arguments in an URL may not be comfortable, especially when the request is complex. In this example, let's change the previous service to accept JSON request and answers with JSON, and force the keys to be integers.

If the function for a path accepts formats::json::Value the easy library attempts to automatically parse the request as JSON. If the function returns formats::json::Value, then the content type is automatically set to application/json:

#include <userver/utest/using_namespace_userver.hpp> // Note: this is for the purposes of samples only
#include <userver/easy.hpp>
#include "schemas/key_value.hpp"
constexpr std::string_view kSchema = R"~(
CREATE TABLE IF NOT EXISTS key_value_table (
key integer PRIMARY KEY,
value VARCHAR
)
)~";
int main(int argc, char* argv[]) {
.DbSchema(kSchema)
.Get(
"/kv",
[](formats::json::Value request_json, const easy::PgDep& dep) {
// Use generated parser for As()
auto key = request_json.As<schemas::KeyRequest>().key;
auto res = dep.pg().Execute(
storages::postgres::ClusterHostType::kSlave, "SELECT value FROM key_value_table WHERE key=$1", key
);
schemas::KeyValue response{key, res[0][0].As<std::string>()};
}
)
.Post("/kv", [](formats::json::Value request_json, easy::PgDep dep) {
// Use generated parser for As()
auto key_value = request_json.As<schemas::KeyValue>();
dep.pg().Execute(
"INSERT INTO key_value_table(key, value) VALUES($1, $2) ON CONFLICT (key) DO UPDATE SET value = $2",
key_value.key,
key_value.value
);
});
}

Note the request_json.As<schemas::KeyRequest>() usage. This example uses JSON schema codegen - the Chaotic to generate the parsers and serializers via CMakeLists.txt:

project(userver-easy-samples-json CXX)
file(GLOB_RECURSE SCHEMAS ${CMAKE_CURRENT_SOURCE_DIR}/schemas/*.yaml)
userver_target_generate_chaotic(${PROJECT_NAME}-chgen
ARGS
# Map '/components/schemas/*' JSONSchema types to C++ types in 'schemas' namespace
-n "/components/schemas/([^/]*)/=schemas::{0}"
-f "(.*)={0}"
# Don't call clang-format
--clang-format=
# Generate serializers for responses
--generate-serializers
OUTPUT_DIR
${CMAKE_CURRENT_BINARY_DIR}/src
SCHEMAS
${SCHEMAS}
RELATIVE_TO
${CMAKE_CURRENT_SOURCE_DIR}
)
add_executable(${PROJECT_NAME} "main.cpp")
target_link_libraries(${PROJECT_NAME} userver::easy ${PROJECT_NAME}-chgen)
userver_testsuite_add_simple(DUMP_CONFIG True)

Content of testsuite/conftest.py file did not change, the testsuite/test_basic.py now uses JSON:

async def test_kv(service_client):
response = await service_client.post(
'/kv', json={'key': 1, 'value': 'one'},
)
assert response.status == 200
assert response.json() is None
assert 'application/json' in response.headers['Content-Type']
response = await service_client.get('/kv', json={'key': 1})
assert response.status == 200
assert response.json() == {'key': 1, 'value': 'one'}
assert 'application/json' in response.headers['Content-Type']
response = await service_client.post(
'/kv', json={'key': 1, 'value': 'again_1'},
)
assert response.status == 200
response = await service_client.get('/kv', json={'key': 1})
assert response.status == 200
assert response.json() == {'key': 1, 'value': 'again_1'}
assert 'application/json' in response.headers['Content-Type']

Custom and multiple dependencies for a service with the easy library

When the logic of your service becomes complicated and big it is a natural desire to move parts of the logic into a separate entities. Component system is the solution for userver based services.

Consider the example, where an HTTP client to a remote service is converted to a component:

class ActionClient : public components::ComponentBase {
public:
static constexpr std::string_view kName = "action-client";
ActionClient(const components::ComponentConfig& config, const components::ComponentContext& context)
: ComponentBase{config, context},
service_url_(config["service-url"].As<std::string>()),
http_client_(context.FindComponent<components::HttpClient>().GetHttpClient()) {}
auto CreateHttpRequest(std::string action) const {
return http_client_.CreateRequest().url(service_url_).post().data(std::move(action)).perform();
}
static yaml_config::Schema GetStaticConfigSchema() {
return yaml_config::MergeSchemas<components::ComponentBase>(R"(
type: object
description: My dependencies schema
additionalProperties: false
properties:
service-url:
type: string
description: URL of the service to send the actions to
)");
}
private:
const std::string service_url_;
clients::http::Client& http_client_;
};

To use that component with the easy library a dependency type should be written, that has a constructor from a single const components::ComponentContext& parameter to retrieve the clients/components and has a static void RegisterOn(easy::HttpBase& app) member function, to register the required component and configs in the component list.

class ActionDep {
public:
explicit ActionDep(const components::ComponentContext& config) : component_{config.FindComponent<ActionClient>()} {}
auto CreateActionRequest(std::string action) const { return component_.CreateHttpRequest(std::move(action)); }
static void RegisterOn(easy::HttpBase& app) {
app.TryAddComponent<ActionClient>(ActionClient::kName, "service-url: http://some-service.example/v1/action");
easy::HttpDep::RegisterOn(app);
}
private:
ActionClient& component_;
};
Note
It is time to think about migrating to a more functional pg_service_template if new components start to appear in the service written via the easy library.

Multiple dependencies can be used with easy::HttpWith in the following way:

int main(int argc, char* argv[]) {
.DbSchema(kSchema)
.DefaultContentType(http::content_type::kTextPlain)
.Post("/log", [](const server::http::HttpRequest& req, const Deps& deps) {
const auto& action = req.GetArg("action");
deps.pg().Execute(
storages::postgres::ClusterHostType::kMaster, "INSERT INTO events_table(action) VALUES($1)", action
);
return deps.CreateActionRequest(action)->body();
});
}

See the full example, including tests:

Migration from the easy library to a service template

At some point a prototype service becomes a production ready solution. From that point usually more control over databases, configurations and deployment is required than the easy library provides. In that case, migration to an opensourse service template is recommended.

Let's take a look, how a service from a previous example can be migrated to the pg_service_template.

First of all, follow the instructions at service template to make a new service from a template.

Migration of configs from the easy library to a service template

To get the up to date static configs from the easy library, just build the service and run it with --dump-config and the binary will output the config to STDOUT. You can also provide a path to dump the static config, for example ./your_prototype --dump-config pg_service_template_based_service/configs/static_config.yaml.

If there's any userver_testsuite_add_simple(DUMP_CONFIG True) in a CMakeLists.txt then do not forget to remove DUMP_CONFIG True to stop the testsuite from taking the static configuration from the binary.

Migration of database schema from the easy library to a service template

To get the up to date database schemas from an easy library, just build the binary and run it with --dump-db-schema and the binary will output the database schema to STDOUT. You can also provide a path to dump the schema, for example ./your_prototype --dump-db-schema pg_service_template_based_service/postgresql/schemas/db_1.sql.

Another option is to take the schema from easy::HttpWith::DbSchema(). In any case, do not forget to remove the DbSchema call and do not forget to remove schema from source code.

If there's any pgsql_local customizations in testsuite tests, then remove db_dump_schema_path fixture usage and use the default version of the fixture from the service template. Note the postgresql/data directory in the service template, that is a good place to store tests data separately from the schema.

Migration of code from the easy library to a service template

You can keep using parts of the easy library in the service template for quite some time. Just move the code to new locations.

As a result you should get something close to the following:

After that, if you fell that easy::HttpWith gets in the way then you can remove it while still using easy::Dependencies. For example the following code with easy::HttpWith:

int main(int argc, char* argv[]) {
.DefaultContentType(http::content_type::kTextPlain)
.Post("/log", [](const server::http::HttpRequest& req, const Deps& deps) {
const auto& action = req.GetArg("action");
deps.pg().Execute(
storages::postgres::ClusterHostType::kMaster, "INSERT INTO events_table(action) VALUES($1)", action
);
return deps.CreateActionRequest(action)->body();
});
}

Becomes:

class MyHandler final : public server::handlers::HttpHandlerBase {
public:
MyHandler(const components::ComponentConfig& config, const components::ComponentContext& component_context)
: HttpHandlerBase(config, component_context),
deps_(component_context.FindComponent<DepsComponent>().GetDependencies()) {}
std::string HandleRequestThrow(const server::http::HttpRequest& request, server::request::RequestContext&)
const override {
const auto& action = request.GetArg("action");
deps_.pg().Execute(
storages::postgres::ClusterHostType::kMaster, "INSERT INTO events_table(action) VALUES($1)", action
);
return deps_.CreateActionRequest(action)->body();
}
private:
const Deps deps_;
};
int main(int argc, char* argv[]) {
.Append<components::HttpClient>()
.Append<ActionClient>()
.Append<DepsComponent>()
.Append<components::Postgres>("postgres")
.Append<MyHandler>("/log-POST");
return utils::DaemonMain(argc, argv, component_list);
}