OdataDuty exposes a writable entity set the moment your data class (class DSL) or resolver (builder DSL) implements a write method. Three operations are inferred this way:
- Implement
create(input)and the set acceptsPOSTrequests, gains apostoperation in$oas2, drops theInsertRestrictionsannotation in$metadata, and advertises an MCPcreate_<Set>tool. - Implement
update(id, input)and the set acceptsPATCHrequests against an individual URL, gains apatchoperation in$oas2, drops theUpdateRestrictionsannotation in$metadata, and advertises an MCPupdate_<Set>tool. - Implement
delete(id)and the set acceptsDELETErequests against an individual URL, gains adeleteoperation in$oas2, drops theDeleteRestrictionsannotation in$metadata, and advertises an MCPdelete_<Set>tool.
There is no separate flag or declaration for any of them: availability is inferred from the presence of the method, exactly the way collection, individual, and od_search are inferred. Omit a method and the corresponding capability disappears across all three contracts; omit all three and the set is read-only.
This guide explains how to implement create, update, and delete, and how each choice is reflected in the generated $oas2, $metadata, and MCP contracts.
- Purpose: Allow clients to create new records through OData
POST, modify existing records through ODataPATCH, and remove existing records through ODataDELETE(and the equivalent MCP tools). - Mechanism: OdataDuty checks whether your data class / resolver defines
create,update, and / ordelete. Each method, independently, makes the corresponding operation available; omitting one drops only that capability, and the set is fully read-only only when all three are absent. - No new declaration: Writability is inferred from the method, just like
collection,individual, andod_search. - Typed input:
createandupdatereceive a coerced input object built from the request body and mapped onto the entity type's properties. Read fields viainput.property_name.updateadditionally receives the entity key as its first argument.deletereceives only the coerced key — aDELETEcarries no body, so there is no input object. - PATCH partial-merge:
updateuses partial-merge semantics — properties omitted from the request body read asnil, so the consumer cannot distinguish "field omitted" from "field explicitly null". - Return value:
createandupdatereturn the affected record — the same object shape your entity type's mapper renders forcollection/individual.deletereturns a truthy value on success and a falsey value to signal "no such record" (which the framework turns into aResourceNotFoundError); on success the response carries no entity payload. - Reflected everywhere: A
createable set has apostin$oas2, noInsertRestrictionsannotation in$metadata, and acreate_<Set>MCP tool. Anupdateable set has apatchin$oas2, noUpdateRestrictionsannotation in$metadata, and anupdate_<Set>MCP tool. Adeleteable set has adeletein$oas2, noDeleteRestrictionsannotation in$metadata, and adelete_<Set>MCP tool. A read-only set has none of these.
All three operations work the same way in both DSLs:
create(input): read the coerced fields offinput, persist a new record, and return it.update(id, input): receive the coerced key plus an input object, merge the present fields onto the existing record, and return it. Fields omitted from the request body read asnil, so the partial-merge pattern is... unless input.field.nil?.delete(id): receive the coerced key, remove the record, and return a truthy value. Return a falsey value (e.g.nil) when no record matches the key so the framework can raiseResourceNotFoundError. ADELETEcarries no body, sodeletetakes no input object.
A resolver that implements create, update, and delete is writable on all three verbs:
class PeopleResolver < OdataDuty::SetResolver
def od_after_init
@records = Person.all
end
def collection
@records
end
def individual(id)
@records.find { |record| record.id == id }
end
# Receives a typed, coerced input object. Access fields via input.property_name.
# Returns the created record (same shape collection/individual return).
def create(input)
Person.create!(user_name: input.user_name, name: input.name, emails: input.emails)
end
# Receives the coerced key and a typed input object (omitted fields read as nil).
# Returns the updated record (same shape collection/individual return).
def update(id, input)
person = Person.find(id)
person.update!(name: input.name) unless input.name.nil?
person
end
# Receives the coerced key only (DELETE carries no body).
# Return a truthy value on success; return falsey when no record matches the key.
def delete(id)
person = Person.find_by(id: id)
return nil unless person
person.destroy!
true
end
endA resolver without create, update, or delete is read-only — the data methods are identical, just no write methods:
class CountriesResolver < OdataDuty::SetResolver
def od_after_init
@records = Country.all
end
def collection
@records
end
def individual(id)
@records.find { |record| record.id == id }
end
endThe class DSL behaves identically; the set itself implements create, update, and delete:
class PeopleSet < OdataDuty::EntitySet
entity_type PersonEntity
def od_after_init
@records = Person.active
end
def collection
@records
end
def individual(id)
@records.find(id)
end
# Receives a typed, coerced input object. Access fields via input.property_name.
def create(input)
Person.create!(user_name: input.user_name, name: input.name, emails: input.emails)
end
# Receives the coerced key and a typed input object (omitted fields read as nil).
def update(id, input)
person = @records.find(id)
person.update!(name: input.name) unless input.name.nil?
person.update!(emails: input.emails) unless input.emails.nil?
person
end
# Receives the coerced key only; return truthy on success, falsey if not found.
def delete(id)
person = @records.find_by(id: id)
return nil unless person
person.destroy!
person
end
endOmit create, update, and delete and the same set is read-only:
class CountriesSet < OdataDuty::EntitySet
entity_type CountryEntity
def od_after_init
@records = Country.all
end
def collection
@records
end
def individual(id)
@records.find(id)
end
endcreate is routed through schema.create (POST), update through schema.update (PATCH), and delete through schema.delete (DELETE), all mirroring schema.execute for GET. schema.update(url, context:, query_options:) and schema.delete(url, context:, query_options:) are the new public entry points beyond create. For update the individual URL (e.g. People('1')) carries the key while the request body arrives through query_options. For delete the individual URL carries the key and there is no body; on success schema.delete returns no entity payload, so the controller responds 204 No Content:
# config/routes.rb
scope '/api' do
root 'api#index'
get '$metadata' => 'api#metadata'
get '$oas2' => 'api#oas2'
get '*url' => 'api#show'
post '*url' => 'api#create'
patch '*url' => 'api#update'
delete '*url' => 'api#destroy'
end# app/controllers/api_controller.rb
def create
render json: schema.create(params[:url], context: self, query_options: query_options)
end
def update
render json: schema.update(params[:url], context: self, query_options: query_options)
end
def destroy
schema.delete(params[:url], context: self, query_options: query_options)
head :no_content
end- Discovery: OdataDuty checks whether your data class / resolver responds to
create,update, and / ordelete. Each check independently drives the contracts below. - Coercion: On
POSTthe request body is coerced and validated against the entity type's properties into a typed input object passed tocreate. OnPATCHthe key is parsed from the individual URL and coerced to the property-ref's type (the same conversionindividual(id)receives), and the body is coerced into the same input-object shape passed toupdate. Under partial-merge semantics, properties absent from the body read asnil. OnDELETEthe key is parsed from the individual URL and coerced the same way, and passed todeletewith no body. - Persistence: Your
create/updatepersists the record and returns it; yourdeleteremoves it and returns a truthy value (falsey when no record matches the key). - Rendering: For
create/updatethe returned record is run through the entity type's mapper, so the response is the same shape aGETwould produce, plus the@odata.contextannotation. Forupdatethe anchor is the individual@odata.context(.../$metadata#People/$entity), the same anchorGET /People('1')returns. Fordeletethere is no entity payload — the controller responds204 No Content.
A PATCH to an individual URL applies a partial merge:
PATCH /api/People('1')
Content-Type: application/json
{ "name": "Alice Updated" }Response (the updated entity, mapper-rendered, with the individual @odata.context anchor):
Because semantics are partial-merge, fields omitted from the body (user_name, emails above) read as nil inside update, and the example consumer leaves them untouched.
A createable set's collection path exposes both get and post. The post operation's operationId is Create<Set> (e.g. CreatePeople):
{
"paths": {
"/People": {
"get": { "operationId": "ListPeople" /* ... */ },
"post": {
"operationId": "CreatePeople",
"produces": ["application/json"],
"parameters": [
{ "name": "body", "in": "body", "required": true,
"schema": { "$ref": "#/definitions/PersonCreate" } }
],
"responses": {
"200": { "description": "Success", "schema": { "$ref": "#/definitions/Person" } },
"201": { "description": "Created", "schema": { "$ref": "#/definitions/Person" } },
"default": { "description": "Unexpected error", "schema": { "$ref": "#/definitions/Error" } }
}
}
}
}
}The post body references the per-operation PersonCreate definition (the create-settable properties), while its responses return the full Person. See doc/using_mutability.md for how mutability: shapes <Entity>Create / <Entity>Update.
An updateable set's individual path gains a patch alongside its get. The operationId is Update<Set> (e.g. UpdatePeople); its parameters are the id path parameter plus a required body parameter $refing the per-operation PersonUpdate definition (the update-settable properties). Note the responses are 200 Success and default Error only — there is no 201, unlike create's post:
{
"paths": {
"/People({id})": {
"get": { "operationId": "GetIndividualPeopleById" /* ... */ },
"patch": {
"operationId": "UpdatePeople",
"produces": ["application/json"],
"parameters": [
{ "name": "id", "in": "path", "required": true, "type": "string" },
{ "name": "body", "in": "body", "required": true,
"schema": { "$ref": "#/definitions/PersonUpdate" } }
],
"responses": {
"200": { "description": "Success", "schema": { "$ref": "#/definitions/Person" } },
"default": { "description": "Unexpected error", "schema": { "$ref": "#/definitions/Error" } }
}
}
}
}
}These per-operation request-body definitions are emitted for every create-/update-able set, including sets with no constrained properties — there PersonCreate / PersonUpdate equal the writable set. The post and patch responses keep returning the full Person.
A deleteable set's individual path gains a delete alongside its get (and patch, if updatable). The operationId is Delete<Set> (e.g. DeletePeople); its only parameter is the id path parameter — there is no body. Its responses are 204 No Content (with no success schema) and default Error only:
{
"paths": {
"/People({id})": {
"get": { "operationId": "GetIndividualPeopleById" /* ... */ },
"delete": {
"operationId": "DeletePeople",
"parameters": [
{ "name": "id", "in": "path", "required": true, "type": "string" }
],
"responses": {
"204": { "description": "No Content" },
"default": { "description": "Unexpected error", "schema": { "$ref": "#/definitions/Error" } }
}
}
}
}
}A read-only set's collection path carries only get and its individual path carries only get — the post, patch, and delete are omitted entirely:
{
"paths": {
"/Countries": {
"get": { "operationId": "ListCountries" /* ... */ }
},
"/Countries({id})": {
"get": { "operationId": "GetIndividualCountriesById" /* ... */ }
}
}
}A set without create carries an InsertRestrictions annotation marking it not insertable, a set without update carries a parallel UpdateRestrictions annotation marking it not updatable, and a set without delete carries a parallel DeleteRestrictions annotation marking it not deletable. A set that is none of the three carries all three:
<EntitySet Name="Countries" EntityType="MySpace.Country">
<Annotation Term="Capabilities.InsertRestrictions">
<Record>
<PropertyValue Property="Insertable" Bool="false" />
</Record>
</Annotation>
<Annotation Term="Capabilities.UpdateRestrictions">
<Record>
<PropertyValue Property="Updatable" Bool="false" />
</Record>
</Annotation>
<Annotation Term="Capabilities.DeleteRestrictions">
<Record>
<PropertyValue Property="Deletable" Bool="false" />
</Record>
</Annotation>
</EntitySet>A set gets no annotation for a capability it supports (the OData defaults are insertable, updatable, and deletable). So a fully writable set with create, update, and delete carries none of the three annotations:
<EntitySet Name="People" EntityType="MySpace.Person" />The Org.OData.Capabilities.V1 vocabulary (aliased Capabilities) is already referenced at the top of the metadata document, so the annotations need no additional setup.
tools/list includes a create_<Set> tool for each insertable set, an update_<Set> tool for each updatable set, and a delete_<Set> tool for each deletable set.
The create_<Set> tool's name is create_<Set>, its description is "Create a new <Set> record", and its inputSchema is an object whose properties are the entity type's writable properties and whose required array is the non-nullable property names:
// tools/list result (insertable People set)
{
"name": "create_People",
"description": "Create a new People record",
"inputSchema": {
"type": "object",
"properties": {
"id": { "type": "string" /* ... */ },
"user_name": { "type": "string" /* ... */ },
"name": { "type": "string" /* ... */ },
"emails": { "type": "array" /* ... */ }
},
"required": ["id", "user_name", "emails"]
}
}The update_<Set> tool's name is update_<Set>, its description is "Update an existing <Set> record", and its inputSchema properties are the key property plus the writable properties. Its required array contains only the key — the key locates the record, while every other field is optional under partial-merge semantics. The key property defaults to computed, so its schema carries "readOnly": true:
// tools/list result (updatable People set)
{
"name": "update_People",
"description": "Update an existing People record",
"inputSchema": {
"type": "object",
"properties": {
"id": { "type": "string", "readOnly": true /* ... */ },
"user_name": { "type": "string" /* ... */ },
"name": { "type": "string" /* ... */ },
"emails": { "type": "array" /* ... */ }
},
"required": ["id"]
}
}The delete_<Set> tool's name is delete_<Set>, its description is "Delete an existing <Set> record", and its inputSchema properties contain only the key — a DELETE carries no body, so no other fields appear. Its required array is the key. The key property defaults to computed, so its schema carries "readOnly": true:
// tools/list result (deletable People set)
{
"name": "delete_People",
"description": "Delete an existing People record",
"inputSchema": {
"type": "object",
"properties": {
"id": { "type": "string", "readOnly": true }
},
"required": ["id"]
}
}A tools/call for create_<Set> creates the record, a tools/call for update_<Set> updates it, and a tools/call for delete_<Set> deletes it, each reusing the same path as the corresponding REST POST / PATCH / DELETE. create and update return the affected entity as structured JSON; delete returns a simple acknowledgement with no entity payload:
// tools/call request (update)
{
"method": "tools/call",
"params": {
"name": "update_People",
"arguments": { "id": "1", "name": "Alice Updated" }
}
}// tools/call request (delete)
{
"method": "tools/call",
"params": {
"name": "delete_People",
"arguments": { "id": "1" }
}
}A read-only set advertises none of these tools, so calling one raises an "Unknown tool" error (see below).
Because reads are also exposed as tools, an agent can complete a full read→act loop through MCP
alone: call list_<Set> to discover records, pick a key, then call get_<Set> / update_<Set> /
delete_<Set> by that key. See using_mcp.md for the read tools.
-
POSTto a set withoutcreate/PATCHto a set withoutupdate/DELETEto a set withoutdelete: RaisesOdataDuty::NoImplementationErrorwith the messagecreate not implemented for <url>,update not implemented for <url>, ordelete not implemented for <url>respectively (the set's URL, e.g.delete not implemented for People). ThisNoImplementationErroris raised only when the set genuinely does not define the operation, determined up front by a capability check. -
A
NoMethodErrorraised inside yourcreate/update: ANoMethodErrorthat originates within your owncreateorupdateimplementation (a typo, a call to an undefined method, anilwhere an object was expected) propagates unchanged — with its original message and backtrace — and is not rewritten toNoImplementationError. So a real bug in your implementation surfaces as the realNoMethodError, consistent with howdeletealready behaves. -
PATCH/DELETEfor a key that doesn't exist: When yourupdateordeletecannot find the record and returns a falsey value, the framework raisesOdataDuty::ResourceNotFoundError(No such entity <id>), the same wayindividualdoes for a missing record. -
Invalid key in the
PATCH/DELETEURL: A key that can't be coerced to the property-ref's type raisesOdataDuty::InvalidPropertyReferenceValue(Invalid individual id : ...), the same conversion errorindividualproduces. -
Request body that fails coercion/validation: When a body value cannot be coerced to a property's type, the input wrapper rescues the internal
InvalidValueand raisesOdataDuty::InvalidType— soOdataDuty::InvalidTypeis what propagates for a wrong-typed value, for bothcreateandupdate. (deletetakes no body, so this case does not apply to it.) TheInvalidTypemessage includes the underlying coercion reason as a substring, not just the field name — e.g.The value provided for 'birth_date' is of wrong type: invalid daterather thanThe value provided for 'birth_date' is of wrong type. The appended reason is the failing type's own coercion-error message (Ruby'sinvalid value for Integer(): "abc"for an integer,invalid datefor a date,<value> not boolean valuefor a boolean). -
Reading an undefined property, or calling an accessor with arguments: The typed input object exposes exactly the entity type's properties. Two misuses both raise
OdataDuty::NoSuchPropertyError:- Reading a field that is not a defined property (e.g.
input.nicknamewhen there is nonicknameproperty) raisesOdataDuty::NoSuchPropertyErrorwith the exact messageNo such property 'nickname'. Unknown keys in the request body are otherwise ignored unless your method reaches for them viainput.<that_key>. - Calling a property accessor with arguments (e.g.
input.first_name('x')) raisesOdataDuty::NoSuchPropertyError— the accessors take no arguments; they only read the coerced value. Read a property withinput.first_name, neverinput.first_name(...).
- Reading a field that is not a defined property (e.g.
-
MCP
create_<Set>/update_<Set>/delete_<Set>for a set that lacks the capability: Because the tool is never listed for such a set, calling it viatools/callraises an"Unknown tool: <tool>"error rather thanNoImplementationError.
- Make a set writable by implementing
create(input)(insert),update(id, input)(partial-merge update), and / ordelete(id)(remove) on its data class (class DSL) or resolver (builder DSL); omit a method to drop that capability, omit all three to keep the set read-only. createreceives a typed, coerced input object and returns the created record.updateadditionally receives the coerced key as its first argument; omitted fields read asnil(partial merge). Both return the affected record in the same shapecollection/individualreturn.deletereceives only the coerced key (no body) and returns a truthy value on success or falsey for "no such record" (→ResourceNotFoundError); on success there is no entity payload (204 No Content).- Insertable sets gain a
post(operationIdCreate<Set>) on the collection path in$oas2, noInsertRestrictionsannotation in$metadata, and acreate_<Set>MCP tool. Updatable sets gain apatch(operationIdUpdate<Set>) on the individual path in$oas2, noUpdateRestrictionsannotation in$metadata, and anupdate_<Set>MCP tool (itsinputSchemaincludes the key,requiredis the key only). Deletable sets gain adelete(operationIdDelete<Set>) on the individual path in$oas2(responses204/default, no body), noDeleteRestrictionsannotation in$metadata, and adelete_<Set>MCP tool (itsinputSchema/requiredis the key only). Read-only sets lack all of these and carry all three restriction annotations.
{ "@odata.context": "https://host/api/$metadata#People/$entity", "id": "1", "user_name": "alice", "name": "Alice Updated", "emails": ["alice@example.com"] }