feat(15-02): add GraphQL Error Handler and HTTP Template utility nodes

- GraphQL Error Handler maps ALREADY_IN_STATE to HTTP 304 (matches Docker API pattern)
- Handles NOT_FOUND, FORBIDDEN, UNAUTHORIZED error codes
- HTTP Template pre-configured with 15s timeout for myunraid.net cloud relay
- Environment variable auth (UNRAID_HOST, UNRAID_API_KEY headers)
- continueRegularOutput error handling for downstream processing
- Standalone utility nodes at [600,2600] and [1000,2600] for Phase 16 wiring
- Fix: removed invalid notesDisplayMode from Container ID Registry
This commit is contained in:
Lucas Berger
2026-02-09 08:52:23 -05:00
parent 1b4b596e05
commit e6ac219212
+55 -4
View File
@@ -4887,8 +4887,7 @@
"position": [
200,
2600
],
"notes": "UTILITY: Transform Unraid GraphQL response to Docker API contract. Not connected - Phase 16 will wire this in."
]
},
{
"id": "code-container-id-registry",
@@ -4899,12 +4898,64 @@
200,
2400
],
"notesDisplayMode": "show",
"notes": "UTILITY: Container name <-> Unraid PrefixedID translation. Not connected - Phase 16 will wire this in.",
"parameters": {
"mode": "runOnceForAllItems",
"jsCode": "// Container ID Registry\n// Maps container names to Unraid PrefixedID format (129-char server_hash:container_hash)\n\n// Initialize registry using static data with JSON serialization pattern\n// CRITICAL: Must use top-level assignment per CLAUDE.md\nconst registry = $getWorkflowStaticData('global');\nif (!registry._containerIdMap) {\n registry._containerIdMap = JSON.stringify({});\n}\nif (!registry._lastRefresh) {\n registry._lastRefresh = 0;\n}\n\nconst containerMap = JSON.parse(registry._containerIdMap);\n\n/**\n * Update registry with fresh container data from Unraid GraphQL API\n * @param {Array} containers - Array of Unraid container objects {id, names[], state}\n * @returns {Object} Updated container map\n */\nfunction updateRegistry(containers) {\n const newMap = {};\n \n for (const container of containers) {\n // Extract container name: strip leading '/', lowercase\n const rawName = container.names[0];\n const name = rawName.startsWith('/') ? rawName.substring(1).toLowerCase() : rawName.toLowerCase();\n \n // Map name -> {name, unraidId}\n // The Unraid 'id' field IS the PrefixedID (129-char format)\n newMap[name] = {\n name: name,\n unraidId: container.id\n };\n }\n \n // Store timestamp\n registry._lastRefresh = Date.now();\n \n // Serialize (top-level assignment - this is what n8n persists)\n registry._containerIdMap = JSON.stringify(newMap);\n \n return newMap;\n}\n\n/**\n * Get Unraid PrefixedID for a container name\n * @param {string} containerName - Container name (e.g., 'plex', '/plex')\n * @returns {string} Unraid PrefixedID (129-char)\n * @throws {Error} If container not found\n */\nfunction getUnraidId(containerName) {\n // Normalize: strip leading '/', lowercase\n const name = containerName.startsWith('/') \n ? containerName.substring(1).toLowerCase() \n : containerName.toLowerCase();\n \n const entry = containerMap[name];\n \n if (!entry) {\n const registryAge = Date.now() - registry._lastRefresh;\n const isStale = registryAge > 60000; // 60 seconds\n \n if (isStale) {\n throw new Error(`Container \"${name}\" not found. Registry may be stale - try \"status\" to refresh.`);\n } else {\n throw new Error(`Container \"${name}\" not found. Check spelling or run \"status\" to see all containers.`);\n }\n }\n \n return entry.unraidId;\n}\n\n/**\n * Get full container entry by name\n * @param {string} containerName - Container name\n * @returns {Object} Container entry {name, unraidId}\n * @throws {Error} If container not found\n */\nfunction getContainerByName(containerName) {\n // Normalize name\n const name = containerName.startsWith('/') \n ? containerName.substring(1).toLowerCase() \n : containerName.toLowerCase();\n \n const entry = containerMap[name];\n \n if (!entry) {\n const registryAge = Date.now() - registry._lastRefresh;\n const isStale = registryAge > 60000; // 60 seconds\n \n if (isStale) {\n throw new Error(`Container \"${name}\" not found. Registry may be stale - try \"status\" to refresh.`);\n } else {\n throw new Error(`Container \"${name}\" not found. Check spelling or run \"status\" to see all containers.`);\n }\n }\n \n return entry;\n}\n\n// Detect mode based on input\nconst input = $input.item.json;\n\nif (input.containers && Array.isArray(input.containers)) {\n // Update mode: refresh registry with new container data\n const updatedMap = updateRegistry(input.containers);\n return {\n mode: 'update',\n registrySize: Object.keys(updatedMap).length,\n lastRefresh: new Date(registry._lastRefresh).toISOString(),\n containers: Object.keys(updatedMap)\n };\n} else if (input.containerName) {\n // Lookup mode: resolve container name to Unraid ID\n const unraidId = getUnraidId(input.containerName);\n const entry = getContainerByName(input.containerName);\n \n return {\n mode: 'lookup',\n containerName: entry.name,\n unraidId: unraidId\n };\n} else {\n throw new Error('Invalid input: provide either \"containers\" array or \"containerName\" string');\n}\n"
}
},
{
"parameters": {
"jsCode": "// GraphQL Error Handler - Standardized error checking and HTTP status mapping\n// Input: $input.item.json = raw response from HTTP Request node\n// Output: { success, statusCode, alreadyInState, message, data }\n\nconst response = $input.item.json;\n\n// Check GraphQL errors array\nif (response.errors && response.errors.length > 0) {\n const error = response.errors[0];\n const code = error.extensions?.code;\n const message = error.message;\n \n // Map error codes to HTTP equivalents\n if (code === 'ALREADY_IN_STATE') {\n // Maps to Docker API HTTP 304 pattern (used in n8n-actions.json)\n return {\n json: {\n success: true,\n statusCode: 304,\n alreadyInState: true,\n message: 'Container already in desired state'\n }\n };\n }\n \n // Error codes that should throw\n if (code === 'NOT_FOUND') {\n throw new Error(`Container not found: ${message}`);\n }\n \n if (code === 'FORBIDDEN' || code === 'UNAUTHORIZED') {\n throw new Error(`Permission denied: ${message}. Check API key permissions.`);\n }\n \n // Any other GraphQL error\n throw new Error(`Unraid API error: ${message}`);\n}\n\n// Check HTTP-level errors\nif (response.statusCode >= 400) {\n throw new Error(`HTTP ${response.statusCode}: ${response.statusMessage}`);\n}\n\n// Check missing data field\nif (!response.data) {\n throw new Error('GraphQL response missing data field');\n}\n\n// Success\nreturn {\n json: {\n success: true,\n statusCode: 200,\n alreadyInState: false,\n data: response.data\n }\n};\n"
},
"id": "code-graphql-error-handler",
"name": "GraphQL Error Handler",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
600,
2600
]
},
{
"parameters": {
"method": "POST",
"url": "={{ $env.UNRAID_HOST }}/graphql",
"authentication": "none",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "x-api-key",
"value": "={{ $env.UNRAID_API_KEY }}"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={\"query\": \"query { docker { containers { id names state } } }\"}",
"options": {
"timeout": 15000,
"allowUnauthorizedCerts": true,
"response": {
"response": {
"fullResponse": true
}
}
}
},
"id": "http-unraid-api-template",
"name": "Unraid API HTTP Template",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
1000,
2600
],
"onError": "continueRegularOutput"
}
],
"connections": {