A small, header-only C++20 library for building message/data processing pipelines out of composable filter stages — both at compile time (fully type-safe, zero-overhead composition) and at runtime (JSON-configured, type-erased, with validation at construction time).
It grew out of a need to turn a fixed sequence of transformation steps into a flexible, reconfigurable filter graph: chain stages, fan out to multiple parallel branches, and drop/short-circuit messages — all without hard-coding the pipeline shape in source code.
New here? Start with the example walkthrough (EXAMPLE.md) — a step-by-step, diagrammed tour of the runnable
apps/textPipelinesample.
A note on the word "filter". Here "filter" follows the Unix-pipeline and media-graph (DirectShow / GStreamer / FFmpeg) tradition: a stage that reads a value and writes a transformed one, not merely a predicate that keeps or drops. A
MessageFiltertherefore transforms (its input and output types may differ), and may optionally drop a message viastd::nullopt. If you expect "filter" in the strict select-only sense, read it as "stage" or "processing step".
A pipeline is a chain of MessageFilter stages: each consumes a value and
returns an std::optional, where std::nullopt short-circuits (drops) the
message. A FanoutFilter can duplicate a message to parallel branches while
passing the original through unchanged.
flowchart LR
In(["input"]) --> A["Stage A"]
A --> F{{"FanoutFilter"}}
F -. "copy" .-> B["side branch<br/>(tap)"]
F ==>|"original"| C["Stage C"]
C -->|"optional"| Out(["output"])
C -. "nullopt" .-> Drop[["dropped"]]
See EXAMPLE.md for a full, diagrammed walkthrough of the runnable
apps/textPipeline sample.
MessageFilter<InputType, OutputType>— the base stage interface. A filter consumesInputType&&and returnsstd::optional<OutputType>; returningstd::nulloptshort-circuits (drops) the message, terminating the chain early. The short-circuit is handled by the framework (FilterGraph/AnyFilterChain): once a stage yieldsstd::nullopt, no later stage runs and the whole chain returnsstd::nullopt. Downstream stages therefore never receive an empty optional — a stage only decides whether to emitstd::nulloptitself and never has to handle one as input. Inside aFanoutFilterbranch this termination is local to that branch and does not affect the main path (the branch result is discarded regardless).Void— an explicit terminal marker type. A path normally ends in a stage that produces a realOutputType(the graph's result). Declaring a stageMessageFilter<InputType, Void>instead marks the path as a pure side-effect sink: it produces no consumable output, distinct from returningstd::nullopt, which means a message was dropped or could not be processed. AVoidstage must be the last stage in a path;AnyFilterChainrejects any stage placed after it at construction time.FilterGraph<Filters...>— compile-time, variadic-template composition of stages. Fully type-checked at compile time; each stage'sOutTypemust match the next stage'sInType. Zero runtime configuration overhead.AnyMessageFilter/AnyMessageFilterAdapter<Filter>— type-erased view of aMessageFilter, used to store/chain stages of different (otherwise incompatible) types at runtime.FilterRegistry/FilterRegistrar<FilterImpl>— a global registry mapping string names to filter factories, so a runtime configuration (e.g. JSON) can select and construct filters by name. Supports both parameterless filters and filters configured from a JSON object.AnyFilterChain— builds and validates a sequence of stages ("a path") from a JSON array, resolving each stage viaFilterRegistry. Fails fast at construction time if two consecutive stages' types don't match.validateGraph(json)/Diagnostic— pre-flight validation for a config. It walks the JSON without running any messages and returns all problems at once (not just the first), each located by a JSON pointer: unknown/typo'd stage types (with a nearest-name suggestion), structural mistakes, adjacent leaf type mismatches, and bad/missing per-stageconfig. Complements the fail-fast construction-time check with author-friendly, located diagnostics.JsonFilterGraph<InputType, OutputType>— a typed wrapper aroundAnyFilterChain, exposing it as a regularMessageFilter<InputType, OutputType>so a JSON-configured pipeline can be used anywhere a compile-time one can.FanoutFilter<InputType>— duplicates an incoming message across multiple independent, multi-stage branches (side-effecting "taps": logging, forwarding, metrics, ...), then passes the original message through unchanged to the rest of the chain. Each branch is itself anAnyFilterChain, so branches can have several stages, not just one. No branch contributes to the pipeline's output: every branch receives its own copy, runs to completion, and has its result discarded (a branch returningstd::nulloptis a no-op for the main path). The value forwarded downstream is always the unchanged original input, regardless of the number or order of branches.SinkFilter<InputType>— a generic terminal stage that forwards data to a caller-suppliedstd::functioncallback and returns a simple status code, useful for terminating a compile-timeFilterGraph.
- A C++20 compiler (MSVC 19.3x, GCC 11+, or Clang 14+). The library uses
std::format,<ranges>, and other C++20 features. - CMake ≥ 3.22 (the presets require CMake ≥ 3.21).
- A build generator such as Ninja (used by the bundled presets).
- nlohmann/json (v3.11.3) — fetched automatically via CPM; no manual install needed.
- Building the tests additionally fetches Catch2 (v3.5.2) via CPM.
The library itself is header-only: consumers only need a C++20 compiler and nlohmann/json.
CPMAddPackage(
NAME filterGraph
GITHUB_REPOSITORY "psyinf/filterGraph"
GIT_TAG v0.2.0 # or main
)
target_link_libraries(myTarget PRIVATE filterGraph::filterGraph)filterGraph depends on nlohmann/json,
which is pulled in transitively via CPM.
#include <filterGraph/core/filterGraph/FilterGraph.hpp>
#include <filterGraph/core/filterGraph/MessageFilter.hpp>
using filterGraph::FilterGraph;
using filterGraph::MessageFilter;
class Double : public MessageFilter<int>
{
public:
std::optional<int> filter(int&& value) override { return value * 2; }
};
class ToString : public MessageFilter<int, std::string>
{
public:
std::optional<std::string> filter(int&& value) override { return std::to_string(value); }
};
FilterGraph<Double, ToString> pipeline(std::make_shared<Double>(), std::make_shared<ToString>());
auto result = pipeline.filter(21); // -> "42"#include <filterGraph/core/filterGraph/FanoutFilter.hpp>
#include <filterGraph/core/filterGraph/FilterRegistry.hpp>
#include <filterGraph/core/filterGraph/JsonFilterGraph.hpp>
#include <iostream>
using namespace filterGraph;
// A side-effecting "tap": logs every value it sees, then passes it on
// unchanged. This is the kind of stage a fanout branch is meant for.
class Log : public MessageFilter<int>
{
public:
std::optional<int> filter(int&& value) override
{
std::cout << "[log] " << value << '\n';
return value;
}
};
// Register each stage under a name. FilterRegistrar comes from filterGraph
// (FilterRegistry.hpp); the registration happens in its constructor, so these
// are just static objects — the variable names (registerDouble, ...) are
// arbitrary and never referenced again. The string is the name used in JSON.
static FilterRegistrar<Double> registerDouble("Double");
static FilterRegistrar<ToString> registerToString("ToString");
static FilterRegistrar<Log> registerLog("Log");
static const bool sRegisterFanout = [] { registerFanoutFilter<int>("Fanout"); return true; }();
// Tap the incoming value into a logging branch, then transform it on the main
// path: double it and turn it into a string.
auto config = nlohmann::json::parse(R"([
{ "type": "Fanout", "config": { "branches": [
[ { "type": "Log" } ]
] } },
{ "type": "Double" },
{ "type": "ToString" }
])");
JsonFilterGraph<int, std::string> pipeline(config);
auto result = pipeline.filter(21); // branch logs "[log] 21"; main path -> "42"The fanout branch observes the original input (21) as a side effect, while
the main path keeps flowing and produces the transformed result ("42").
For a complete, diagrammed walkthrough of these concepts, see
EXAMPLE.md. It builds on the runnable
apps/textPipeline sample (a small text pipeline:
uppercase/reverse/print, with a JSON-configured variant including a fanout
branch and a length-based filter).
A pipeline (or a FanoutFilter branch) is described as an array of stages:
[
{ "type": "StageName" },
{ "type": "OtherStage", "config": { "someParam": 123 } }
]typeis the name a filter was registered under viaFilterRegistrar.configis optional and is passed verbatim to the filter's registered creator function; its shape is entirely up to that filter.
A FanoutFilter's config has one special key, branches: an array where
each entry is itself a full stage array (a path), not a single stage:
{
"type": "Fanout",
"config": {
"branches": [
[ { "type": "StageA" } ],
[ { "type": "StageB" }, { "type": "StageC" } ]
]
}
}Branches are side-effect-only: each receives a copy of the message and its result is discarded, so no branch contributes to the pipeline's output (the fanout always forwards the unchanged original downstream).
Construction throws on the first error with no location, which is awkward while
authoring. validateGraph checks a config up front — without running any
messages — and returns every problem it finds, each with a JSON pointer:
#include <filterGraph/core/filterGraph/GraphValidator.hpp>
auto diagnostics = filterGraph::validateGraph(config);
for (const auto& d : diagnostics)
{
std::cerr << d.pointer << ": " << d.message << '\n';
}
if (diagnostics.empty()) { /* safe to build the JsonFilterGraph */ }Example output for a config with a typo and a nested mistake:
/0: unknown filter type 'Uppercas' — did you mean 'Uppercase'? (known types: ...)
/1/config/paths/0/0: unknown filter type 'Revrse' — did you mean 'Reverse'? (known types: ...)
It detects unknown/typo'd type names (with a nearest-name suggestion and the
list of known types), structural errors (non-object stage, missing type, a
composite's sub-paths not being an array), adjacent leaf type mismatches, and
bad/missing per-stage config (leaf stages are constructed to check). It does
not check a graph's declared input/output types (those are C++ template
parameters, not JSON), and type-chaining pauses across a composite stage
(Fanout/Join), whose through-type is not knowable from JSON alone.
The repository ships a set of CMake presets (see
CMakePresets.json) covering MSVC, Clang and GCC in Debug
and Release. Pick the one matching your toolchain, e.g.:
# Windows / MSVC (Release)
cmake --preset windows-msvc-release-user-mode
cmake --build --preset windows-msvc-release-user-mode
ctest --preset test-windows-msvc-release-developer-mode# Linux or macOS / GCC (Release)
cmake --preset unixlike-gcc-release
cmake --build --preset unixlike-gcc-release
ctest --preset test-unixlike-gcc-releaseAvailable configure presets include windows-msvc-{debug,release}-{developer,user}-mode,
windows-clang-{debug,release}, unixlike-gcc-{debug,release} and
unixlike-clang-{debug,release}. Test presets are prefixed with test- (only
the developer-mode / non-user-mode configurations register tests).
Testing is enabled by default (-DENABLE_TESTING=ON); pass
-DENABLE_TESTING=OFF to skip building the tests. Tests use Catch2 (fetched via
CPM) and live under tests/.
Run the bundled example directly after building:
./out/build/windows-msvc-release-user-mode/apps/textPipeline/textPipelinePlanned improvements, not yet implemented:
- Track which stage short-circuited. When a chain returns
std::nullopt,AnyFilterChaincurrently gives no indication of which stage dropped the message. It should record the index/name of the stage that returnedstd::nullopt(and expose it to the caller, e.g. via an accessor or a richer result type) so callers can observe and diagnose where a message was filtered out. (TheVoidtype already distinguishes an intentional dead-end from a dropped message; this item covers observing unintentional drops.)
MIT, see LICENSE.