RPC.NET.Server 6.0.0-preview2

This is a prerelease version of RPC.NET.Server.
dotnet add package RPC.NET.Server --version 6.0.0-preview2
NuGet\Install-Package RPC.NET.Server -Version 6.0.0-preview2
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="RPC.NET.Server" Version="6.0.0-preview2" />
For projects that support PackageReference, copy this XML node into the project file to reference the package.
paket add RPC.NET.Server --version 6.0.0-preview2
#r "nuget: RPC.NET.Server, 6.0.0-preview2"
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
// Install RPC.NET.Server as a Cake Addin
#addin nuget:?package=RPC.NET.Server&version=6.0.0-preview2&prerelease

// Install RPC.NET.Server as a Cake Tool
#tool nuget:?package=RPC.NET.Server&version=6.0.0-preview2&prerelease

RPC.NET Server

Simple, lightweight RPC server implementation for .NET

This documentation refers the version 6.X of the library

Name Package
RPC.NET.Interfaces Nuget (with prereleases)
RPC.NET.Server Nuget (with prereleases)

How it works

  1. The client sends a HTTP GET to the server in order to grab the API schema (optional)

    • The request URI
      • Must use HTTP or HTTPS scheme
      • Identifies the remote module in the query component

    For example: http://www.example.org:1986/api?module=ICalculator.

    • The response contains the module schema in JSON format:
    {
      "ICalculator": {
        "Methods": {
          "Add":{
            "Layout": "TODO"
          },
          "AddAsync": {
            "Layout": "TODO"
          },
          "ParseInt": {
            "Layout": "TODO"
          }
        }
        "Properties": {
          "PI": {
            "Layout": "TODO",
            "HasGetter": true,
            "HasSetter": false
          }
        }
      }
    }
    

    Note that the layout field is currently not supported (since neither the .NET nor the JS client doesn't require it)

  2. The client sends a HTTP POST to the server where

    • The request URI

      • Must use HTTP or HTTPS scheme
      • Identifies the remote module and method (in the query component)
      • May contain the sessionid and/or custom data (in the query component)

      For example: http://www.example.org:1986/api?module=IMyModule&method=ModuleMethod&sessionid=xXx.

    • The content-type is application/json

    • The request body is an (UTF-8) JSON stringified array that contains the method arguments. For example: ["cica", 10].

  3. The type of response depends on the kind of the result:

    • If the remote method has a non Stream return value then the content-type is application/json and the response body contains the (UTF-8) JSON stringified result. The result is a wrapped object that contains the actual outcome of the method or the error description:
      {
        "Result": 12,
        "Exception": null
      }
      
      or
      {
        "Result": null,
        "Exception": {
          "TypeName": "System.Exception, System.Private.CoreLib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=7cec85d7bea7798e",
          "Message": "Exception of type 'System.Exception' was thrown.",
          "Data": {}
        }
      }
      
    • If the remote method has a Stream return value (and the invocation was successful) then the content-type is application/octet-stream and the response body contains the raw data.

Architecture

  1. layer: The app-host
    • It's responsible for configuring, installing and running the underlying WebService. These logics are placed in separate methods defined by AppHostBase class.
    using Solti.Utils.DI.Interfaces;
    
    using Solti.Utils.Rpc;
    using Solti.Utils.Rpc.Hosting;
    using Solti.Utils.Rpc.Interfaces;
    using Solti.Utils.Rpc.Pipeline;
    using Solti.Utils.Rpc.Servers;
    
    public class AppHost : AppHostBase
    {
      public AppHost(string[] args) : base(args) { }
    
      public override void OnConfigureWin32Service(Win32ServiceDescriptor descriptor)
      {
        base.OnConfigureWin32Service(descriptor);
        descriptor.Name = "My Service";
      }
    
      public override void OnConfigure(WebServiceBuilder webServiceBuilder) => webServiceBuilder
        // it's required to set a HTTP server implementation
        .ConfigureBackend(_ => new HttpListenerBackend("http://localhost:1986/api/") { ReserveUrl = true })
        // shortcut to use the default RPC pipeline
        .ConfigureRpcService(conf =>
        {
          // confifure the pipeline
          switch (conf) 
          {
            case Modules modules: 
              modules.Register<ICalculator, Calculator>(); // remotely visible modules
              break;
            case RpcAccessControl ac:
              ac.AllowedOrigins.Add("http://localhost:1987");
              break;
           }
         })
         // services defined here are WebService exclusives
         .ConfigureServices(services => ...);
    
       // services defined here are visible for the WebService and also for the installation logic (OnInstall, OnUninstall, etc)
       public override OnConfigureServices(services) => services => services
           .Service<IMyService, MyService>(Lifetime.Scoped)
           .Factory<ICache>(i => ..., Lifetime.Singleton));
    
       public override void OnInstall(IInjector scope) { /*Initialize DB schema, etc...*/ }
     }
    
     static class Program
     {
         static int Main(string[] args) => new AppHost(args).Run();
     }
    
    • It defines an extensible command line interface to let you control your app. The command structure is verb -arg1 value1 -arg2 value2 (e.g.: install -root "cica@mica.hu -pwVariable PW_VARIABLE"). Defaults verbs are [service ]install, [service ]uninstall (if you don't wish to install your app as a Win32 Service you can ommit the "service " prefix'). Note that you can define your own verbs as you can see here.
  2. layer: The WebService, which has 3 responsibilities:
    • controls the underlying IHttpServer implementation

    • maintains the worker threads

    • when a new request is available, it creates a new scope and processes the request by invoking the request pipeline. The default pipeline (defined by ConfigureRpcService()) is:

      webServiceBuilder
        .ConfigurePipeline(pipeline => pipeline
          .Use<Modules>(conf => conf
            .Register<ICalculator, Calculator>()) // exposed (remotely visible) module
          .Use<RequestTimeout>(conf => {...})
          .Use<SchemaProvider>(conf => {...})
          .Use<HttpAccessControl>(conf => {...})
          .Use<RequestLimiter>(conf => {...})
          .Use<ExceptionCatcher>(conf => {...}));
      

      Where:

      • ExceptionCatcher configures and registers the ExceptionCacherHandler. As its name suggests, this handler catches the unhandled exceptions and generates the appropriate HTTP response (by default HTTP 500 is returned).
      • RequestLimiter configures and registers the RequestLimiterHandler. It is aimed to reject the request if the reuqest count exceeds a threshold. Every remote endpoint has its own counter that resets after a specified interval. By default a remote client can made 1000 requests in every 10 seconds.
      • HttpAccessControl is used to register the HttpAccessControlHandler, set up to support RPC. You may tweak this handler if you want to set up CORS.
      • SchemaProvider installs the SchemaProviderHandler which is responsible for publishing the module schema. By default, schema is generated for the API's annotated with the PublishSchemaAttribute.
      • RequestTimeout installs the RequestTimeoutHandler in order to make the request cancellable if the processing lasts too long. The default timeout is 10 seconds.
      • Modules installs the heart of the pipeline, the ModuleInvocationHandler. Its responsible for invoking the RPC module. The module and the method should be specified in the query part of the request while the serialized method parameters are in the request body. For more information see how RPC works

      As you can see every pipeline item (request handler) has its own configuration.

  3. layer: The exposed modules:
    • They are defined in Modules pipeline item
    • They may be decorated by aspects
    • Each session has its own module instance

Modules

  • They are also services
  • They are accessible remotely (see how it works section)
  • They may have dependencies (defined in the ConfigureServices() method)
  • They are registered in a Scoped lifetime

Note that the ConfigureRpcService() (see above) method implicitly registers the IServiceDescriptor module which describes the RPC service itself and exposes only the name and version of your app.

A basic module looks like this:

using Solti.Utils.Rpc.Interfaces;

public interface ICalculator // it may be worth to put this interface to a separate assembly
{
  [Alias("AddInt")] // method will be exposed with this name, useful in case of overloaded methods
  int Add(int a, int b); // regular method
  double PI { get; } // property
  Task<int> AddAsync(int a, int b); // async methods also supported
  [Ignore] // this cannot be called remotely
  void Ignored();
}
...
public class Calculator : ICalculator 
{
  private readonly IRequestContext FContext;
  // You can access the request context as a regular dependency
  public Calculator(IRequestContext context, /*other dependencies go here*/) => FContext = context ?? throw new ArgumentNullException(nameof(context));
  public int Add(int a, int b) => a + b;
  public Task<int> AddAsync(int a, int b)
  {
    FContext.Cancellation.ThrowIfCancellationRequested();
    return Task.FromResult(a + b);
  }
  public double PI => Math.PI;
}

Aspects

Aspects are intended to separate common functionality from the actual behavior (in case of modules this means the business logic). In this library aspects are implemented with interceptors and attributes:

using Solti.Utils.DI.Interfaces;

[AttributeUsage(AttributeTargets.Interface, AllowMultiple = false)]
public sealed class ParameterValidatorAspect : AspectAttribute
{
  public override Type GetInterceptor(Type iface)
  {
    // to ensure "separation of concerns" the attribute itself never references the actual interceptor
    Type interceptor = Type.GetType("Solti.Utils.Rpc.Aspects.ParameterValidator`1, Solti.Utils.Rpc, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null", throwOnError: true);
    return interceptor.MakeGenericType(iface);
  }
}
// Base class of all the validator attributes
public abstract class ParameterValidatorAttribute: Attribute
{
  public abstract void Validate(ParameterInfo param, object value);
}

[AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false)]
public class NotNullAttribute : ParameterValidatorAttribute
{
  public override void Validate(ParameterInfo param, object value)
  {
    if (value is null)
      throw new ArgumentNullException(param.Name);
  }
}
...
using Solti.Utils.Proxy;

public class ParameterValidatorProxy<TInterface> : InterfaceInterceptor<TInterface> where TInterface : class
{
  public ParameterValidatorProxy(TInterface target) : base(target) { }

  public override object Invoke(InvocationContext context)
  {
    foreach(var descr in context.Method.GetParameters().Select(
      (p, i) => new 
      { 
        Parameter = p, 
        Value = context.Args[i], 
        Validators = p.GetCustomAttributes<ParameterValidatorAttribute>() // ParamterValidator is the base class of all the validators
      }))
    {
      foreach (var validator in descr.Validators) 
      {
        validator.Validate(descr.Parameter, descr.Value);
      }
    }
    return base.Invoke(context);
  }
}

Interceptors are applied automatically during service/module registration so you just have to deal with the aspect attributes:

[ParameterValidatorAspect]
public interface IModule
{
  void DoSomething([NotNull] string arg1);
}
...
webServiceBuilder
  .ConfigureRpcService(conf => (conf as Modules)?.Register<IModule, Module>());

In this library, aspects reside in the interfaces while interceptors in the server project.

Parameter validation aspect

ParameterValidatorAspect aims that its name suggests:

using Solti.Utils.Rpc.Interfaces;

[ParameterValidatorAspect]
public interface IModule
{
  void DoSomething([NotNull, Match("cica$", ParameterValidationErrorMessage = "Parameter must be ended by 'cica'")] string arg1, [NotNull] object arg2);
  void DoSomethingElse([NotNull, ValidateProperties] ComplexData arg);
  void ConditionallyValidated([NotNull(ParameterValidationErrorMessage = "Customized error message")] string arg);
}

If a validation fails the server throws a ValidationException which uses the Data["TargetName"] to identify the target parameter or property. It allows the client to handle the error more precisely (e.g. it may highlight the input containing the wrong data)

Built-in validators are the follows:

  • NotNullAttribute: Ensures that a parameter or property is not null
  • NotEmptyAttribute: Ensures that a parameter or property (implementing the IEnumerable interface) is not empty
  • LengthBetweenAttribute: Ensures that the length of a collection is between the two provided values
  • MatchAttribute: Executes a regular expression against a parameter or property
  • MustAttribute: Executes a predicate (IPredicate implementation) against a parameter or property:
    void DoSomethingElse([Must(typeof(CustomValidationLogic))] ComplexData arg);
    
  • ValidatePropertiesAttribute: Instructs the system to execute validators placed on properties.
    public class ComplexData
    {
        [NotNull]
        public string Prop {get; set;}
        [NotNull, ValidateProperties]
        public InnerData InnerComplex {get; set;}
    }
    void DoSomethingElse([ValidateProperties] ComplexData arg);
    

Of course, you can define your own validator by implementing the IParameterValidator and IPropertyValidator interfaces:

[AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property, AllowMultiple = false)]
public sealed class MyValidatorAttribute : Attribute, IParameterValidator, IPropertyValidator
{
}

For more informtaion check out the sources or the documentation!

Authorization aspect

By default the system uses "role based" authorization. It means that every user (even the anonymous) must have its own set of assigned roles (for e.g.: AuthenticatedUser, Admin) and every module method provides a role list required the calling user to have:

using Solti.Utils.Rpc.Interfaces;

[Flags]
public enum MyRoles
{
  Anonymous,
  AuthenticatedUser,
  MayPrint,
  Admin
}

[RoleValidatorAspect]
public interface IModule
{
  // Admins or any authenticated user having print right are able to print 
  [RequiredRoles(MyRoles.AuthenticatedUser | MyRoles.MayPrint, MyRoles.Admin)]
  void Print();
  [RequiredRoles(MyRoles.AuthenticatedUser | MyRoles.MayPrint, MyRoles.Admin)]
  Task<string> PrintAsync();
  [RequiredRoles(MyRoles.Anonymous)]
  string Login(string user, string pw); // returns the new session id
  [RequiredRoles(MyRoles.AuthenticatedUser)]
  void Logout();
  // will throw on every invocation since there is no RequiredRoles attribute
  void MissingRequiredRoleAttribute();
}

public class Module: IModule {...}

// In order to use the RoleValidatorAspect we have to implement the IRoleManager interface
public class RoleManagerService: IRoleManager
{
  public Enum GetAssignedRoles(string? sessionId)
  {
    if (session is null)
      return MyRoles.Anonymous;

    // acquire the roles assigned to the user
    MyRoles assignedRoles = ...;
    return assignedRoles | MyRoles.AuthenticatedUser;
  }
  public Task<Enum> GetAssignedRolesAsync(string? sessionId, CancellationToken cancellation) => Task.FromResult(GetAssignedRoles(sessionId));
}
...
webServiceBuilder
  .ConfigureRpcService(conf => (conf as Modules)?.Register<IModule, Module>())
  .ConfigureServices(services => services.Service<IRoleManager, RoleManagerService>(Lifetime.Scoped));

If the authorization fails the system throws an AuthenticationException.

Transaction manager aspect

To ensure database consistency we can define transactions using aspects:

using System.Data;
using System.Threading.Tasks;
using Solti.Urils.Rpc.Interfaces;

[TransactionAspect]
public interface IModule
{
  void NonTransactional();
  [Transactional]
  void DoSomething(object arg);
  [Transactional(IsolationLevel = IsolationLevel.Serializable)]
  Task<int> DoSomethingAsync();
}

TransactionAspect requires the IDbConnection service to be installed:

using System.Data;

webServiceBuilder
  .ConfigureRpcService(conf => (conf as Modules)?.Register<IModule, Module>())
  .ConfigureServices(services => services.Factory<IDbConnection>(injector => /*create a new db connection*/, Lifetime.Scoped));

Logger aspect

using Solti.Utils.Rpc.Interfaces;

[LoggerAspect(typeof(typeof(ModuleMethodScopeLogger), typeof(ExceptionLogger), typeof(ParameterLogger), typeof(StopWatchLogger)))] // set the default log behaviors to run
public interface IUserManager
{
  [Loggers(typeof(ModuleMethodScopeLogger), typeof(ExceptionLogger), typeof(StopWatchLogger))] // don't log parameteres (may contain sensitive data)
  Task<UserEx> Login(string emailOrUserName, string pw);

  Task Logout(); // will run the default behaviors
}

LoggerAspect uses the ILogger service as a backend so you have to register it:

using Microsoft.Extensions.Logging;

public class ConsoleLogger : Solti.Utils.Rpc.Internals.LoggerBase 
{
  protected override void LogCore(string message) => Console.Out.WriteLine(message);

  public static ILogger Create<TCategory>() => new ConsoleLogger(GetDefaultCategory<TCategory>());

  public ConsoleLogger(string category) : base(category) { }
}
...
webServiceBuilder
  // Modules/services may also request the backend to do some method specific logging.
  .ConfigureServices(services => services.Factory<ILogger>(i => ConsoleLogger.Create<AppHost>(), Lifetime.Scoped));

Built-in log behaviors are the follows:

  • ExceptionLogger: Logs the unhandled exceptions then rethrows it
  • ModuleMethodScopeLogger: Creates a new log scope containing the module and method name (intended to be used on module interfaes)
  • ParameterLogger: Logs the parameter names and values (note that this logger should be disabled when a method accepts sensitive data, e.g. passwords)
  • ServiceMethodScopeLogger: Creates a new log scope containing the service and method name (intended to be used on service interfaes)
  • StopWatchLogger: Logs the elapsed time while the method invocation takes place You can define your own log behavior by descending from the Solti.Utils.Rpc.Interfaces.LoggerBase (not be confused about the Solti.Utils.Rpc.Internals.LoggerBase class).

A sample log looks like this:

'Solti.Utils.Rpc.Server.Sample.exe' (AppHost): [Worker ID = 1, Url = http://localhost:1986/api/, Remote EndPoint = [::1]:50173] Information: Request available.
'Solti.Utils.Rpc.Server.Sample.exe' (AppHost): [Worker ID = 1, Url = http://localhost:1986/api/, Remote EndPoint = [::1]:50173] Information: Request processed successfully.
'Solti.Utils.Rpc.Server.Sample.exe' (AppHost): [Worker ID = 2, Url = http://localhost:1986/api/, Remote EndPoint = [::1]:50173] Information: Request available.
'Solti.Utils.Rpc.Server.Sample.exe' (AppHost): [Worker ID = 2, Url = http://localhost:1986/api/, Remote EndPoint = [::1]:50173] [Module = IUserManager, Method = Logout, SessionId = NULL] Information: Parameters: 
'Solti.Utils.Rpc.Server.Sample.exe' (AppHost): [Worker ID = 2, Url = http://localhost:1986/api/, Remote EndPoint = [::1]:50173] [Module = IUserManager, Method = Logout, SessionId = NULL] Information: Time elapsed: 0ms
'Solti.Utils.Rpc.Server.Sample.exe' (AppHost): [Worker ID = 2, Url = http://localhost:1986/api/, Remote EndPoint = [::1]:50173] Information: Request processed successfully.

How to listen on HTTPS (Windows only)

Requires this script to be loaded (.(".\cert.ps1"))

  1. If you don't have your own, create a self-signed certificate
    Create-SelfSignedCertificate -OutDir ".\Cert" -Password "cica"
    
  2. Register the certificate
    Bind-Certificate -P12Cert ".Cert\certificate.p12" -Password "cica" -IpPort "127.0.0.1:1986"
    

Additional resources

API docs

Version history

Server boilerplate (comprehensive)

Sample server (used in tests)

Benchmark results

Product Compatible and additional computed target framework versions.
.NET net5.0 was computed.  net5.0-windows was computed.  net6.0 was computed.  net6.0-android was computed.  net6.0-ios was computed.  net6.0-maccatalyst was computed.  net6.0-macos was computed.  net6.0-tvos was computed.  net6.0-windows was computed.  net7.0 was computed.  net7.0-android was computed.  net7.0-ios was computed.  net7.0-maccatalyst was computed.  net7.0-macos was computed.  net7.0-tvos was computed.  net7.0-windows was computed.  net8.0 was computed.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed. 
.NET Core netcoreapp2.0 was computed.  netcoreapp2.1 was computed.  netcoreapp2.2 was computed.  netcoreapp3.0 was computed.  netcoreapp3.1 was computed. 
.NET Standard netstandard2.0 is compatible.  netstandard2.1 is compatible. 
.NET Framework net461 was computed.  net462 was computed.  net463 was computed.  net47 was computed.  net471 was computed.  net472 was computed.  net48 was computed.  net481 was computed. 
MonoAndroid monoandroid was computed. 
MonoMac monomac was computed. 
MonoTouch monotouch was computed. 
Tizen tizen40 was computed.  tizen60 was computed. 
Xamarin.iOS xamarinios was computed. 
Xamarin.Mac xamarinmac was computed. 
Xamarin.TVOS xamarintvos was computed. 
Xamarin.WatchOS xamarinwatchos was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last updated
6.0.0-preview2 196 2/13/2022
6.0.0-preview1 193 1/30/2022
5.0.2 361 12/22/2021
5.0.1 445 12/18/2021
5.0.0 320 12/12/2021
5.0.0-preview3 216 12/4/2021
5.0.0-preview2 221 10/29/2021
5.0.0-preview1 238 10/29/2021
4.0.1 377 7/2/2021
4.0.0 351 7/1/2021
3.0.4 380 6/29/2021
3.0.3 351 5/25/2021
3.0.2 375 4/8/2021
3.0.1 385 4/2/2021
3.0.0 424 3/20/2021
3.0.0-preview3 220 3/9/2021
3.0.0-preview2 259 3/8/2021
3.0.0-preview1 246 2/22/2021
2.2.0 412 10/16/2020
2.1.1 421 10/12/2020
2.1.0 433 9/25/2020
2.0.0 419 9/8/2020
2.0.0-preview4 301 8/25/2020
2.0.0-preview3 327 8/17/2020
2.0.0-preview2 319 8/8/2020
2.0.0-preview1 365 8/3/2020
1.0.0 522 7/19/2020
1.0.0-preview3 392 7/18/2020
1.0.0-preview2 321 7/14/2020
1.0.0-preview1 385 7/12/2020