Skip to content

DHI.Services.JsonDocuments.WebApi — Internal Developer Guide

This package exposes a small REST API for working with JsonDocument<string> (from DHI.Services.JsonDocuments) and pushes change notifications over SignalR. It’s meant to be hosted in your service and wired to your repository/service registration, or the providers that you choose.


What this package gives you

  • A controller (JsonDocumentsController) with CRUD-style endpoints, query & time-window reads, and discovery helpers.
  • A DTO (JsonDocumentDTO) for wire-format serialization (including metadata & permissions).
  • A couple of mapping extensions (ToDTO, ToDTOs).
  • SignalR integration: emits group-scoped notifications on add/update/delete.

You bring:

  • A JsonDocumentService<string> bound to your storage provider, registered by connection id.
  • JWT auth config, logging, filter repository, and SignalR hosting (see Setup).

Setup (Startup.cs)

Minimum wiring (replace comments with your own types/paths):

public void ConfigureServices(IServiceCollection services)
{
    // AuthN (JWT)
    services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
        .AddJwtBearer(options =>
        {
            options.TokenValidationParameters = new TokenValidationParameters
            {
                ValidateIssuer = true,
                ValidateAudience = true,
                ValidateLifetime = true,
                ValidateIssuerSigningKey = true,
                ValidIssuer = Configuration["Tokens:Issuer"],
                ValidAudience = Configuration["Tokens:Audience"],
                IssuerSigningKey = RSA.BuildSigningKey(Configuration["Tokens:PublicRSAKey"].Resolve())
            };
        });

    // AuthZ (optional policies)
    services.AddAuthorization(options =>
    {
        options.AddPolicy("AdministratorsOnly",
            policy => policy.RequireClaim(ClaimTypes.GroupSid, "Administrators"));
        options.AddPolicy("EditorsOnly",
            policy => policy.RequireClaim(ClaimTypes.GroupSid, "Editors"));
    });

    // API versioning + MVC
    services.AddApiVersioning(o =>
    {
        o.ReportApiVersions = true;
        o.AssumeDefaultVersionWhenUnspecified = true;
        o.DefaultApiVersion = new ApiVersion(1, 0);
        o.ApiVersionReader = ApiVersionReader.Combine(
            new QueryStringApiVersionReader("api-version", "version", "ver"),
            new HeaderApiVersionReader("api-version"));
    });

    services
        .AddCors()
        .AddResponseCompression()
        .AddControllers()
        .AddJsonOptions(o =>
        {
            o.JsonSerializerOptions.PropertyNamingPolicy = null; // keep names
            o.JsonSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
            o.JsonSerializerOptions.NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals;
            o.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
        });

    // HSTS + Swagger (optional but recommended)
    services.AddHsts(o =>
    {
        o.Preload = true;
        o.MaxAge = TimeSpan.FromDays(Configuration.GetValue<double>("AppConfiguration:HstsMaxAgeInDays"));
    });
    services.AddSwaggerGen(s =>
    {
        s.SwaggerDoc(Configuration["Swagger:SpecificationName"], new OpenApiInfo {
            Title = Configuration["Swagger:DocumentTitle"],
            Version = "1",
            Description = File.ReadAllText(Configuration["Swagger:DocumentDescription"].Resolve())
        });
        s.EnableAnnotations();
        s.IncludeXmlComments(Path.Combine(AppContext.BaseDirectory, "DHI.Services.JsonDocuments.WebApi.xml"));
        s.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme {
            Description = "Enter the word 'Bearer' followed by a space and the JWT.",
            Name = "Authorization", In = ParameterLocation.Header,
            Type = SecuritySchemeType.ApiKey, Scheme = "Bearer"
        });
        s.AddSecurityRequirement(new OpenApiSecurityRequirement {
            { new OpenApiSecurityScheme {
                Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "Bearer" },
                Scheme = "oauth2", Name = "Bearer", In = ParameterLocation.Header
              }, new List<string>() }
        });
    });

    // REQUIRED Domain services for this API:
    services.AddScoped<ILogger>(provider => new SimpleLogger("[AppData]log.json".Resolve()));
    services.AddSingleton<IFilterRepository>(provider => new FilterRepository("[AppData]signalr-filters.json".Resolve()));

    // SignalR is REQUIRED (notifications)
    services.AddSignalR();
}

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    if (env.IsDevelopment()) app.UseDeveloperExceptionPage(); else app.UseHsts();

    app.UseAuthentication();
    app.UseHttpsRedirection();
    app.UseSwagger();
    app.UseSwaggerUI(u =>
    {
        var spec = Configuration["Swagger:SpecificationName"];
        u.SwaggerEndpoint($"../swagger/{spec}/swagger.json", Configuration["Swagger:DocumentName"]);
        u.DocExpansion(Swashbuckle.AspNetCore.SwaggerUI.DocExpansion.None);
    });

    app.UseExceptionHandling();
    app.UseResponseCompression();
    app.UseRouting();
    app.UseAuthorization();

    app.UseEndpoints(endpoints =>
    {
        endpoints.MapControllers();
        endpoints.MapHub<NotificationHub>("/notificationhub");
    });

    // App_Data root for Domain Services
    var root = Configuration.GetValue("AppConfiguration:ContentRootPath", env.ContentRootPath);
    AppDomain.CurrentDomain.SetData("DataDirectory", Path.Combine(root, "App_Data"));

    // Register your JsonDocumentService<string> by connection id
    var service = new JsonDocumentService(
        new JsonDocumentRepository() /* // Your provider */
    );
    ServiceLocator.Register(service, "main"); // <-- connectionId used in routes
}

Important:

  • Inject both ILogger and IFilterRepository as shown.
  • Enable SignalR and map the /notificationhub.
  • Register your JsonDocumentService<string> in the ServiceLocator under a connection id (e.g., "main"). You’ll call the API with /api/jsondocuments/main/....

Routing, versioning, and auth

  • Base route: api/jsondocuments/{connectionId}
  • Version: v1 (query header or query-string, defaults to 1.0)
  • Auth: [Authorize] on the controller — configure JWT as above.
  • connectionId: selects which service instance to use (Services.Get<JsonDocumentService<string>>(connectionId)).

DTO model

JsonDocumentDTO is the wire contract.

{
  "FullName": "configs/simulations/Simulation 42", // required
  "Data": "{\"params\":{\"dt\":60}}",              // required (stringified JSON)
  "DateTime": "2025-01-23T12:34:56Z",
  "Added": "2025-01-23T12:35:00Z",
  "Updated": "2025-01-24T07:00:00Z",
  "Deleted": null,
  "Permissions": [ /* PermissionDTO[] (optional) */ ],
  "Metadata": { "owner": "ops" }                  // dictionary, keys serialized as strings
}

Mapping:

  • DTO -> Domain: ToJsonDocument() parses FullName (group + name), initializes JsonDocument<string>, copies Metadata and Permissions, and passes through timestamps.
  • Domain -> DTO: constructor copies fields verbatim.

Endpoints

Base: api/jsondocuments/{connectionId}

1) GET / — List or time-window

Query:

  • from (optional, ISO 8601)
  • to (optional, ISO 8601)
  • dataSelectors (optional) — e.g. dataSelectors=[$.params,$.notes]

Behavior:

  • If from and to omitted -> all docs.
  • Else -> docs with DateTime[from, to].

200 ⇒ JsonDocumentDTO[]

2) POST /query — Query body

Body: QueryDTO<JsonDocument<string>> Query: optional dataSelectors.

Returns 200 with JsonDocumentDTO[].

The repository/service controls which query conditions are supported.

3) GET /{fullName} — Get one

Path:

  • fullName is URL-encoded; use FullNameString.FromUrl(fullName) on the server side.

Query:

  • optional dataSelectors

200 ⇒ JsonDocumentDTO 404 ⇒ not found

4) GET /group/{group} — List by group

Path:

  • group is URL-encoded; parsed via FullNameString.FromUrl(group).

Query:

  • optional dataSelectors

200 ⇒ JsonDocumentDTO[]

5) GET /count — Count all

200 ⇒ integer

6) GET /fullnames — Discovery

Query:

  • optional group (URL-encoded)

200 ⇒ string[] of FullName

7) POST / — Add

Body: JsonDocumentDTO (must include FullName and Data).

201 ⇒ JsonDocumentDTO (CreatedAt link targets GET /{fullName}) 404 ⇒ (rare) if immediate re-read fails

Side effects: Emits SignalR "JsonDocumentAdded" to matching groups (see Notifications).

8) PUT / — Update

Body: JsonDocumentDTO.

200 ⇒ JsonDocumentDTO 404 ⇒ not found

Side effects: Emits "JsonDocumentUpdated" to groups.

9) DELETE /{fullName} — Delete

Query:

  • softDelete (bool, default false)

204 ⇒ hard-deleted 200 ⇒ soft-deleted (returns the soft-deleted entity) 404 ⇒ not found

Side effects: Emits "JsonDocumentDeleted" to groups.


Data selectors

All read endpoints accept dataSelectors to project the JSON. The controller accepts a simple bracketed, comma-separated list:

dataSelectors=[$.params,$.notes]

Parsing is minimal (split by , after trimming [ and ]), then passed to the underlying service which uses JSONPath-like tokens with Argon.JObject.SelectToken.

Tips

  • Prefer explicit absolute paths: $.params.dt, $.notes.
  • URL-encode the brackets in some clients: dataSelectors=%5B%24.params,%24.notes%5D
  • Avoid wildcards/filters (*, ?())—the current implementation targets single tokens.

Notifications over SignalR

When a document is added, updated, or deleted, the controller discovers relevant groups and sends a message via /notificationhub:

  • Hub method (to group): "JsonDocumentAdded" | "JsonDocumentUpdated" | "JsonDocumentDeleted"
  • Payload (Parameters):
    • Add/Update: { id, data, userName }
    • Delete (hard/soft): { id, userName }

Who receives messages?

Group resolution uses a filter repository (IFilterRepository) via FilterService:

  • If a filter has no QueryConditions -> its group always receives messages.
  • Else the controller builds new Query<JsonDocument<string>>(filter.QueryConditions) { new QueryCondition("Id", id) } and uses the service to evaluate it. If any results match, that group receives the message.

Implement/seed your IFilterRepository (e.g., FilterRepository("[AppData]signalr-filters.json")) with group filters appropriate for your application.


Request examples

Assume you registered your service under connection id main.

Get one

curl -H "Authorization: Bearer $JWT" \
  'https://host/api/jsondocuments/main/configs%2Fsimulations%2FSimulation%2042?dataSelectors=%5B%24.params,%24.notes%5D'

Add

curl -X POST -H "Authorization: Bearer $JWT" -H "Content-Type: application/json" \
  -d '{
        "FullName": "configs/simulations/Simulation 43",
        "Data": "{\"params\":{\"dt\":120},\"notes\":\"candidate\"}",
        "DateTime": "2025-01-25T10:00:00Z",
        "Metadata": {"owner":"ops"}
      }' \
  https://host/api/jsondocuments/main

Update

curl -X PUT -H "Authorization: Bearer $JWT" -H "Content-Type: application/json" \
  -d '{
        "FullName": "configs/simulations/Simulation 43",
        "Data": "{\"params\":{\"dt\":180},\"notes\":\"retuned\"}"
      }' \
  https://host/api/jsondocuments/main

Delete (soft)

curl -X DELETE -H "Authorization: Bearer $JWT" \
  'https://host/api/jsondocuments/main/configs%2Fsimulations%2FSimulation%2043?softDelete=true'

Status codes & errors

  • 200 OK: successful GET/PUT (PUT returns the updated entity), soft delete returns entity.
  • 201 Created: successful POST; Location points to GET /{fullName}.
  • 204 No Content: hard delete success.
  • 404 Not Found: entity/group missing (depending on endpoint).
  • 401/403: auth/authz issues.

Exceptions during SignalR notifications are logged but do not change the API response.


Extensibility & notes

  • Multiple backends: register multiple JsonDocumentService<string> instances with different connection ids (e.g., "main", "staging") and call the API with the appropriate /{connectionId}.
  • DTO evolution: The API defaults to PascalCase JSON; naming policy is disabled (PropertyNamingPolicy = null). Keep this in mind when introducing clients that assume camelCase.
  • Metadata keys: JsonDocumentDTO.Metadata uses Dictionary<object, object> in the DTO; when converting to domain it casts keys to string.
  • Permissions: DTO maps 1:1 to domain Permission via PermissionDTO. Enforce/inspect in your repository or service if required.
  • Group & FullName: FullName is a combined identifier (group/name). Route parameters for {fullName} and {group} must be URL-encoded; server-side parsing uses FullNameString.FromUrl(...).