Software & practice

Plan REST interfaces with explicit contracts and version checks

One fictional task resource connects accepted input, structured problems and competing edits in a complete Node.js experiment.

AI-generated symbolic API contract: red metal request component has a matching tab for the dark response component. A supported API plaque spans the two sides. German labels Anfrage and Antwort.

A useful REST interface starts with a clear agreement: which business object can a client read, which changes are permitted and how does the client recognise a conflict? JSON and attractive path names do not answer those questions. This guide designs one fictional task resource and follows two competing edits through an executed HTTP experiment. The complete Node.js example uses artificial data and actual loopback requests. It demonstrates a limited interface contract rather than a finished public customer-management service.

Define the observable result before choosing endpoints

Our fictional editorial team has task 7, initially titled “Anfrage prüfen” with an open state. Two people can load that task at the same time. One finishes it while the other still has an older draft on screen. The interface must tell the second person that the underlying object has changed. It must preserve the newer object instead of silently replacing it with an outdated draft. That concrete requirement gives the design a result which can be inspected after an unsuccessful write.

We therefore start with three acceptance statements. Reading does not change the object. An accepted update returns the resulting object. An update based on an obsolete version leaves the current object unchanged. We also state a limit: the example task exists in one process's memory. Restarting that process discards it. A real service needs durable storage, object-level permissions and a mechanism which combines the version check and write atomically even when more than one worker handles requests.

Distinguish REST from a practical HTTP interface

In Roy Fielding's account, REST is an architectural style with interacting constraints, including a uniform interface and stateless communication. Sending JSON over HTTP does not automatically satisfy those constraints. Many interfaces commonly called REST also lack hypermedia controls. Our teaching example is deliberately a resource-oriented HTTP API with a previously documented endpoint. It does not claim to implement the entire REST architectural style. The conceptual distinction comes from the original REST chapter.

This distinction helps a team describe its system honestly. We can choose a small, understandable contract without attributing properties to it which have not been designed. Our client knows the task address from documentation. A later system might expose suitable next actions through links in its representations. Whether that additional work is worthwhile depends on the integration. It would not replace a documented state model or the decision about which person may change this particular task at this particular moment.

Separate the representation from writable input

The task has four visible properties: identifier, title, state and revision. The identifier remains fixed and the server alone produces the revision. A replacement request may contain only title and state. This explicitly separates the representation returned to a reader from the input accepted from a writer. Copying every incoming JSON property into the stored object would let a caller alter server-controlled information. It could also expose internal properties added during a later release without anyone deliberately approving them as writable inputs.

For this fictional contract, the title contains one to one hundred Unicode code points and has no surrounding whitespace. The state is exactly open or done. Other values are rejected rather than guessed, and additional properties are not accepted. These are decisions for our own example, not universal task-management requirements. In particular, the title limit is neither a database limit nor evidence that the resulting text fits every interface. A client still needs an appropriate layout for an allowed long title.

PropertyRead representationReplacement input
Identifier7Determined by the path only
TitleCurrent textOne to one hundred code points without outer whitespace
Stateopen or doneExactly one of those two values
RevisionServer-generated numberNot a writable property

Choose a small addressable resource

The only resource path is /api/tasks/7. It identifies the object independently of a button in the user interface. A button labelled Complete does not therefore require an endpoint named after that button. It can submit the intended state of the task. The experiment deliberately rejects a different identifier, a leading zero and additional query parameters. That narrow selection is testable. It is not a recommendation that production APIs should generally avoid query parameters or use hard-coded identifiers.

A real task collection would need a separate contract for listing, filtering and pagination. For example, the team would need to decide whether a sorted list remains traversable while other people add or change tasks. We do not hide that additional problem behind an apparently finished collection endpoint. Creating and deleting tasks are outside this experiment too. A small documented scope tells a reader more than a larger set of paths whose behaviour under repetition, invalid input or concurrent editing remains unspecified.

Select methods by intended effect

The contract permits GET to read the task, HEAD to obtain matching response metadata without response content and PUT to replace both writable properties. Other methods on this known resource receive 405 with an Allow header. The general properties of safe and idempotent methods, together with strong comparison for If-Match, are described in RFC 9110. Idempotence concerns the intended effect of repeating a request, not a promise that every repetition receives the same response.

In our own implementation, submitting the same current values again produces no additional business change and no extra revision. Repeating a request with an obsolete condition can nevertheless fail. The client must then read the current task and compare its draft. This matters when an answer is lost: the absence of an answer does not establish that the server discarded the first update. A repeated write needs a deliberate recovery decision instead of treating the connection outcome as a reliable record of the business outcome.

Make the response usable as evidence

A successful read returns the current task as JSON and its version marker in the ETag header. A successful replacement also returns the resulting task. The client therefore does not have to infer the server's final state from its old input form. In the experiment, the first marker is "task-7-r1". Changing the state produces "task-7-r2". A client should treat that marker as opaque: preserve the received value and send it back rather than generating the next number itself.

Every response in the teaching server has Cache-Control: no-store. We consequently do not introduce a reusable response-cache contract in this experiment. The ETag is used for write preconditions here. A later caching design would need to address representation selection and conditional reads separately. This small program is not a general HTTP cache or a complete implementation of every possible conditional request header. Its deliberately limited surface makes the statements about its observed behaviour precise and reproducible.

Three actually checked steps: reading returns revision 1; the matching update returns revision 2; an older draft receives 412 and preserves the existing title and done state. German labels.
The diagram uses actual response values from the artificial task fixture. The first accepted write sets done and produces revision 2. The obsolete draft receives 412, after which the stored object was compared. This is an observed single-process case, not a guarantee for distributed databases.

Follow a lost-update attempt through the actual state

Two clients first read the same revision. Client A then sends the existing title, a done state and the version marker it read. That update succeeds. Client B subsequently sends a different title with the same older marker. The server rejects this attempt with 412. In the actual experiment, the title remains “Anfrage prüfen” and the state remains done. We inspect the stored object after the rejection, so the result is more than a reassuring message displayed beside an unexamined write.

When the required condition is missing entirely, the server returns 428 and explains that the client should read the task and submit its ETag. That additional status is defined in RFC 6585, section 3. A wildcard condition checks existence instead of a particular previously read version. Our model accepts that meaning, but the wildcard is not evidence of a matching draft base. A client aiming to prevent lost updates uses the concrete marker it received.

Separate transport, input and version problems

A client should be able to distinguish a request described with the wrong media type, structurally unsuitable input and an outdated understanding of the object. The example gives those situations different responses. The MDN status overview provides their general context. Which cases we accept, reject and check first is part of our own deliberately limited contract. A response status is useful when the client also knows the corrective action associated with that particular failure.

The request-content limit is 4096 bytes. That transport limit is separate from the title's one hundred code points. A large JSON document can exceed the byte limit despite having a short visible title. Conversely, a small request can contain an unsupported state. Those failures should remain distinguishable. The limit is small because the example accepts only two input properties. An upload or a large collection would need a different design, including a decision about streaming, resource consumption and the evidence retained when processing stops partway through.

Actually transmitted caseResponseEffect
Unknown path404No change
POST to the known task405 with AllowNo change
Unaccepted content media type415No change
Invalid JSON400No change
Additional revision property422No change
Missing write precondition428No change
Obsolete version marker412Newer object retained

Return useful problems without implementation internals

The error representation contains a type, a short status title, the concrete numeric HTTP status and an explanation. Its media type is application/problem+json. The general structure follows RFC 9457. The status member needs to agree with the actual response. A user interface should be able to propose a sensible next step from the contract without parsing a database name or stack trace. Internal exception details remain outside the public error representation.

Our example uses the generic about:blank problem type. A larger product might benefit from documented custom problem types. In that design, stable machine decisions should depend on the agreed type or code instead of the exact German wording. Otherwise, improving a sentence could unexpectedly break a client. Sensitive submitted content should not automatically be echoed in an explanation either. This task only needs a statement about the permitted format, without reproducing arbitrary text from another person's request.

Run the complete bounded experiment

The following file uses Node.js's built-in HTTP module. When executed directly, it binds only to the loopback address. It contains no credentials and has no connection to an external system. All changes concern the single artificial task held in memory. The Node.js HTTP documentation describes the server primitive. This exact program was executed with Node.js 24.22.1. The current documentation linked here may describe a later runtime release, which is distinct from our recorded execution environment.

Save the file as api.mjs and run node api.mjs. After reading the task, a client can send the two writable properties and the received If-Match value. Making the teaching server publicly reachable is not an appropriate next step. A real service first needs the missing operational and permission decisions. The experiment explains the request contract and its observed responses. It does not conceal missing authentication behind a demonstration API key or imply that a successful local test establishes a secure deployed service.

import {createServer,STATUS_CODES} from 'node:http';
import {pathToFileURL} from 'node:url';
export function makeDemo(){
 let task={id:7,title:'Anfrage prüfen',state:'open',revision:1};
 const tag=()=>`"task-7-r${task.revision}"`;
 const server=createServer(async(req,res)=>{
  const send=(status,value,headers={})=>{
   const body=JSON.stringify(value);
   res.writeHead(status,{'Content-Type':'application/json; charset=utf-8','Cache-Control':'no-store','Content-Length':Buffer.byteLength(body),...headers});
   res.end(req.method==='HEAD'?'':body);
  };
  const problem=(status,detail,headers={})=>send(status,{type:'about:blank',title:STATUS_CODES[status],status,detail},{'Content-Type':'application/problem+json',...headers});
  try{
   if(req.url!=='/api/tasks/7')return problem(404,'Diese Beispielressource existiert nicht.');
   if(!['GET','HEAD','PUT'].includes(req.method))return problem(405,'Diese Methode ist hier nicht vorgesehen.',{'Allow':'GET, HEAD, PUT'});
   if(req.method!=='PUT')return send(200,task,{'ETag':tag()});
   if(!/^application\/json(?:\s*;\s*charset=utf-8)?$/i.test(req.headers['content-type']??''))return problem(415,'JSON mit UTF-8 verwenden.');
   let size=0;const chunks=[];
   for await(const chunk of req){size+=chunk.length;if(size>4096)return problem(413,'Maximal 4096 Bytes senden.');chunks.push(chunk);}
   let input;
   try{input=JSON.parse(new TextDecoder('utf-8',{fatal:true}).decode(Buffer.concat(chunks)));}catch{return problem(400,'Der Inhalt muss gültiges UTF-8 und JSON sein.');}
   if(!input||typeof input!=='object'||Array.isArray(input)||Object.keys(input).sort().join(',')!=='state,title'||typeof input.title!=='string'||input.title.trim()!==input.title||[...input.title].length<1||[...input.title].length>100||!['open','done'].includes(input.state))return problem(422,'Genau title und state im vereinbarten Format senden.');
   const condition=req.headers['if-match'];
   if(condition===undefined)return problem(428,'Zuerst lesen und den erhaltenen ETag als If-Match mitsenden.');
   if(condition!=='*'&&!/^(?:W\/)?"[\x21\x23-\x7e\x80-\xff]*"(?:\s*,\s*(?:W\/)?"[\x21\x23-\x7e\x80-\xff]*")*$/.test(condition))return problem(400,'If-Match muss eine gültige Liste von Entity-Tags oder * sein.');
   const candidates=condition.match(/(?:W\/)?"[^\"]*"/g)??[];
   if(condition!=='*'&&!candidates.includes(tag()))return problem(412,'Der gelesene Stand ist nicht mehr aktuell. Erneut lesen und Änderungen vergleichen.');
   // No await between revision check and update in this one-process fixture.
   if(input.title!==task.title||input.state!==task.state)task={...task,...input,revision:task.revision+1};
   return send(200,task,{'ETag':tag()});
  }catch(error){console.error(error);if(!res.headersSent)problem(500,'Die Anfrage konnte nicht verarbeitet werden.');else res.destroy();}
 });
 server.requestTimeout=5000;server.headersTimeout=5000;
 return {server,snapshot:()=>structuredClone(task)};
}
if(process.argv[1]&&import.meta.url===pathToFileURL(process.argv[1]).href){const {server}=makeDemo();server.listen(8787,'127.0.0.1',()=>console.log('http://127.0.0.1:8787/api/tasks/7'));}

Understand exactly what the checks establish

The check program sent 32 real HTTP requests over a dynamically allocated loopback address and passed 93 individual assertions. It inspected response status, media type, ETag, Allow, empty HEAD content and the task remaining in memory. Invalid UTF-8 was actually transmitted. A syntactically valid list containing an entity tag with an embedded comma was also exercised. A weak version marker did not satisfy strong matching. These are concrete results from the supplied program, not measurements of an unrelated production application.

Two simultaneously started writes using the same current marker resulted in exactly one accepted update. The other received 412. That observation applies to one Node.js process: there is no asynchronous pause between its version comparison and assignment. It establishes no distributed or multi-worker guarantee. A database-backed implementation must evaluate the same business condition atomically with its write. Performing a SELECT and later an unconditional UPDATE could reintroduce the race despite preserving the same HTTP names and JSON response shape.

Give a conflict a usable interface

A 412 response should not erase the person's unsaved draft. The fictional task editor can keep three versions separately: the object originally read, the person's current draft and the newly retrieved server representation. That comparison reveals whether the same title changed or only the completion state. Automatically replacing the ETag and blindly repeating the old draft would bypass the purpose of the conflict check. It would turn a detected collision into the silent overwrite which the original requirement specifically set out to prevent.

In our example, the second person prepared a different title while the first person completed the task. Showing both changes is more useful than a generic saving-failed notice. The person can decide which fields still need to be combined. A newly assembled replacement uses the most recently read marker. Another concurrent edit can still cause that attempt to fail. The recovery flow therefore needs to be repeatable. It must not abandon its version rule merely because the user has already encountered one conflict.

Distinguish a confirmed rejection from an unknown outcome

A received error response differs from a broken connection without a usable response. In the first situation, the client has a statement from the service. In the second, that statement is missing even though the requested change might already have happened. For this task, reading again can show whether the intended values are present. Whether such agreement provides sufficient business evidence depends on the object. A task state is not equivalent to a payment or a legally significant approval with a separate audit requirement.

The client must consequently avoid showing success just because it sent a request. It needs a matching successful response or later evidence which the contract explicitly accepts. It should not claim that nothing happened merely because the connection failed either. This distinction belongs in client acceptance tests. One useful extension would interrupt a response after the update and inspect the recovery interface. Our recorded checks do not include that network-interruption experiment, so this article does not report it as a completed capability of the example.

Evolve the contract without changing meanings silently

Suppose a later release adds a waiting-for-reply state. Updating the server's allowed values is only part of the change. An existing client might display only two states and incorrectly interpret an unfamiliar value as open. The handover needs an explicit response to unknown output values. Adding a new mandatory input property creates a different compatibility problem: old clients cannot supply it. A version decision should examine these practical effects instead of asking only whether the revised JSON remains syntactically valid.

Our strict input contract intentionally rejects extra properties. That helps expose misspellings, but extensions need a coordinated introduction. A team could first release server support for an optional property and subsequently deploy clients which send it. That order and its rollback conditions belong in the same change plan. For the task example, the existing negative checks should continue running. Adding a state must not accidentally allow a client-supplied revision to override the server's version control or cause an obsolete draft to be accepted.

Accept description, permissions and operation together

A handover can describe the interface using a deliberately selected OpenAPI version. OpenAPI 3.1.1 is a named reference here, not a claim that it is the latest published edition. The description would specify paths, methods, input schemas, allowed states, headers and responses. Readable documentation does not replace executed negative cases. In particular, the write schema should not present the server-controlled revision as a property which a client can directly replace.

The OWASP REST security guidance addresses HTTPS, access control, input validation and media types. Our teaching server does not pretend to complete these areas through a sample secret. A real tenant-aware service needs verified identity and permission checks on the actual object. Knowing a task identifier grants no authority. CORS is not authentication, and a version marker is not a credential authorising access. Each missing responsibility needs an owner and an observable acceptance condition.

  • Define the business object and permitted transitions in writing.
  • Separate read representations from writable input.
  • Document methods, paths, limits and failures with concrete examples.
  • Test missing and obsolete preconditions independently.
  • Plan retries after an unknown outcome explicitly.
  • Combine the version check with durable changes atomically.
  • Accept object permissions, logging and operating limits separately.
  • Compare clients and servers with negative cases when changing the contract.

The next valuable extension would preserve this same task durably, verify the caller's permission and deliberately repeat the conflict. Only then could the team assess whether the original requirement still holds in the new architecture. More decorative endpoints would not answer that question. A well-designed interface helps a client recognise an unexpected state, preserve the person's work and proceed with a decision which reflects what the service actually knows.

Let’s move forward

What would you like to change?

An idea, a specific challenge or your next step with AI. We’re listening.

Let’s talk