CRUD Operations

June 10, 2026 · View on GitHub

DataSurface generates fully-featured REST endpoints via ASP.NET Core Minimal APIs. Each resource annotated with [CrudResource] gets a complete set of CRUD endpoints automatically.


Generated Endpoints

MethodEndpointOperationDescription
GET/api/{resource}ListPaginated list with filtering, sorting, search
HEAD/api/{resource}ListCount only — returns X-Total-Count header
GET/api/{resource}/{id}GetSingle resource by ID
POST/api/{resource}CreateCreate a new resource
PATCH/api/{resource}/{id}UpdatePartial update — only provided fields are changed
PUT/api/{resource}/{id}UpdateFull replacement — all updatable fields required
DELETE/api/{resource}/{id}DeleteDelete a resource

Additional endpoints (when enabled):

MethodEndpointDescription
POST/api/{resource}/bulkBatch create/update/delete
GET/api/{resource}/streamNDJSON streaming
GET/api/{resource}/exportExport data (JSON/CSV)
POST/api/{resource}/importImport data
GET/api/$schema/{resource}JSON Schema for resource
GET/api/$resourcesList all available resources

POST responses return 201 Created with a relative, URL-encoded Location header pointing at the created resource. Create and update responses pass through field-level authorization, so fields the caller cannot read are redacted (see Security).


Controlling Available Operations

Disable specific operations per resource:

[CrudResource("audit-logs",
    EnableCreate = false,
    EnableUpdate = false,
    EnableDelete = false)]  // Read-only resource
public class AuditLog { /* ... */ }
PropertyDefaultEffect when false
EnableListtrueGET /api/{resource} returns 405
EnableGettrueGET /api/{resource}/{id} returns 405
EnableCreatetruePOST /api/{resource} returns 405
EnableUpdatetruePATCH /api/{resource}/{id} returns 405
EnableDeletetrueDELETE /api/{resource}/{id} returns 405

Field-Level Control

Control which fields appear in which DTO shapes using CrudDto flags:

[CrudResource("products")]
public class Product
{
    [CrudKey]
    public int Id { get; set; }

    // Read + Create + Update — full lifecycle field
    [CrudField(CrudDto.Read | CrudDto.Create | CrudDto.Update, RequiredOnCreate = true)]
    public string Name { get; set; } = default!;

    // Read + Create only — set once, never update
    [CrudField(CrudDto.Read | CrudDto.Create)]
    public string SKU { get; set; } = default!;

    // Read-only — server-managed
    [CrudField(CrudDto.Read)]
    public DateTime CreatedAt { get; set; }

    // Not exposed — no [CrudField] attribute
    internal string InternalNotes { get; set; } = default!;
}
FlagEffect
CrudDto.ReadIncluded in GET responses
CrudDto.CreateAccepted in POST body
CrudDto.UpdateAccepted in PATCH body
CrudDto.FilterCan be used in filter[field]=value
CrudDto.SortCan be used in sort=field

Properties without [CrudField] are not exposed via the API — this is a safe default.


PATCH vs PUT

PATCH (partial update) — Only fields present in the request body are updated:

PATCH /api/products/1
Content-Type: application/json

{"name": "Updated Name"}

Only name is changed; all other fields remain as-is.

PUT (full replacement) — All updatable fields must be provided. Missing fields return 400. Field names in the body are matched case-insensitively, like the rest of the pipeline:

PUT /api/products/1
Content-Type: application/json

{"name": "Updated Name", "sku": "NEW-SKU", "price": 29.99}

PUT must be explicitly enabled:

app.MapDataSurfaceCrud(new DataSurfaceHttpOptions
{
    EnablePutForFullUpdate = true
});

Default Values

Automatically apply defaults when creating resources. Defaults are applied server-side when a field is not provided in the request body:

[CrudResource("orders")]
public class Order
{
    [CrudKey]
    public int Id { get; set; }

    [CrudField(CrudDto.Read | CrudDto.Create, DefaultValue = "pending")]
    public string Status { get; set; } = default!;

    [CrudField(CrudDto.Read | CrudDto.Create, DefaultValue = 0)]
    public int Priority { get; set; }
}
  • Defaults are only applied on create (POST)
  • If the field is provided in the request, the provided value is used
  • Works with strings, numbers, and booleans

Computed Fields

Server-calculated read-only fields evaluated at read time:

[CrudResource("employees")]
public class Employee
{
    [CrudKey]
    public int Id { get; set; }

    [CrudField(CrudDto.Read | CrudDto.Create | CrudDto.Update)]
    public string FirstName { get; set; } = default!;

    [CrudField(CrudDto.Read | CrudDto.Create | CrudDto.Update)]
    public string LastName { get; set; } = default!;

    [CrudField(CrudDto.Read, ComputedExpression = "FirstName + ' ' + LastName")]
    public string FullName { get; set; } = default!;

    [CrudField(CrudDto.Read | CrudDto.Create)]
    public decimal Salary { get; set; }

    [CrudField(CrudDto.Read | CrudDto.Create)]
    public decimal Bonus { get; set; }

    [CrudField(CrudDto.Read, ComputedExpression = "Salary + Bonus")]
    public decimal TotalCompensation { get; set; }
}
  • Computed fields are read-only — cannot be set via POST or PATCH
  • Values are calculated fresh on every read
  • Expressions reference CLR property names (not API names)
  • Supports string concatenation, numeric operations, and property references

HEAD Requests

HEAD requests return count information without a response body:

HEAD /api/users?filter[status]=active
HTTP/1.1 200 OK
X-Total-Count: 42
X-Page: 1
X-Page-Size: 200

Useful for dashboards and "item count" UI elements without transferring data.


Soft Delete

Entities implementing ISoftDelete are marked as deleted instead of being permanently removed:

using DataSurface.EFCore.Interfaces;

public class User : ISoftDelete
{
    public int Id { get; set; }
    public string Email { get; set; } = default!;
    public bool IsDeleted { get; set; }  // Set to true on DELETE
}
  • On delete: IsDeleted = true instead of row removal
  • On queries: Soft-deleted records are automatically filtered out
  • On updates: Soft-deleted records cannot be updated — PATCH/PUT on a soft-deleted row returns 404, just like reads
  • Disable: EnableSoftDeleteFilter = false in DataSurfaceEfCoreOptions

Timestamps

Entities implementing ITimestamped get automatic timestamp population:

using DataSurface.EFCore.Interfaces;

public class User : ITimestamped
{
    public int Id { get; set; }
    public string Email { get; set; } = default!;
    public DateTime CreatedAt { get; set; }  // Auto-set on insert
    public DateTime UpdatedAt { get; set; }  // Auto-set on insert and update
}
  • On insert: Both CreatedAt and UpdatedAt set to DateTime.UtcNow
  • On update: Only UpdatedAt is refreshed
  • Disable: EnableTimestampConvention = false in DataSurfaceEfCoreOptions

In-Process Usage (No HTTP)

All CRUD operations are available without HTTP via IDataSurfaceCrudService:

var crudService = serviceProvider.GetRequiredService<IDataSurfaceCrudService>();

// List
var result = await crudService.ListAsync("User", querySpec, expand: null, ct);

// Get
var user = await crudService.GetAsync("User", entityId, expand: null, ct);

// Create
var created = await crudService.CreateAsync("User", jsonBody, ct);

// Update
var updated = await crudService.UpdateAsync("User", entityId, jsonBody, ct);

// Delete
await crudService.DeleteAsync("User", entityId, deleteSpec: null, ct);

Same validation, security, hooks, and contracts apply — no HTTP involved.