Repository: ndsev/mapget (language: python)

Issue:
Basic datasource API
A basic mapget DataSource abstraction should be implemented, which allows implementing a feature data provider.

![image](https://github.com/Klebert-Engineering/mapget/assets/8723581/b8b3604b-8a21-4e5f-96a0-ec1bd6a41e99)

For now, only the `/tile`- and `/metadata-`endpoints shall be supported. On startup, a DataSource shall automatically select a port to bind to, if no port is explicitely provided. The port shall be written in a message to stdout, which will allow a management application to register a newly launched data source with a mapget server.


Discussion / hints:

Fix patch:
diff --git a/.clang-tidy b/.clang-tidy
index 0a1f23a5..7273056f 100644
--- a/.clang-tidy
+++ b/.clang-tidy
@@ -1,1 +1,1 @@
-Checks: '-misc-no-recursion'
\ No newline at end of file
+Checks: '-misc-no-recursion,-cert-err34-c'
\ No newline at end of file
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 1d2ca63d..854e6f77 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -97,6 +97,14 @@ if (NOT TARGET bitsery)
   FetchContent_MakeAvailable(bitsery)
 endif()
 
+if (NOT TARGET yaml-cpp)
+  FetchContent_Declare(yaml-cpp
+    GIT_REPOSITORY "https://github.com/jbeder/yaml-cpp.git"
+    GIT_TAG        "master"
+    GIT_SHALLOW    ON)
+  FetchContent_MakeAvailable(yaml-cpp)
+endif()
+
 if (WITH_COVERAGE)
   include(CodeCoverage)
 endif()
@@ -105,6 +113,7 @@ endif()
 # libs
 
 add_subdirectory(libs/model)
+add_subdirectory(libs/datasource)
 
 ##############
 # tests
diff --git a/libs/datasource/CMakeLists.txt b/libs/datasource/CMakeLists.txt
new file mode 100644
index 00000000..718c2c4c
--- /dev/null
+++ b/libs/datasource/CMakeLists.txt
@@ -0,0 +1,30 @@
+project(mapget-datasource)
+
+add_library(mapget-datasource STATIC
+  include/mapget/datasource/server.h
+  src/server.cpp)
+
+target_include_directories(mapget-datasource
+  PUBLIC
+    include
+  PRIVATE
+    include/mapget/datasource)
+
+target_link_libraries(mapget-datasource
+  PUBLIC
+    httplib::httplib
+    yaml-cpp
+    mapget-model)
+
+if (MSVC)
+  target_compile_definitions(mapget-datasource
+    PRIVATE
+      _CRT_SECURE_NO_WARNINGS)
+
+  set_target_properties(mapget-datasource PROPERTIES
+    WINDOWS_EXPORT_ALL_SYMBOLS ON)
+endif()
+
+if (WITH_COVERAGE)
+  append_coverage_compiler_flags_to_target(mapget-datasource)
+endif()
diff --git a/libs/datasource/include/mapget/datasource/server.h b/libs/datasource/include/mapget/datasource/server.h
new file mode 100644
index 00000000..4fae2fe0
--- /dev/null
+++ b/libs/datasource/include/mapget/datasource/server.h
@@ -0,0 +1,90 @@
+#pragma once
+
+#include "mapget/model/featurelayer.h"
+
+namespace mapget
+{
+
+/**
+ * Small server, which implements the data source protocol expected
+ * by a mapget server. The DataSource must be constructed with a
+ * DataSourceInfo metadata instance, which describes the map data that
+ * is provided by the source.
+ */
+class DataSourceServer
+{
+public:
+    /**
+     * Construct a DataSource with a DataSourceInfo metadata instance.
+     */
+    explicit DataSourceServer(DataSourceInfo const& info);
+
+    /**
+     * Destructor, also stops the server if it is running.
+     */
+    ~DataSourceServer();
+
+    /**
+     * Set the Callback which will be invoked when a `/tile`-request is received.
+     * The callback argument is a fresh TileFeatureLayer, which the callback
+     * must fill according to the set TileFeatureLayer's layer info and tile id.
+     * If an error occurs while filling the tile, the callback can use
+     * TileFeatureLayer::setError(...) to singal the error downstream.
+     */
+    DataSourceServer& onTileRequest(std::function<void(TileFeatureLayer&)> const&);
+
+    /**
+     * Launch the DataSource server in it's own thread.
+     * Use the stop-function to stop the thread.
+     * The server will also be stopped automatically, if the DataSource object is destroyed.
+     * An exception will be thrown if this instance is already running,
+     * or if the server fails to launch within waitMs.
+     *
+     * @param interface Network interface to bind to.
+     * @param port Port which the DataSource HTTP server shall bind to.
+     *  With port=0, a random free port will be chosen. On startup,
+     *  a message will be written to stdout:
+     *
+     *  "====== Running on port <port> ======"
+     *
+     *  A parent supervisor process may parse this message to determine
+     *  the chosen port, and enter the DataSource's network address into
+     *  the mapget data source configuration file.
+     * @param waitMs Number of milliseconds to wait for the server to start up.
+     */
+    void go(std::string const& interfaceAddr="0.0.0.0", uint16_t port=0, uint32_t waitMs=100);
+    
+    /**
+     * Returns true if this instance is currently running (go() was called
+     * and not stopped).
+     */
+    [[nodiscard]] bool isRunning();
+
+    /**
+     * Stop this instance. Will be a no-op if this instance is not running.
+     */
+    void stop();
+
+    /**
+     * Blocks until SIGINT or SIGTERM is received, then shuts down the server.
+     * Note: You can never run this function in parallel for multiple sources
+     *  within the same process!
+     */
+    void waitForSignal();
+
+    /**
+     * Get the port currently used by the instance, or 0 if go() has never been called.
+     */
+    [[nodiscard]] uint16_t port() const;
+
+    /**
+     * Get the DataSourceInfo metadata which this instance was constructed with.
+     */
+    DataSourceInfo const& info();
+
+private:
+    struct Impl;
+    std::unique_ptr<Impl> impl_;
+};
+
+}
diff --git a/libs/datasource/src/server.cpp b/libs/datasource/src/server.cpp
new file mode 100644
index 00000000..fd05f7d3
--- /dev/null
+++ b/libs/datasource/src/server.cpp
@@ -0,0 +1,173 @@
+#include "httplib.h"
+#include "mapget/model/featurelayer.h"
+#include "mapget/model/stream.h"
+
+#include "server.h"
+
+#include <utility>
+#include <csignal>
+#include <atomic>
+
+namespace mapget
+{
+
+static std::atomic<DataSourceServer*> activeDataSource;
+
+struct DataSourceServer::Impl
+{
+    httplib::Server server_;
+    DataSourceInfo info_;
+    std::function<void(TileFeatureLayer&)> tileCallback_;
+    uint16_t port_ = 0;
+    std::thread serverThread_;
+    std::shared_ptr<Fields> fields_;
+
+    explicit Impl(DataSourceInfo info) : info_(std::move(info)), fields_(std::make_shared<Fields>(info_.nodeId_))
+    {
+        // Set up GET /tile endpoint
+        server_.Get(
+            "/tile",
+            [this](const httplib::Request& req, httplib::Response& res)
+            {
+                auto layerIdParam = req.get_param_value("layer");
+                auto layerIt = info_.layers_.find(layerIdParam);
+                if (layerIt == info_.layers_.end())
+                    throw std::runtime_error(stx::format("Unknown layer id `{}`!", layerIdParam));
+
+                auto tileIdParam = TileId{std::stoull(req.get_param_value("tileId"))};
+                auto fieldsOffsetParam = (simfil::FieldId)0;
+                if (req.has_param("fieldsOffset"))
+                    fieldsOffsetParam = (simfil::FieldId)
+                        std::stoul(req.get_param_value("fieldsOffset"));
+
+                auto tileFeatureLayer = std::make_shared<TileFeatureLayer>(
+                    tileIdParam,
+                    info_.nodeId_,
+                    info_.mapId_,
+                    layerIt->second,
+                    fields_);
+                if (tileCallback_)
+                    tileCallback_(*tileFeatureLayer);
+
+                // Serialize TileFeatureLayer using TileLayerStream
+                std::stringstream content;
+                TileLayerStream::FieldOffsetMap fieldOffsets{{info_.nodeId_, fieldsOffsetParam}};
+                TileLayerStream::Writer layerWriter{
+                    [&](auto&& msg) { content << msg; },
+                    fieldOffsets};
+                layerWriter.write(tileFeatureLayer);
+
+                res.set_content(content.str(), "application/binary");
+            });
+
+        // Set up GET /info endpoint
+        server_.Get(
+            "/info",
+            [this](const httplib::Request&, httplib::Response& res)
+            {
+                nlohmann::json j = info_.toJson();
+                res.set_content(j.dump(), "application/json");
+            });
+
+    }
+
+    static void handleSignal(int signal)
+    {
+        // Temporarily holds the current active data source
+        auto* expected = activeDataSource.load();
+
+        // Stop the active instance when a signal is received.
+        // We use compare_exchange_strong to make the operation atomic.
+        if (activeDataSource.compare_exchange_strong(expected, nullptr)) {
+            if (expected) {
+                expected->stop();
+            }
+        }
+    }
+};
+
+DataSourceServer::DataSourceServer(DataSourceInfo const& info)
+    : impl_(new Impl(info))
+{
+}
+
+DataSourceServer&
+DataSourceServer::onTileRequest(std::function<void(TileFeatureLayer&)> const& callback)
+{
+    impl_->tileCallback_ = callback;
+    return *this;
+}
+
+void DataSourceServer::go(std::string const& interfaceAddr, uint16_t port, uint32_t waitMs)
+{
+    if (impl_->server_.is_running() || impl_->serverThread_.joinable())
+        throw std::runtime_error("DataSource is already running");
+
+    if (port == 0) {
+        impl_->port_ = impl_->server_.bind_to_any_port(interfaceAddr);
+    }
+    else {
+        impl_->port_ = port;
+        impl_->server_.bind_to_port(interfaceAddr, port);
+    }
+
+    impl_->serverThread_ = std::thread(
+        [this, interfaceAddr]
+        {
+            std::cout << "====== Running on port " << impl_->port_ << " ======" << std::endl;
+            impl_->server_.listen_after_bind();
+        });
+
+    std::this_thread::sleep_for(std::chrono::milliseconds(waitMs));
+    if (!impl_->server_.is_running() || !impl_->server_.is_valid())
+        throw std::runtime_error(stx::format("Could not start DataSource on {}:{}", interfaceAddr, port));
+}
+
+bool DataSourceServer::isRunning()
+{
+    return impl_->server_.is_running();
+}
+
+void DataSourceServer::stop()
+{
+    if (!impl_->server_.is_running())
+        return;
+
+    impl_->server_.stop();
+    impl_->serverThread_.join();
+}
+
+uint16_t DataSourceServer::port() const
+{
+    return impl_->port_;
+}
+
+DataSourceInfo const& DataSourceServer::info()
+{
+    return impl_->info_;
+}
+
+DataSourceServer::~DataSourceServer()
+{
+    if (isRunning())
+        stop();
+}
+
+void DataSourceServer::waitForSignal()
+{
+    // So the signal handler knows what to call
+    activeDataSource = this;
+
+    // Set the signal handler for SIGINT and SIGTERM.
+    std::signal(SIGINT, Impl::handleSignal);
+    std::signal(SIGTERM, Impl::handleSignal);
+
+    // Wait for the signal handler to stop us, or the server to shut down on its own.
+    while (isRunning()) {
+        std::this_thread::sleep_for(std::chrono::milliseconds (200));
+    }
+
+    activeDataSource = nullptr;
+}
+
+}  // namespace mapget
diff --git a/libs/model/include/mapget/model/info.h b/libs/model/include/mapget/model/info.h
index 3d62f1b1..ae72ad3a 100644
--- a/libs/model/include/mapget/model/info.h
+++ b/libs/model/include/mapget/model/info.h
@@ -36,13 +36,37 @@ struct Version {
         s.value2b(minor_);
         s.value2b(patch_);
     }
+
+    /** Create Version from JSON. */
+    static Version fromJson(const nlohmann::json& j);
+
+    /** Serialize Version to JSON. */
+    [[nodiscard]] nlohmann::json toJson() const;
 };
 
 /** Enum to represent the possible data types */
 enum class IdPartDataType {I32, U32, I64, U64, UUID128, STR};
+NLOHMANN_JSON_SERIALIZE_ENUM(
+    IdPartDataType,
+    {
+        {IdPartDataType::I32, "I32"},
+        {IdPartDataType::U32, "U32"},
+        {IdPartDataType::I64, "I64"},
+        {IdPartDataType::U64, "U64"},
+        {IdPartDataType::UUID128, "UUID128"},
+        {IdPartDataType::STR, "STR"},
+    })
 
 /** Enum to represent the possible layer types */
 enum class LayerType {Features, Heightmap, OrthoImage, GLTF};
+NLOHMANN_JSON_SERIALIZE_ENUM(
+    LayerType,
+    {
+        {LayerType::Features, "Features"},
+        {LayerType::Heightmap, "Heightmap"},
+        {LayerType::OrthoImage, "OrthoImage"},
+        {LayerType::GLTF, "GLTF"},
+    })
 
 /** Structure to represent a part of a unique feature id composition. */
 struct UniqueIdPart
@@ -61,6 +85,12 @@ struct UniqueIdPart
 
     /** Is the identifier actually optional? */
     bool isOptional_ = false;
+
+    /** Create UniqueIdPart from JSON. */
+    static UniqueIdPart fromJson(const nlohmann::json& j);
+
+    /** Serialize UniqueIdPart to JSON. */
+    [[nodiscard]] nlohmann::json toJson() const;
 };
 
 /** Structure to represent the feature type info */
@@ -74,6 +104,37 @@ struct FeatureTypeInfo
      * A single id composition must never have more than 16 parts.
      */
     std::vector<std::vector<UniqueIdPart>> uniqueIdCompositions_;
+
+    /**
+     * Deserializes a FeatureTypeInfo object from JSON.
+     *
+     * The JSON is expected to have the following structure:
+     *
+     * {
+     *   "name": <string>,                  // Mandatory: The name of the feature type.
+     *   "uniqueIdCompositions": [          // Mandatory: A list of unique ID compositions.
+     *     [                                // A list of UniqueIdPart objects, each having the following structure:
+     *       {
+     *         "partId": <string>,          // Mandatory: The ID of the unique ID part.
+     *         "description": <string>,     // Optional: The description of the unique ID part.
+     *         "datatype": <IdPartDataType>,// Optional: The data type of the unique ID part, must be one of the enum values from IdPartDataType. Defaults to I64.
+     *         "isSynthetic": <bool>,       // Optional: A flag indicating if the unique ID part is synthetic. Defaults to false.
+     *         "isOptional": <bool>         // Optional: A flag indicating if the unique ID part is optional. Defaults to false.
+     *       },
+     *       ...
+     *     ],
+     *     ...
+     *   ]
+     * }
+     *
+     * @param j The JSON to deserialize.
+     * @return The deserialized FeatureTypeInfo object.
+     * @throws std::runtime_error if any mandatory field is missing.
+     */
+    static FeatureTypeInfo fromJson(const nlohmann::json& j);
+
+    /** Serialize FeatureTypeInfo to JSON. */
+    [[nodiscard]] nlohmann::json toJson() const;
 };
 
 /**
@@ -82,10 +143,10 @@ struct FeatureTypeInfo
  */
 struct Coverage
 {
-    /** Minimum tile id */
+    /** Minimum tile id (north-west AABB corner). */
     TileId min_;
 
-    /** Maximum tile id. Must have same zoom level as min. */
+    /** Maximum tile id (south-east AABB corner). Must have same zoom level as min. */
     TileId max_;
 
     /**
@@ -96,6 +157,26 @@ struct Coverage
      * to be fully filled.
      */
     std::vector<bool> filled_;
+
+    /**
+     * Deserializes a Coverage object from JSON.
+     *
+     * The JSON is expected to have the following structure:
+     *
+     * {
+     *   "min": <uint64_t>,                // Mandatory: The minimum tile ID.
+     *   "max": <uint64_t>,                // Mandatory: The maximum tile ID.
+     *   "filled": [<bool>...]             // Optional: A list of boolean values indicating filled tiles. Defaults to an empty list.
+     * }
+     *
+     * @param j The JSON to deserialize.
+     * @return The deserialized Coverage object.
+     * @throws std::runtime_error if any mandatory field is missing.
+     */
+    static Coverage fromJson(const nlohmann::json& j);
+
+    /** Serialize Coverage to JSON. */
+    [[nodiscard]] nlohmann::json toJson() const;
 };
 
 /**
@@ -129,6 +210,12 @@ struct LayerInfo
 
     /** Version of the map layer. */
     Version version_;
+
+    /** Create LayerInfo from JSON. */
+    static std::shared_ptr<LayerInfo> fromJson(const nlohmann::json& j, std::string const& layerId="");
+
+    /** Serialize LayerInfo to JSON. */
+    [[nodiscard]] nlohmann::json toJson() const;
 };
 
 /**
@@ -153,6 +240,52 @@ struct DataSourceInfo
 
     /** Used mapget protocol version */
     Version protocolVersion_;
+
+    /**
+     * Deserializes a DataSourceInfo object from JSON.
+     *
+     * The JSON is expected to have the following structure:
+     *
+     * {
+     *   "mapId": <string>,                   // Mandatory: A unique identifier for the map.
+     *   "layers": {                          // Mandatory: A dictionary mapping layer names to LayerInfo objects.
+     *     <layerId>: {
+     *       "type": <LayerType>,             // Optional: The type of the layer. Defaults to "Features".
+     *       "featureTypes": [<FeatureTypeInfo>...], // Mandatory: A list of feature type information.
+     *       "zoomLevels": [<int>...],        // Optional: A list of zoom levels. Defaults to empty list.
+     *       "coverage": [<Coverage>...],     // Optional: A list of coverage objects. Defaults to empty list.
+     *       "canRead": <bool>,               // Optional: Whether the layer can be read. Defaults to true.
+     *       "canWrite": <bool>,              // Optional: Whether the layer can be written. Defaults to false.
+     *       "version": {                     // Optional: The version of the layer.
+     *         "major": <int>,                // Mandatory: The major version number.
+     *         "minor": <int>,                // Mandatory: The minor version number.
+     *         "patch": <int>                 // Mandatory: The patch version number.
+     *       }
+     *     },
+     *     ...
+     *   },
+     *   "maxParallelJobs": <int>,            // Optional: The maximum number of parallel jobs allowed. Defaults to 8.
+     *   "extraJsonAttachment": <JSON object>,// Optional: Any extra JSON data attached. Defaults to an empty object.
+     *   "protocolVersion": {                 // Optional: The version of the protocol. Defaults to the current protocol version.
+     *     "major": <int>,                    // Mandatory: The major version number.
+     *     "minor": <int>,                    // Mandatory: The minor version number.
+     *     "patch": <int>                     // Mandatory: The patch version number.
+     *   },
+     *   "nodeId": <string>,                  // Optional: A UUID for the node. If not provided, a random UUID will be generated.
+     *                                        // Note: Only provide this if you have a good reason.
+     * }
+     *
+     * Each LayerType, FeatureTypeInfo, and Coverage object has its own specific JSON structure,
+     * as defined by the corresponding class's fromJson() method.
+     *
+     * @param j The JSON to deserialize.
+     * @return The deserialized DataSourceInfo object.
+     * @throws std::runtime_error if any mandatory field is missing.
+     */
+    static DataSourceInfo fromJson(const nlohmann::json& j);
+
+    /** Serialize DataSourceInfo to JSON. */
+    [[nodiscard]] nlohmann::json toJson() const;
 };
 
 }  // End of namespace mapget
diff --git a/libs/model/src/info.cpp b/libs/model/src/info.cpp
index 99bb5edb..12b5fd2d 100644
--- a/libs/model/src/info.cpp
+++ b/libs/model/src/info.cpp
@@ -1,9 +1,33 @@
 #include "info.h"
+#include "stream.h"
+
 #include <tuple>
+#include <random>
+#include <sstream>
 
 namespace mapget
 {
 
+namespace {
+
+/** Generates a random 16-Byte UUID. Used to generate random DataSourceInfo node IDs. */
+std::string generateUuid() {
+    std::random_device rd;
+    std::mt19937 rng(rd());
+    std::stringstream ss;
+    ss << std::hex;
+    for (int i = 0; i < 4; ++i)
+        ss << std::setw(4) << std::setfill('0') << (rng() & 0xFFFF);
+    return ss.str();
+}
+
+auto missing_field(std::string const& error, std::string const& context) {
+    return std::runtime_error(
+        stx::format("{}::fromJson(): `{}`", context, error));
+}
+
+}
+
 bool Version::isCompatible(const Version& other) const
 {
     return other.major_ == major_ && other.minor_ == minor_;
@@ -28,4 +52,196 @@ bool Version::operator<(const Version& other) const
         std::tie(major_, minor_, patch_));
 }
 
+Version Version::fromJson(const nlohmann::json& j)
+{
+    try {
+        return {
+            j.at("major").get<uint16_t>(),
+            j.at("minor").get<uint16_t>(),
+            j.at("patch").get<uint16_t>()};
+    }
+    catch (nlohmann::json::out_of_range const& e) {
+        throw missing_field(e.what(), "Version");
+    }
+}
+
+nlohmann::json Version::toJson() const
+{
+    return nlohmann::json{{"major", major_}, {"minor", minor_}, {"patch", patch_}};
+}
+
+UniqueIdPart UniqueIdPart::fromJson(const nlohmann::json& j)
+{
+    try {
+        return {
+            j.at("partId").get<std::string>(),
+            j.value("description", ""),
+            j.value("datatype", IdPartDataType::I64),
+            j.value("isSynthetic", false),
+            j.value("isOptional", false)};
+    }
+    catch (nlohmann::json::out_of_range const& e) {
+        throw missing_field(e.what(), "UniqueIdPart");
+    }
+}
+
+nlohmann::json UniqueIdPart::toJson() const
+{
+    return nlohmann::json{
+        {"partId", partId_},
+        {"description", description_},
+        {"datatype", datatype_},
+        {"isSynthetic", isSynthetic_},
+        {"isOptional", isOptional_}};
+}
+
+FeatureTypeInfo FeatureTypeInfo::fromJson(const nlohmann::json& j)
+{
+    try {
+        std::vector<std::vector<UniqueIdPart>> idCompositions;
+        for (auto& item : j.at("uniqueIdCompositions")) {
+            std::vector<UniqueIdPart> idParts;
+            for (auto& idPart : item) {
+                idParts.push_back(UniqueIdPart::fromJson(idPart));
+            }
+            idCompositions.push_back(idParts);
+        }
+
+        return {j.at("name").get<std::string>(), idCompositions};
+    }
+    catch (nlohmann::json::out_of_range const& e) {
+        throw missing_field(e.what(), "FeatureTypeInfo");
+    }
+}
+
+nlohmann::json FeatureTypeInfo::toJson() const
+{
+    std::vector<nlohmann::json> idCompositions;
+    for (auto& item : uniqueIdCompositions_) {
+        nlohmann::json idParts;
+        for (auto& part : item) {
+            idParts.push_back(part.toJson());
+        }
+        idCompositions.push_back(idParts);
+    }
+
+    return nlohmann::json{{"name", name_}, {"uniqueIdCompositions", idCompositions}};
+}
+
+Coverage Coverage::fromJson(const nlohmann::json& j)
+{
+    try {
+        return {
+            TileId(j.at("min").get<uint64_t>()),
+            TileId(j.at("max").get<uint64_t>()),
+            j.value("filled", std::vector<bool>())};
+    }
+    catch (nlohmann::json::out_of_range const& e) {
+        throw missing_field(e.what(), "Coverage");
+    }
+}
+
+nlohmann::json Coverage::toJson() const
+{
+    return nlohmann::json{{"min", min_.value_}, {"max", max_.value_}, {"filled", filled_}};
+}
+
+std::shared_ptr<LayerInfo> LayerInfo::fromJson(const nlohmann::json& j, std::string const& layerId)
+{
+    try {
+        std::vector<FeatureTypeInfo> featureTypes;
+        for (auto& item : j.at("featureTypes")) {
+            featureTypes.push_back(FeatureTypeInfo::fromJson(item));
+        }
+
+        std::vector<Coverage> coverages;
+        if (j.contains("coverage"))
+            for (auto& item : j.at("coverage")) {
+                coverages.push_back(Coverage::fromJson(item));
+            }
+
+        return std::make_shared<LayerInfo>(LayerInfo{
+            j.value("layerId", layerId),
+            j.value("type", LayerType::Features),
+            featureTypes,
+            j.value("zoomLevels", std::vector<int>()),
+            coverages,
+            j.value("canRead", true),
+            j.value("canWrite", false),
+            Version::fromJson(j.value("version", Version().toJson()))});
+    }
+    catch (nlohmann::json::out_of_range const& e) {
+        throw missing_field(e.what(), "LayerInfo");
+    }
+}
+
+nlohmann::json LayerInfo::toJson() const
+{
+    std::vector<nlohmann::json> featureTypes;
+    featureTypes.reserve(featureTypes_.size());
+    for (const auto& item : featureTypes_) {
+        featureTypes.push_back(item.toJson());
+    }
+
+    std::vector<nlohmann::json> coverages;
+    coverages.reserve(coverage_.size());
+    for (const auto& item : coverage_) {
+        coverages.push_back(item.toJson());
+    }
+
+    return nlohmann::json{
+        {"layerId", layerId_},
+        {"type", type_},
+        {"featureTypes", featureTypes},
+        {"zoomLevels", zoomLevels_},
+        {"coverage", coverages},
+        {"canRead", canRead_},
+        {"canWrite", canWrite_},
+        {"version", version_.toJson()}};
+}
+
+DataSourceInfo DataSourceInfo::fromJson(const nlohmann::json& j)
+{
+    try {
+        std::map<std::string, std::shared_ptr<LayerInfo>> layers;
+        for (auto& item : j.at("layers").items()) {
+            layers[item.key()] = LayerInfo::fromJson(item.value(), item.key());
+        }
+
+        std::string nodeId;
+        if (j.contains("nodeId"))
+            nodeId = j.at("nodeId").get<std::string>();
+        else
+            nodeId = generateUuid();
+
+        return {
+            nodeId,
+            j.at("mapId").get<std::string>(),
+            layers,
+            j.value("maxParallelJobs", 8),
+            j.value("extraJsonAttachment", nlohmann::json::object()),
+            Version::fromJson(
+                j.value("protocolVersion", TileLayerStream::CurrentProtocolVersion.toJson()))};
+    }
+    catch (nlohmann::json::out_of_range const& e) {
+        throw missing_field(e.what(), "DataSourceInfo");
+    }
+}
+
+nlohmann::json DataSourceInfo::toJson() const
+{
+    nlohmann::json layersJson;
+    for (const auto& [layerName, layerPtr] : layers_) {
+        layersJson[layerName] = layerPtr->toJson();
+    }
+
+    return nlohmann::json{
+        {"nodeId", nodeId_},
+        {"mapId", mapId_},
+        {"layers", layersJson},
+        {"maxParallelJobs", maxParallelJobs_},
+        {"extraJsonAttachment", extraJsonAttachment_},
+        {"protocolVersion", protocolVersion_.toJson()}};
+}
+
 }


Test patch:
diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt
index 7f1ed802..68600a74 100644
--- a/test/CMakeLists.txt
+++ b/test/CMakeLists.txt
@@ -10,11 +10,14 @@ if (NOT TARGET Catch2)
 endif()
 
 add_executable(test.mapget
-  model.cpp)
+  model.cpp
+  info.cpp
+  datasource.cpp)
 
 target_link_libraries(test.mapget
   PUBLIC
     mapget-model
+    mapget-datasource
     Catch2::Catch2WithMain)
 
 include(Catch)
diff --git a/test/datasource.cpp b/test/datasource.cpp
new file mode 100644
index 00000000..12a73829
--- /dev/null
+++ b/test/datasource.cpp
@@ -0,0 +1,110 @@
+#include <catch2/catch_test_macros.hpp>
+
+#include "httplib.h"
+#include "mapget/datasource/server.h"
+#include "mapget/model/stream.h"
+
+using namespace mapget;
+
+TEST_CASE("DataSource", "[DataSource]")
+{
+    // Create DataSourceInfo
+    auto info = DataSourceInfo::fromJson(R"(
+    {
+        "mapId": "GarlicChickenMap",
+        "layers": {
+            "WayLayer": {
+                "featureTypes":
+                [
+                    {
+                        "name": "Way",
+                        "uniqueIdCompositions":
+                        [
+                            [
+                                {
+                                    "partId": "areaId",
+                                    "description": "String which identifies the map area.",
+                                    "datatype": "STR"
+                                },
+                                {
+                                    "partId": "wayId",
+                                    "description": "Globally Unique 32b integer.",
+                                    "datatype": "U32"
+                                }
+                            ]
+                        ]
+                    }
+                ]
+            }
+        }
+    }
+    )"_json);
+    // configure info
+
+    // Initialize a DataSource
+    DataSourceServer ds(info);
+
+    ds.onTileRequest([](TileFeatureLayer& tile) {
+        auto f = tile.newFeature("Way", {{"areaId", "Area42"}, {"wayId", 0}});
+        auto g = f->geom()->newGeometry(GeomType::Line);
+        g->append({42., 11});
+        g->append({42., 12});
+    });
+
+    // Launch the DataSource on a separate thread
+    ds.go();
+
+    // Ensure the DataSource is running
+    REQUIRE(ds.isRunning() == true);
+
+    SECTION("Fetch /info")
+    {
+        // Initialize an httplib client
+        httplib::Client cli("localhost", ds.port());
+
+        // Send a GET info request
+        auto fetchedInfoJson = cli.Get("/info");
+        auto fetchedInfo = DataSourceInfo::fromJson(nlohmann::json::parse(fetchedInfoJson->body));
+        REQUIRE(fetchedInfo.toJson() == info.toJson());
+    }
+
+    SECTION("Fetch /tile")
+    {
+        // Initialize an httplib client
+        httplib::Client cli("localhost", ds.port());
+
+        // Send a GET tile request
+        auto tileResponse = cli.Get("/tile?layer=WayLayer&tileId=1");
+
+        // Check that the response is OK
+        REQUIRE(tileResponse != nullptr);
+        REQUIRE(tileResponse->status == 200);
+
+        // Check the response body for expected content
+        auto receivedTileCount = 0;
+        TileLayerStream::Reader reader(
+            [&](auto&& mapId, auto&& layerId)
+            {
+                REQUIRE(mapId == info.mapId_);
+                return info.layers_[std::string(layerId)];
+            },
+            [&](auto&& tile) { receivedTileCount++; });
+        reader.read({tileResponse->body.begin(), tileResponse->body.end()});
+
+        REQUIRE(receivedTileCount == 1);
+    }
+
+    SECTION("Stop the data source")
+    {
+        ds.stop();
+        REQUIRE(ds.isRunning() == false);
+    }
+
+    SECTION("Wait for data source")
+    {
+        auto waitThread = std::thread([&]{ds.waitForSignal();});
+        ds.stop();
+        waitThread.join();
+        REQUIRE(ds.isRunning() == false);
+    }
+}
diff --git a/test/info.cpp b/test/info.cpp
new file mode 100644
index 00000000..f7a24676
--- /dev/null
+++ b/test/info.cpp
@@ -0,0 +1,54 @@
+#include <catch2/catch_test_macros.hpp>
+
+#include "mapget/model/info.h"
+#include "mapget/model/stream.h"
+
+using namespace mapget;
+
+TEST_CASE("InfoToJson", "[DataSourceInfo]")
+{
+    // Create a DataSourceInfo object
+    std::map<std::string, std::shared_ptr<LayerInfo>> layers;
+    layers["testLayer"] = std::make_shared<LayerInfo>(LayerInfo{
+        "testLayer",
+        LayerType::Features,
+        std::vector<FeatureTypeInfo>(),
+        std::vector<int>{0, 1, 2},
+        std::vector<Coverage>(),
+        true,
+        false,
+        Version{1, 0, 0}});
+
+    DataSourceInfo info(DataSourceInfo{
+        "testNodeId",
+        "testMapId",
+        layers,
+        5,
+        nlohmann::json::object(),
+        TileLayerStream::CurrentProtocolVersion});
+
+    // Serialize it to JSON
+    nlohmann::json j = info.toJson();
+
+    // Deserialize it back into a DataSourceInfo object, then serialize it again.
+    auto j2 = DataSourceInfo::fromJson(j).toJson();
+
+    // Check that the two DataSourceInfo objects are equal
+    REQUIRE(j == j2);
+}
+
+TEST_CASE("InfoFromJson", "[DataSourceInfo]")
+{
+    // create a JSON object with some mandatory fields missing
+    nlohmann::json j = R"({
+        "nodeId": "testNodeId",
+        "protocolVersion": {
+            "major": 1,
+            "minor": 0,
+            "patch": 0
+        }
+    })"_json;
+
+    // Attempting to deserialize should throw an exception because "mapId" is missing.
+    REQUIRE_THROWS_AS(DataSourceInfo::fromJson(j), std::runtime_error);
+}
diff --git a/test/model.cpp b/test/model.cpp
index 0a5607f8..0f444dcd 100644
--- a/test/model.cpp
+++ b/test/model.cpp
@@ -12,15 +12,29 @@ TEST_CASE("FeatureLayer", "[test.featurelayer]")
 {
     // Create layer info which has a single feature type with
     // a single allowed feature id composition.
-    auto layerInfo = std::make_shared<LayerInfo>();
-    layerInfo->featureTypes_.emplace_back(FeatureTypeInfo{
-        "Way",
-        {{
-            UniqueIdPart{"areaId", "String which identifies the map area.", IdPartDataType::STR, false, false},
-            UniqueIdPart{"wayId", "Globally Unique 32b integer.",
-              IdPartDataType::U32, false, false}
-        }}});
-    layerInfo->layerId_ = "WayLayer";
+    auto layerInfo = LayerInfo::fromJson(R"({
+        "layerId": "WayLayer",
+        "type": "Features",
+        "featureTypes": [
+            {
+                "name": "Way",
+                "uniqueIdCompositions": [
+                    [
+                        {
+                            "partId": "areaId",
+                            "description": "String which identifies the map area.",
+                            "datatype": "STR"
+                        },
+                        {
+                            "partId": "wayId",
+                            "description": "Globally Unique 32b integer.",
+                            "datatype": "U32"
+                        }
+                    ]
+                ]
+            }
+        ]
+    })"_json);
 
     // Create empty shared autofilled field-name dictionary
     auto fieldNames = std::make_shared<Fields>("TastyTomatoSaladNode");
@@ -68,7 +82,7 @@ TEST_CASE("FeatureLayer", "[test.featurelayer]")
         REQUIRE(feature1->evaluate("**.mozzarella.smell").toString() == "neutral");
         REQUIRE(feature1->evaluate("properties.main_ingredient").toString() == "Pepper");
         REQUIRE(
-            feature1->evaluate("geo(geometry.geometries.*) within bbox(40., 9., 45., 12.)")
+            feature1->evaluate("geo() within bbox(40., 9., 45., 12.)")
                 .toString() == "true");
     }