There is a gap that shows up consistently in application frameworks between what HTTP actually specifies and what the framework exposes to developers. HTTP's uniform interface — GET, PUT, PATCH, POST, DELETE — is precise and well-reasoned. The semantics are defined. The contract is clear. But most frameworks layer their own routing and handler abstractions on top, and the correspondence between what HTTP says and what your code does becomes indirect. You end up mapping between two systems rather than working in one.
Harper's Resource API closes that gap. In v5, it does so more directly than any previous version.
The Uniform Interface, Directly Expressed
The Resource API is a JavaScript class interface where static methods map one-to-one to HTTP verbs. A Resource class represents a collection; a Resource instance represents a single record. To define how your application responds to HTTP requests, you extend the base Resource class and override the methods that correspond to the HTTP verbs you care about.
class Product extends Resource {
static async get(target) {
// called for HTTP GET /Product/:id
return super.get(target);
}
static async put(target, data) {
// called for HTTP PUT /Product/:id
return super.put(target, { ...(await data), status: 'active' });
}
static async delete(target) {
// called for HTTP DELETE /Product/:id
return super.delete(target);
}
}This is not a coincidence of naming. The static methods are the HTTP handlers. Harper routes incoming requests directly to these methods. There is no separate routing layer, no middleware registration, no controller-to-method mapping to maintain. The class structure encodes the URL hierarchy; the method names encode the HTTP verbs.
v5 formalizes this further by explicitly encouraging use of static methods as the primary interaction pattern, and by pre-parsing the RequestTarget before it reaches any static method. That second point matters more than it might seem.
The RequestTarget as Parsed URL
In v5, when a request arrives, Harper parses the URL into a RequestTarget object before calling your static method. RequestTarget is a subclass of URLSearchParams, so it carries the full query string as a native iterable interface, plus additional properties that Harper derives from the URL structure:
target.id— the primary key extracted from the pathtarget.pathname— the path portion relative to the resourcetarget.isCollection—truewhen the request targets a collection (e.g./Product/with query conditions) rather than a single recordtarget.conditions,target.limit,target.offset,target.sort,target.select— query parameters parsed from Harper's extended URL query syntax
The consequence is that your get method does not need to parse anything. The URL has already been translated into a structured object that reflects the semantics of the request.
static get(target) {
const id = target.id;
const filter = target.get('status'); // standard URLSearchParams access
const limit = target.limit; // parsed by Harper from URL syntax
return super.get(target);
}Compare this to the alternative: receiving a raw URL string, manually extracting path segments, manually parsing query parameters, and then constructing some internal query object from what you found. The RequestTarget design eliminates that translation layer. What HTTP says about the request is what your method receives.
Data Modeling Through the Resource Interface
The Resource API is also the primary modeling interface for data. Tables in Harper extend Resource, which means the same methods that handle HTTP requests are the methods you use to interact with data from application code. There is one interface, not two.
Static methods handle reads and writes at the collection level:
// retrieve a single record
const product = await Product.get(34);
// partial update
await Product.patch(34, { description: 'Updated description' });
// full replacement
await Product.put({ id: 34, name: 'New Product Name', price: 49.99 });
// create with auto-generated key
const created = await Product.create({ name: 'New Product', price: 29.99 });
// delete
await Product.delete(34);Instance methods, retrieved via update(), allow mutable access to a single record within a transaction:
const product = await Product.update(32);
product.status = 'active';
product.subtractFrom('quantity', 1); // CRDT-safe decrement
product.save(); // explicit save within transactionThe save() method is new in v5. Previously, pending changes were committed only when the transaction completed. save() makes those changes visible to subsequent reads and queries within the same transaction — useful when you need to write a record and then query against the updated state before the transaction closes.
The get() method in v5 returns a RecordObject: a frozen plain object containing the record's properties plus getUpdatedTime() and getExpiresAt(). It is immutable. This is intentional. The returned object represents the committed state of the record in the database; mutation belongs to the update() flow. If you need a modified version of the record for a response, the spread operator is the explicit path:
static async get(target) {
const record = await super.get(target);
return { ...record, computedField: derive(record) };
}The consistency here — a frozen read result, a distinct mutable update path — reflects the underlying transaction model. Reads are snapshots. Writes are transactions. The API makes that distinction explicit.
Caching and Sourcing: Where the Model Gets Interesting
The Resource API's treatment of caching is where the data modeling story becomes most useful. Harper supports the concept of a source resource: a resource that a caching table delegates to when a record is not found locally. This is configured with sourcedFrom():
ProductCache.sourcedFrom(ExternalProductAPI, {
expiration: 300, // TTL in seconds
eviction: 3600 // eviction time in seconds
});Once configured, reads against ProductCache transparently check local storage first, fall back to ExternalProductAPI if there is a miss, cache the result, and serve subsequent requests from local storage until expiration. Writes are delegated upstream.
The v5 changes to how source resources work make this pattern significantly more practical.
Returning a Response Object from Source
A source resource's get method can now return a standard Response object — the same type you get from a fetch() call. Harper will stream the response body and save the headers into the cached record automatically.
class ExternalProductAPI extends Resource {
static async get(target) {
const response = await fetch(`https://api.example.com/products/${target.id}`);
return response; // Harper handles streaming and header caching
}
}This matters because most upstream data sources are HTTP APIs. Previously, you had to extract the body, parse it, and return a plain object — discarding headers that might carry semantic information (caching directives, content type, custom metadata). Now the full response is preserved. If the upstream API returns Cache-Control or ETag headers, those are stored and can inform downstream caching behavior.
The getResponse() function, available as a named export from the harper module, provides access to the response object from within a resource method when you need it. You can use wasLoadedFromSource() on the instance to determine whether the current request was a cache miss — useful when you want to behave differently on cold cache versus warm cache.
Stale-While-Revalidate
The allowStaleWhileRevalidate() method gives you control over cache freshness policy at the record level:
static allowStaleWhileRevalidate(entry, id) {
// serve the stale entry immediately while revalidation runs in the background
return (Date.now() - entry.localTime) < 60_000; // stale for less than 60s
}When this returns true, Harper serves the cached value immediately and triggers a background refresh. When it returns false, the request waits for the fresh value. The entry object includes version, localTime, expiresAt, and value, giving you the information needed to make this decision per-record rather than globally.
The combination of source delegation, Response object handling, and stale-while-revalidate control gives you a caching layer that is not bolted on but is expressed through the same method interface as everything else in the API.
Context Without Threading
One of the practical complications in multi-request server environments is getting access to the current request from code that is not the immediate handler. If you have a utility function five calls deep in a call chain, and you need the request's authentication token or user context, the common solution is to pass the request object as a parameter through every intermediate function. This works but it is noise — most functions do not need the request, they just relay it to the next level.
v5 solves this with asynchronous context tracking. getContext(), available as a named export from the harper module or as a global, returns the current Request object — the one associated with whatever async execution context is currently running. No parameter passing required.
import { getContext } from 'harper';
function someUtilityDeepInTheCallChain() {
const context = getContext();
const userRole = context.user.role;
// act on user authorization without having received request as a parameter
}When triggered by HTTP, the context is the Request object with properties for method, headers, responseHeaders, url, ip, host, body, and data. You can set response headers through context.responseHeaders.set() from anywhere in the async context — another case where the alternative is passing state through intermediate layers.
This is the Node.js AsyncLocalStorage API applied consistently throughout Harper's request lifecycle. The mechanism is not novel, but the fact that Harper exposes it through the standard getContext() export means you do not have to set it up yourself.
The Benefits, in Summary
The Resource API's design choices compound. Because static methods map directly to HTTP verbs, there is no translation layer between what the HTTP client requests and what your code handles. Because RequestTarget arrives pre-parsed, URL interpretation is not your responsibility. Because the same methods work for both HTTP handling and programmatic data access, there is one mental model for both concerns. Because source resources can return Response objects, the caching layer preserves upstream HTTP semantics rather than discarding them. Because getContext() uses async context tracking, request state is accessible without explicit parameter threading.
None of these are individually unprecedented. What the Resource API does is combine them into a consistent interface where each decision reinforces the others. The HTTP uniform interface — which is precise and well-specified — becomes the application's data interface as well. That alignment reduces the surface area for inconsistency, and it reduces the amount of glue code that does nothing but translate between layers.
I am most interested to see how teams use the source resource pattern as systems scale. The caching and sourcing model is expressive enough to handle a range of architectures — local caching of remote APIs, tiered storage, conditional revalidation based on record-level logic — and I think the combinations people reach for in practice will be more varied than what we have anticipated.
The v5 Resource API documentation covers the full method signatures and options.








.jpg)