Last reviewed: October 29, 2026 (Asia/Shanghai).
Direct answer
Implement CometAPI Fastify input validation before calling the generation adapter. Accept one JSON object containing a bounded prompt string, reject extra properties and wrong types, reject whitespace-only text, and construct the upstream model, messages, and stream fields on the server. Test the failure paths with an injected adapter and assert that its call count remains zero.
There is a configuration trap: Fastify’s validation reference
documents defaults that coerce types, insert defaults, and remove additional properties. With those settings, additionalProperties: false can remove an unwanted model field while allowing the request to proceed. If your route promises rejection, explicitly disable that removal and type coercion.
The CometAPI chat reference
documents POST /v1/chat/completions, bearer authentication, and the required model and messages fields. The browser contract below is deliberately smaller: { "prompt": "Hello" }. The server translates that into a non-streaming chat request rather than forwarding a caller-supplied API payload.
Who this is for
This tutorial is for Node.js developers placing a Fastify route in front of a text-only CometAPI integration. It provides a small runnable application factory, an upstream adapter, and local tests. The examples were checked with Node.js 24.22.1 and Fastify 5.12.7.
The listener binds to loopback. Before making it reachable by users, connect it to your application’s authenticated ingress and per-user request limits. Input validation decides whether a body meets the route contract; authentication decides who may spend through it. Keep the API credential in server runtime configuration.
The separate guide to TypeScript request shapes helps describe upstream payloads in typed code. Runtime validation is still needed for bytes arriving over HTTP.
Key takeaways
- Disable coercion and field removal when invalid requests must be rejected.
- Enforce a body-byte limit as well as a prompt-length limit.
- Keep endpoint, credential, model, and message-role selection on the server.
- Build a new upstream object from validated input rather than spreading the request body.
- Prove that invalid input makes zero calls to the generation dependency.
- Return fixed application errors without copying parser or upstream diagnostics.
Build the input gate
In a new local example directory, install the version used for these checks:
npm install --save-exact [email protected]
Save this as app.mjs. Its injected generateReply(payload) function must return a promise for assistant text and enforce the upstream response contract. Keeping that dependency injectable lets the tests count dispatches without a key or network connection.
import Fastify from 'fastify';
export function buildApp({ model, generateReply }) {
if (typeof model !== 'string' || !model.trim() ||
typeof generateReply !== 'function') {
throw new Error('invalid_server_config');
}
const selectedModel = model.trim();
const app = Fastify({
logger: false,
bodyLimit: 8 * 1024,
ajv: {
customOptions: {
coerceTypes: false,
removeAdditional: false,
useDefaults: false,
allErrors: false
}
}
});
app.setErrorHandler((error, _request, reply) => {
if (error.code === 'FST_ERR_CTP_BODY_TOO_LARGE') {
return reply.code(413).send({ code: 'body_too_large' });
}
if (error.validation || error.statusCode === 400) {
return reply.code(400).send({ code: 'invalid_request' });
}
return reply.code(500).send({ code: 'internal_error' });
});
app.post('/api/chat', {
onRequest: async (request, reply) => {
const type = (request.headers['content-type'] ?? '')
.split(';')[0].trim().toLowerCase();
if (type !== 'application/json') {
return reply.code(415).send({ code: 'json_required' });
}
},
schema: {
body: {
type: 'object',
required: ['prompt'],
additionalProperties: false,
properties: {
prompt: { type: 'string', minLength: 1, maxLength: 2000 }
}
},
response: {
200: {
type: 'object',
required: ['reply'],
additionalProperties: false,
properties: { reply: { type: 'string' } }
}
}
}
}, async (request, reply) => {
const prompt = request.body.prompt.trim();
if (!prompt) {
return reply.code(400).send({ code: 'invalid_request' });
}
try {
const text = await generateReply({
model: selectedModel,
stream: false,
messages: [{ role: 'user', content: prompt }]
});
if (typeof text !== 'string' || !text.trim()) {
throw new Error('invalid_reply');
}
return { reply: text };
} catch {
return reply.code(502).send({ code: 'generation_failed' });
}
});
return app;
}
The factory creates a dedicated Fastify instance. Its Ajv options affect schemas on that instance, including any routes you later add. Review existing route expectations before transferring these settings to a larger application that intentionally coerces query parameters or supplies defaults.
The schema rejects a missing prompt, a non-string prompt, an array in place of the body, and any additional property. The handler then trims the validated string and rejects an empty result. Length validation applies to the submitted string before trimming, so adding padding cannot bring an overlong submission back under the limit.
The media-type gate permits application/json, including parameters such as a charset. It rejects text/plain and vendor JSON media types by policy. This matters because Fastify documents that ordinary body-schema validation is attempted for JSON content; accepting another parser format requires an explicit validation decision. Keep the schema in trusted application code rather than accepting schema definitions from callers.
Fastify’s factory reference
defines bodyLimit in bytes. The example’s 8 KiB ceiling and 2,000-character schema limit are independent application choices. JSON escaping, multibyte text, and whitespace can increase body bytes without increasing the parsed prompt’s length. Neither value represents a CometAPI token or account limit.
Connect the CometAPI adapter
Copy the limited-json.mjs module from the site’s bounded JSON reader tutorial
into the same directory. That helper counts response bytes before decoding and parsing. Save the following adapter as comet-reply.mjs:
import { readJsonWithinLimit } from './limited-json.mjs';
export function createCometReply({ key, fetchImpl = fetch }) {
if (typeof key !== 'string' || !key.trim()) {
throw new Error('missing_server_credential');
}
return async payload => {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 20000);
try {
const response = await fetchImpl(
'https://api.cometapi.com/v1/chat/completions', {
method: 'POST',
redirect: 'error',
signal: controller.signal,
headers: {
authorization: `Bearer ${key}`,
'content-type': 'application/json'
},
body: JSON.stringify(payload)
}
);
const type = (response.headers.get('content-type') ?? '')
.split(';')[0].trim().toLowerCase();
if (type !== 'application/json') {
throw new Error('unexpected_media_type');
}
const { data } = await readJsonWithinLimit(
response, controller, 1024 * 1024
);
if (!response.ok || data?.object !== 'chat.completion' ||
!Array.isArray(data.choices)) {
throw new Error('upstream_failed');
}
const choice = data.choices[0];
if (choice?.finish_reason !== 'stop' ||
choice?.message?.refusal != null ||
typeof choice?.message?.content !== 'string') {
throw new Error('unaccepted_text_result');
}
return choice.message.content;
} finally {
controller.abort();
clearTimeout(timer);
}
};
}
The 20-second deadline and 1 MiB response ceiling are illustrative local settings. The deadline remains active through body consumption. Only an ordinary completed text choice is accepted; a refusal, tool call, truncated completion, or unexpected response shape reaches the route’s generation_failed branch. If the product needs those outcomes, define separate application branches before expanding the adapter.
Save this as server.mjs. Inject COMETAPI_KEY through your secret mechanism and set COMETAPI_MODEL to a currently verified model for this endpoint, then run node server.mjs locally.
import { buildApp } from './app.mjs';
import { createCometReply } from './comet-reply.mjs';
const app = buildApp({
model: process.env.COMETAPI_MODEL,
generateReply: createCometReply({ key: process.env.COMETAPI_KEY })
});
await app.listen({ host: '127.0.0.1', port: 3000 });
The input route never accepts a model or credential from its caller. Model-specific generation options still require verification against the current CometAPI documentation. Add them in trusted server configuration when needed, keeping the public input contract narrow. The example performs one adapter call after validation and contains no automatic retry.
Verify rejection before dispatch
Fastify’s testing guide
documents inject() for fake HTTP requests without starting a network listener. It also explains that injection waits for registered plugins to boot. Save this as app.test.mjs and run node --test app.test.mjs:
import test from 'node:test';
import assert from 'node:assert/strict';
import { buildApp } from './app.mjs';
function setup(t, generateReply) {
const calls = [];
const app = buildApp({
model: 'configured-chat-model',
generateReply: async payload => {
calls.push(payload);
return generateReply ? generateReply(payload) : 'Synthetic reply';
}
});
t.after(() => app.close());
return { app, calls };
}
function send(app, payload, contentType = 'application/json') {
return app.inject({
method: 'POST',
url: '/api/chat',
headers: { 'content-type': contentType },
payload
});
}
test('valid input produces one server-owned request', async t => {
const { app, calls } = setup(t);
const response = await send(app, { prompt: ' Hello ' });
assert.equal(response.statusCode, 200);
assert.deepEqual(response.json(), { reply: 'Synthetic reply' });
assert.deepEqual(calls, [{
model: 'configured-chat-model',
stream: false,
messages: [{ role: 'user', content: 'Hello' }]
}]);
});
test('invalid JSON shapes never call generation', async t => {
const { app, calls } = setup(t);
const fixtures = [
{}, { prompt: '' }, { prompt: ' \t\n' }, { prompt: 42 },
{ prompt: null }, { prompt: ['Hello'] }, ['Hello'],
{ prompt: 'Hello', model: 'caller-model' },
{ prompt: 'Hello', stream: true },
{ prompt: 'Hello', messages: [{ role: 'system', content: 'override' }] },
{ prompt: 'a'.repeat(2001) }
];
for (const payload of fixtures) {
const response = await send(app, payload);
assert.equal(response.statusCode, 400);
assert.deepEqual(response.json(), { code: 'invalid_request' });
}
assert.equal(calls.length, 0);
});
test('parsing, media type, and byte limits precede generation', async t => {
const { app, calls } = setup(t);
const fixtures = [
['{"prompt":', 'application/json', 400, 'invalid_request'],
['{"prompt":"Hello"}', 'text/plain', 415, 'json_required'],
[JSON.stringify({ prompt: 'a'.repeat(9000) }),
'application/json', 413, 'body_too_large'],
[JSON.stringify({ prompt: 'Hello' }) + ' '.repeat(9000),
'application/json', 413, 'body_too_large']
];
for (const [payload, type, status, code] of fixtures) {
const response = await send(app, payload, type);
assert.equal(response.statusCode, status);
assert.deepEqual(response.json(), { code });
}
assert.equal(calls.length, 0);
});
test('the prompt boundary is inclusive', async t => {
const { app, calls } = setup(t);
const response = await send(app, { prompt: 'a'.repeat(2000) });
assert.equal(response.statusCode, 200);
assert.equal(calls.length, 1);
});
test('upstream failures do not leak their messages', async t => {
const { app, calls } = setup(t, async () => {
throw new Error('private upstream diagnostic');
});
const response = await send(app, { prompt: 'Hello' });
assert.equal(response.statusCode, 502);
assert.deepEqual(response.json(), { code: 'generation_failed' });
assert.equal(calls.length, 1);
assert.equal(response.body.includes('private upstream diagnostic'), false);
});
All five test groups passed locally during preparation. Three additional checks passed using synthetic Fetch responses: adapter request construction and text acceptance, rejection of response-contract failures, and the combined route-plus-adapter success path. No authenticated CometAPI request, public deployment, or load test was performed.
The zero-call assertion is the essential failure-path evidence. Checking only for HTTP 400 could miss a route that dispatches generation and then rejects the input. The padded-body fixture tests another useful boundary: its parsed prompt is short, but its serialized body still exceeds the byte ceiling.
Contract details to verify
| Boundary | Required local behavior | Verification |
|---|---|---|
| Submitted JSON | One object with one string property | Reject numbers, arrays, missing values, and extra fields. |
| Prompt length | At most 2,000 schema characters before trimming | Accept the exact boundary and reject one character more. |
| Transport body | At most 8 KiB in this example | Reject oversized serialized JSON, including trailing whitespace. |
| Message authority | Server chooses model and role | Assert the complete adapter payload in the success test. |
| Failure dispatch | Invalid requests never invoke generation | Assert zero calls after each invalid-fixture group. |
| Upstream result | Accepted non-streaming text only | Exercise refusals, empty choices, truncation, HTTP errors, and malformed JSON. |
Keep byte limits, schema string length, model context limits, and token budgets separate. JSON Schema string length does not measure user-perceived grapheme clusters, and serialized request bytes also include syntax and escaping. Choose limits from the application’s input needs and verify representative multilingual fixtures before release.
Failure modes
Extra fields disappear and the request succeeds. The instance still uses removeAdditional: true. Decide whether the contract promises rejection or stripping, configure it explicitly, and keep the extra-model fixture as a regression check.
A numeric prompt becomes text. Type coercion is enabled somewhere in the active validator configuration. Verify the validator used by the actual deployed route, especially after adding a plugin or custom compiler.
A different content type bypasses the intended schema. A parser accepts text or another media type without the corresponding validation contract. Keep the explicit JSON gate, or deliberately add and test every accepted format.
An invalid request already reached generation. A hook or earlier middleware dispatched work before the validated handler. Keep billable dispatch after validation and repeat the zero-call tests against the complete application integration.
Error responses expose input. Returning error.message, validation details, or the raw upstream body can disclose submitted content or internal information. The example returns fixed codes and disables logging; add production diagnostics using fixed outcomes and correlation identifiers, excluding prompts, replies, credentials, and raw errors.
Sources checked
These maintained primary references were fetched successfully on October 28 UTC, October 29 in Asia/Shanghai. They support an evergreen implementation task. The local limits and route design are application recommendations rather than provider defaults.
- Fastify: Validation and Serialization — JSON Schema validation, Ajv defaults and customization, content-type scope, schema ownership, serialization, and validation errors.
- Fastify: Factory
— byte-based
bodyLimit, oversized-body errors, and custom error handling. The reference URL retains theServerpath. - Fastify: Testing — application factories, fake HTTP injection, plugin readiness, assertions, and closing test instances.
- CometAPI: Create a chat completion — endpoint, bearer authentication, model and message fields, non-streaming response shape, and finish reasons.
The Fastify pages identified the current documentation as version 5.12.7 when checked. The CometAPI chat page displayed a last-modified date of October 8, 2026; it was re-fetched for this article rather than treated as a new release announcement.
FAQ
Why use both additionalProperties: false and removeAdditional: false?
The schema states that extra properties are inadmissible. The Ajv option ensures the validator reports them instead of deleting them to make the input pass. Both are needed for this route’s rejection policy.
Does input validation stop prompt injection?
It constrains the request structure and resources. A valid string can still contain adversarial instructions. Keep application instructions, tool authorization, and downstream action checks under separate server control.
Can the client choose from several models?
Add an application-owned option only when the product needs it. Validate it against a trusted allowlist and map it to server configuration. Do not turn this route into an arbitrary upstream-payload proxy.
Should malformed input be retried?
Correct the request first. The example never dispatches invalid input and adds no retry. A generation failure has a different cause and needs a separate bounded retry policy if the application adopts one.
Reader next step
Run the injected tests before configuring a credential. Then connect the adapter in a controlled environment and make one harmless authenticated request to verify the selected model and response envelope. Record the deployed Fastify version, input limits, fixed outcome code, and whether generation was dispatched. Keep the invalid-fixture matrix in the application tests as the route evolves.
When account setup is needed, Start with CometAPI , verify the current model contract, and repeat the same checks through the authenticated application path.