Skip to content

Writing your own transport

Daniel Frantík edited this page Sep 16, 2026 · 2 revisions

Writing your own transport

tik4net ships eleven transports, but the connection contract is open: a transport can be written in your own assembly, and everything above it — the ADO.NET-like commands and the O/R mapper — works on it without changes. This page is for someone doing that: a transport for a channel tik4net does not speak (a serial console, a jump host, a proxy of your own), or a wrapper around an existing one.

If what you want is a connection that answers from memory so you can test your code without a router, you do not need this page — use TikFakeConnection from tik4net.testing, see Unit testing without a router.

The shape

Derive from TikCommandConnectionBase (namespace tik4net.Connection). It implements the whole ITikConnection surface — command factory, timeouts, diagnostics events — and hands the real work to hooks you override:

You implement Called for
Open ×2, OpenAsync ×2, Close the connection's lifecycle
RunPrint(TikCommandDescriptor) → rows every read: ExecuteList, ExecuteSingleRow, ExecuteScalar on a non-add command, and so every Load* of the mapper
RunAdd(TikCommandDescriptor) → new .id ExecuteScalar on an add command, and Save of a new entity
RunNonQuery(TikCommandDescriptor) ExecuteNonQuery: set, remove, move, enable, unset, actions… — Save of an existing entity, Delete, Move
optional RunPrintAsync, RunAddAsync, RunNonQueryAsync the Execute*Async / *Async mapper calls
optional RunRawText (+ RunRawTextAsync) CreateRawCommand — a command in your transport's own language

The optional hooks throw by default. That is deliberate: a transport that cannot genuinely await its I/O should not pretend to, so the default is "not supported" rather than a Task.Run wrapper around the synchronous hook.

A complete transport

This one keeps its "router" in memory, which is enough to show every hook — and the mapper runs on it unchanged. Demo.Run at the end uses it the way any tik4net connection is used:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using tik4net;
using tik4net.Connection;
using tik4net.Objects;
using tik4net.Objects.Ip;

public sealed class InMemoryConnection : TikCommandConnectionBase
{
    // menu path ("/ip/address") → its rows; each row is RouterOS field name → value
    private readonly Dictionary<string, List<Dictionary<string, string>>> _menus =
        new Dictionary<string, List<Dictionary<string, string>>>();
    private int _nextId = 1;

    // Only what this transport really does. No AsyncCommands (the Run*Async hooks are not overridden),
    // no RawCommand (it has no command language of its own), no Listen/Streaming.
    public override TikConnectionCapability Capabilities => TikConnectionCapability.Crud;

    public override void Open(string host, string user, string password) => Open(host, 0, user, password);
    public override void Open(string host, int port, string user, string password) => SetOpened();

    public override Task OpenAsync(string host, string user, string password,
        CancellationToken cancellationToken = default)
        => OpenAsync(host, 0, user, password, cancellationToken);

    public override Task OpenAsync(string host, int port, string user, string password,
        CancellationToken cancellationToken = default)
    {
        SetOpened();
        return Task.CompletedTask;
    }

    public override void Close() => SetClosed();

    protected override IList<TikRecordSentence> RunPrint(TikCommandDescriptor descriptor)
    {
        EnsureOpened();
        // Filters arrive with ParameterFormat.Filter. This sample understands plain equality only; a real
        // transport must translate every query form it accepts, or refuse it — never ignore a filter.
        var filters = descriptor.Parameters.Where(p => p.ParameterFormat == TikCommandParameterFormat.Filter).ToList();

        return Rows(descriptor.CommandText)
            .Where(row => filters.All(f => row.TryGetValue(f.Name, out var v) && v == f.Value))
            .Select(row => new TikRecordSentence(new Dictionary<string, string>(row)))
            .ToList();
    }

    protected override string RunAdd(TikCommandDescriptor descriptor)
    {
        EnsureOpened();
        string id = "*" + (_nextId++).ToString("X");
        var row = new Dictionary<string, string> { [TikSpecialProperties.Id] = id };
        foreach (var p in descriptor.Parameters)
            row[p.Name] = p.Value ?? "";
        Rows(descriptor.CommandText).Add(row);
        return id;
    }

    protected override void RunNonQuery(TikCommandDescriptor descriptor)
    {
        EnsureOpened();
        string verb = descriptor.CommandText.Substring(descriptor.CommandText.LastIndexOf('/') + 1);
        string? id = descriptor.Parameters.FirstOrDefault(p => p.Name == TikSpecialProperties.Id)?.Value;
        var rows = Rows(descriptor.CommandText);
        var row = rows.FirstOrDefault(r => r[TikSpecialProperties.Id] == id)
            ?? throw new TikNoSuchItemException(CreateCommand(descriptor.CommandText));

        switch (verb)
        {
            case "set":
                foreach (var p in descriptor.Parameters.Where(p => p.Name != TikSpecialProperties.Id))
                    row[p.Name] = p.Value ?? "";
                break;
            case "remove":
                rows.Remove(row);
                break;
            default:
                throw new NotSupportedException($"'{verb}' is not implemented by this transport.");
        }
    }

    // "/ip/address/print" → the rows of "/ip/address"
    private List<Dictionary<string, string>> Rows(string commandText)
    {
        string menu = commandText.Substring(0, commandText.LastIndexOf('/'));
        if (!_menus.TryGetValue(menu, out var rows))
            _menus[menu] = rows = new List<Dictionary<string, string>>();
        return rows;
    }
}

public static class InMemoryConnectionSetupExtensions
{
    // Reads like a built-in transport: setup.CreateInMemoryConnection()
    public static InMemoryConnection CreateInMemoryConnection(this TikConnectionSetup setup)
    {
        var connection = new InMemoryConnection();
        setup.ApplyTo(connection);    // timeouts, encoding, … exactly as the built-in transports get them
        connection.Open(setup.Address.Host!, setup.User, setup.Password);
        return connection;
    }
}

public static class Demo
{
    // Save, LoadAll and Delete all go through the three hooks above.
    public static void Run()
    {
        var setup = new TikConnectionSetup("192.0.2.1", "admin", "");
        using var connection = setup.CreateInMemoryConnection();

        var address = new IpAddress { Address = "192.0.2.10/24", Interface = "ether1" };
        connection.Save(address);                  // RunAdd — address.Id is now set

        address.Comment = "lab";
        connection.Save(address);                  // RunNonQuery: /ip/address/set .id=*1 comment=lab
        var all = connection.LoadAll<IpAddress>(); // RunPrint
        connection.Delete(address);                // RunNonQuery: /ip/address/remove
    }
}

What the hooks are given

Everything arrives in one TikCommandDescriptor:

  • CommandText — the path and verb in API form, /ip/address/print. Multi-line command text written by a caller has already been split: you get the path, and its ?/= rows as parameters.
  • Parameters — Name, Value, ParameterFormat. On a read, every parameter the caller did not format explicitly has already been resolved (to Filter, or to the command's own default format), so a Filter parameter is a query and anything else is an argument. On a write, parameters are arguments to the verb (Default and NameValue mean the same thing there); set/remove/move carry the row's .id as a parameter named TikSpecialProperties.Id.
  • IsRaw / WrapAsValue — only for CreateRawCommand; see Capabilities below.

Two parameters are not about data. .proplist asks for a subset of fields — honour it or ignore it, returning more fields than asked for is harmless. .tag belongs to the binary API's multiplexing and can be ignored by any other transport.

Filters are the part to be careful with. A transport that drops a filter it cannot translate turns LoadSingle into "the first row of the whole table" — a wrong answer that looks right. Translate every query form you accept, and throw for the rest.

What the hooks return, and what they throw

  • RunPrint returns one TikRecordSentence per row, built from a dictionary keyed by RouterOS field names (address, .id, …) with the values as the router writes them. The mapper converts from there. The dictionary is held by reference, so hand over a fresh one per row.
  • RunAdd returns the new row's .id.
  • Errors. Use the library's exception types, so callers' error handling does not have to know which transport they are on (see Exception handling):
    • a row that does not exist → TikNoSuchItemException, as the sample does;
    • the router refusing a command → TikCommandTrapException, which takes the command (CreateCommand(descriptor.CommandText) builds one) and an ITikTrapSentence — a small class of your own carrying the router's message;
    • the connection torn down while a command runs → the base's ClosedWhileRunning(inner);
    • a command on a closed connection → call EnsureOpened() first, as the sample does.

Capabilities

Override Capabilities and declare only what is true — callers on a connection picked at run time ask Supports(...) before they call, and a flag that promises too much turns their check into a runtime failure further down. The base answers Crud alone. What each flag commits you to:

  • AsyncCommands — override the Run*Async hooks and genuinely await your I/O. Declaring it with the defaults in place makes every async call throw.
  • RawCommand — override RunRawText (and handle IsRaw in RunPrint), so CreateRawCommand can send a command in your transport's own language verbatim. Implement ITikRawSentenceConnection too if you want the low-level CallCommandSync.
  • SafeMode — implement ITikSafeModeConnection; the base keeps SafeModeHeld for you.

The full list and what every built-in transport declares is in Connection types and capabilities.

Thread safety is your decision

The base offers a lock — the protected _cmdLock semaphore — but does not take it for you: the hooks run without it held. Decide what your channel needs and document it. A channel that carries one conversation at a time (a terminal, a serial line) must take it around every command, or two threads will read each other's answers; one where each command is independent (like HTTP) does not need it. The built-in transports make each of these choices — see Threading and concurrency.

Creating and configuring it

Create it with new, pass it to TikConnectionSetup.ApplyTo, then open it — what CreateInMemoryConnection in the sample does. ApplyTo is public for exactly this: it applies the same timeouts and encoding the built-in transports get, and the transport-specific options (AllowInvalidCertificate, RouterMac, CancellationMode) reach your connection if it implements the interface that declares them (ITikTlsConnection, ITikMacLayerConnection, ITikCancellationModeConnection).

To make it read like a built-in transport, add a Create…Connection extension method on TikConnectionSetup in your own namespace — InMemoryConnectionSetupExtensions at the end of the sample above. That is how the tik4net.ssh package offers CreateSshConnection.

ConnectionFactory.RegisterConnectionFactory is not for a new transport. It registers a factory for a TikConnectionType, and that enum is closed: every value except Ssh is implemented by tik4net itself, and registering one of those throws ArgumentException. The method exists so that the separate tik4net.ssh package can fill the Ssh slot, which makes TikConnectionSetup.Create(TikConnectionType.Ssh) work. A transport of your own has no TikConnectionType value and is created directly, as above.

Diagnostics

OnWriteRow / OnReadRow / DebugEnabled work on your transport if you call the base's FireWriteRow and FireReadRow with what you send and receive. RowTracingEnabled says whether anyone is listening, so you can skip building an expensive trace line when nobody is, and DiagnosticPrefix names your channel in the debug output. See Communication debugging.

Start here

API levels

Entities

Transports

Safety & diagnostics

Project

Clone this wiki locally