REST API Ticket Related Developer Guide - edmozley/freeitsm GitHub Wiki
๐ A ticket's related block and attachments list - Developer Guide
How GET /tickets/{id} came to say what is on a ticket and what it is linked to, and how GET /tickets/{id}/attachments lists its files. Built for #328 (5.2.0), asked for by pappas81. User docs: REST API - Tickets (the related block under GET /tickets/{id}, and GET /tickets/{id}/attachments).
The one-line version: one new file, api/v1/resources/ticket_related.php, with one function - apiTicketRelated($conn, $ticketId, $apiKey) - that three places call: the single GET, the list (only with ?include=related), and the webhook engine's Full record loader for tickets. Passing the API key makes it leave out what the key could not read itself; passing null (a webhook) gives everything.
1. Why
Before 5.2.0, GET /tickets/{id} - and the ticket's Full record webhook, which is the same serialiser - said nothing about what hung off the ticket. An integration that wanted the whole picture called /notes, /thread, /forms, /checklists, /watchers... in turn, most of them returning []. And there was no way at all to learn what the ticket was linked to (tasks have a ticket_id; tickets had nothing pointing the other way), or what files were attached.
before after
GET /tickets/42 GET /tickets/42
GET /tickets/42/notes -> [] related.counts -> { notes: 0, messages: 2, attachments: 1, forms: 0, ... }
GET /tickets/42/thread -> [2] related.links -> [ {type: task, id: 41}, {type: ticket, relation: duplicate_of} ]
GET /tickets/42/forms -> [] GET /tickets/42/thread (worth calling)
GET /tickets/42/checklists -> [] GET /tickets/42/attachments (worth calling)
...
2. The files
| File | ๐จ | What changed |
|---|---|---|
api/v1/resources/ticket_related.php |
๐ | new: apiTicketRelated(), apiTicketAttachments(), the handler apiTicketAttachmentsList() |
api/v1/resources/tickets.php |
๐ | requires it; apiTicketsGet() adds related; apiTicketsList() adds it with ?include=related |
api/v1/lib/routes.php |
๐ | GET /tickets/{id}/attachments |
api/v1/lib/permissions.php |
๐ | permission resource ticket_attachments (read) |
api/v1/lib/modules.php |
๐ | ticket_attachments belongs to Tickets (always on) |
api/v1/index.php |
๐ | the route in the root endpoint list |
workflow/includes/engine.php |
โ๏ธ | the ticket Full record loader appends related (with $apiKey = null) |
api/workflow/webhook_test.php |
โ๏ธ | the editor's Send test sample does the same, so the preview matches a real send |
api/v1/spec.json |
๐ | the new route; include on GET /tickets; GET /tickets/{id} describes related |
api/v1/lib/openapi_schemas.php |
๐ | typed TicketRelated and TicketAttachment; related on Ticket and TicketDetail |
includes/feature_bingo/cards/system-integrations.php |
๐ฏ | system.api_ticket_related |
tests/api-ticket-related.php |
๐งช | new: 16 checks over real HTTP |
No schema change - every table it reads already existed.
3. The shape
"related": {
"counts": { "notes": 3, "messages": 5, "attachments": 2, "forms": 1,
"checklists": 0, "watchers": 2, "time_entries": 0 },
"links": [
{ "type": "task", "id": 41, "name": "Order laptop" },
{ "type": "problem", "id": 7, "reference": "PRB-0007", "name": "VPN drops" },
{ "type": "ticket", "id": 990, "reference": "TKT-0990", "relation": "duplicate_of" },
{ "type": "external_issue", "provider": "jira", "reference": "OPS-12",
"url": "https://example.atlassian.net/browse/OPS-12", "status": "In Progress" }
]
}
Counts, not booleans. The issue asked for notes_attached: true/false. A count answers the same question (> 0) and the next one too (is it worth paging?), for the same query.
One count per sub-endpoint, named for what the endpoint returns, so the mapping is obvious:
| count | endpoint | permission | how it is counted |
|---|---|---|---|
notes |
/notes |
ticket_notes |
COUNT(*) FROM ticket_notes |
messages |
/thread |
ticket_thread |
COUNT(*) FROM emails |
attachments |
/attachments |
ticket_attachments |
count(apiTicketAttachments()) - the list itself, so they can never disagree |
forms |
/forms |
ticket_forms |
count(TicketForms::forTicket()) - the endpoint's own call |
checklists |
/checklists |
ticket_checklists |
COUNT(*) FROM ticket_checklists |
watchers |
/watchers |
ticket_watchers |
count(TicketWatchers::list()) - it leaves out inactive analysts, so a COUNT would not match |
time_entries |
/time-entries |
ticket_time_entries |
COUNT(*) ... AND is_active = 1, the endpoint's filter |
TRAP: a count must match what its endpoint returns. Where the endpoint filters (watchers drop inactive analysts, forms come from the host logic), the count calls the same service rather than writing a second query that would drift.
4. Who sees what
related must never tell a key more than it could find out by calling the endpoints itself. So:
// api/v1/resources/ticket_related.php
$can = function (string $perm) use ($apiKey): bool {
if ($apiKey === null) return true; // a webhook - see below
return in_array('read', $apiKey['permissions'][$perm] ?? [], true);
};
...
if ($can('ticket_notes')) $counts['notes'] = $scalar("SELECT COUNT(*) FROM ticket_notes WHERE ticket_id = ?");
- A count appears only with
readon its endpoint's permission. A key withtickets.readalone gets"counts": {}. - A link appears only with
readon that record's permission (tasks,problems,changes,projects,assets,cmdb_objects) and if the record is in one of the key's companies:
$inScope = function (string $table, int $id) use ($conn, $apiKey): bool {
return $apiKey === null || apiKeyCanAccessTenantRow($conn, $apiKey, $table, $id);
};
- A linked ticket goes through
apiKeyCanAccessTicket()- company and the key's analyst's departments, the same gate asGET /tickets/{id}. - A linked ticket never carries its subject, only its number and the relation. The other ticket may be confidential, and on the webhook path nothing redacts a ticket that is not the one the event is about. (The ticket the event IS about is still handled by
ticketRedactForOutbound()- a confidential ticket gets no.fullat all.) - A webhook passes
nulland gets everything: whoever set up the workflow chose to send the record, as with every other field of.full.
TRAP: empty counts must be {}, not []. PHP encodes an empty array as [], which the OpenAPI shape check (rightly) calls a wrongly-serialised object. The function returns $counts ?: new stdClass().
5. Links
Six record kinds come from a table of [type, permission, table, SQL], each SQL giving id, reference and name:
$kinds = [
['task', 'tasks', 'tasks', "SELECT id, NULL AS reference, title AS name FROM tasks WHERE ticket_id = ? ORDER BY id"],
['problem', 'problems', 'problems', "SELECT p.id, p.problem_number AS reference, p.title AS name FROM problem_tickets pt JOIN problems p ON p.id = pt.problem_id WHERE pt.ticket_id = ? ORDER BY p.id"],
['change', 'changes', 'changes', "... FROM change_tickets ct JOIN changes c ..."],
['project', 'projects', 'projects', "... FROM project_tickets pt JOIN projects p ..."],
['asset', 'assets', 'assets', "... FROM ticket_assets ta JOIN assets a ..."],
['cmdb_object', 'cmdb_objects', 'cmdb_objects', "... FROM ticket_cmdb_objects tc JOIN cmdb_objects o ..."],
];
Adding a kind is one row. Each query is in its own try, so a module whose tables Database Verify has not created yet simply links nothing.
Ticket to ticket reuses ticketLinksFor() from includes/ticket_links.php - the very grouping the ticket screen draws - and turns its groups into a relation from THIS ticket's side:
$g = ticketLinksFor($conn, $ticketId); // parent, children[], duplicate_of, duplicates[], related[]
if ($g['parent']) $links[] = $one($g['parent'], 'child_of');
foreach ($g['children'] as $t) $links[] = $one($t, 'parent_of');
if ($g['duplicate_of']) $links[] = $one($g['duplicate_of'], 'duplicate_of');
foreach ($g['duplicates'] as $t) $links[] = $one($t, 'duplicated_by');
foreach ($g['related'] as $t) $links[] = $one($t, 'related');
Merges add merged_into_this from ticket_merges (the merged ticket is in the trash; GET /tickets/{id}?deleted=true still finds it). External tracker issues come from integration_links joined to integration_connections for the provider - key, address and status in the tracker - under tickets.read, since they are part of the ticket.
6. GET /tickets/{id}/attachments
Attachments were the one thing with no endpoint at all, so a count alone would have been a dead end. apiTicketAttachments() reads two places:
| source | from | left out |
|---|---|---|
message |
email_attachments joined to the ticket's emails |
is_inline = 1 - a signature's logo or a picture shown in the body is part of the message, and would bury the real files |
note |
document_links with parent_type = 'ticket_note' on the ticket's notes, joined to documents |
deleted documents |
Tickets have no Documents panel - files on a ticket live on its notes - which is why a note document's source_id is the note.
A document of kind files (a link to a file in the Files module, #303) is listed without its name: in the app the name is shown only to someone who can open the file's folder, and the API does not check Files permissions. It says there is a file here, nothing about it.
It lists; it does not download. Downloading over the API would need its own design (streaming, storage drivers, Files permissions) and was not asked for.
7. The list, and why related is opt-in there
// api/v1/resources/tickets.php - apiTicketsList()
if (in_array('related', array_map('trim', explode(',', (string)($_GET['include'] ?? ''))), true)) {
foreach ($tickets as &$tk) $tk['related'] = apiTicketRelated($conn, (int)$tk['id'], $apiKey);
unset($tk);
}
related is about a dozen small queries per ticket. On one ticket that is nothing; on a page of 100 it is 1,200, for callers who mostly do not want it. include is a comma list so another opt-in can join it later.
8. The webhook
The Full record loader for tickets appends it, exactly as the API does but with null:
// workflow/includes/engine.php - fullObjectLoaders()
'ticket' => $one('tickets.php', 'apiTicketSelect', ' WHERE t.id = ? LIMIT 1',
fn($c, $r) => apiSerializeTicket($r) + ['related' => apiTicketRelated($c, (int)$r['id'], null)]),
So {{ticket.full.related.counts.attachments}} works in any action, and Send test in the editor (api/workflow/webhook_test.php) builds its sample the same way, so the preview is what a real send carries.
9. Testing
FREEITSM_TEST_DATABASE=<throwaway db> FREEITSM_TEST_URL=http://localhost/<that install> php tests/api-ticket-related.php
Two ZZ tickets (one the parent of the other, the child's subject containing SECRET), a note with a document, a message with a real attachment and an inline picture, a task. Two temporary keys over real HTTP:
- the full key - counts are 1 note, every message, 2 attachments (the inline picture left out), and forms / checklists / watchers / time entries all present; links hold the task by id and name and the child as
parent_ofby number; nothing anywhere saysSECRET; from the child, the main ticket ischild_of. - the narrow key (
tickets.readonly) -countsis{}; no task link; the ticket link stays. /attachmentslists the message file and the note document with names, types, sizes andsource_id; 403 withoutticket_attachments.read.GET /tickets?include=relatedadds it to every row; without it the list is unchanged.- the webhook path -
enrichWithFullObjects()givesticket.full.relatedwith all seven counts and both links.
The OpenAPI document generated with the new schemas passes openapi_check.php and validates against the OpenAPI 3.0 meta-schema; openapi_verify.php against a live install finds nothing wrong with related.
10. Traps
- A count must match its endpoint - call the endpoint's own service where it filters.
$counts ?: new stdClass()- an empty object, not[].- Never a linked ticket's subject.
$reftakes no siblings in OpenAPI 3.0 - adescriptionbeside'$ref' => '#/components/schemas/TicketRelated'is ignored, and some validators reject it. The note went in a PHP comment.- Adding a sub-endpoint to tickets? Add its count here too, under its own permission, or
relatedquietly stops being the whole picture.