Skip to content

Commit 3bf414e

Browse files
authored
feat: add JSON body mode for engine API endpoints (#294)
* feat: restore JSON body config mode without breaking CLI paths Reintroduce application/json config bodies for the engine API while keeping legacy header and NDJSON flows working. Fix OpenAPIHono and the OpenAPI-based CLI generation so typed header schemas, streaming endpoints, and connector loading all continue to work with the new spec. Made-with: Cursor Committed-By-Agent: cursor * refactor: centralize mode-specific pipeline access Move JSON-vs-header requiredness into shared helpers so handlers stop merging two conditional code paths into nullable locals. Made-with: Cursor Committed-By-Agent: cursor * fix: tighten JSON content-type validation Keep JSON validation strict for JSON routes while avoiding false positives for NDJSON and JSON-header alternatives. Made-with: Cursor Committed-By-Agent: cursor * fix: align isJsonBody with middleware content-type logic Export isApplicationJsonContentType from hono-zod-openapi and reuse it in app.ts so handler and middleware agree on what counts as JSON body mode. Fixes mixed-case and json-seq false positives/negatives. Made-with: Cursor Committed-By-Agent: cursor
1 parent 1b35577 commit 3bf414e

11 files changed

Lines changed: 2249 additions & 1205 deletions

File tree

apps/engine/src/__generated__/openapi.d.ts

Lines changed: 184 additions & 142 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

apps/engine/src/__generated__/openapi.json

Lines changed: 1146 additions & 1009 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

apps/engine/src/api/app.test.ts

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -867,6 +867,230 @@ describe('POST /source_discover', () => {
867867
})
868868
})
869869

870+
// ---------------------------------------------------------------------------
871+
// JSON body mode (Content-Type: application/json)
872+
// ---------------------------------------------------------------------------
873+
874+
const syncParamsObj = JSON.parse(syncParams)
875+
876+
describe('JSON body mode', () => {
877+
it('POST /pipeline_check accepts pipeline in JSON body', async () => {
878+
const app = await createApp(resolver)
879+
const res = await app.request('/pipeline_check', {
880+
method: 'POST',
881+
headers: { 'Content-Type': 'application/json' },
882+
body: JSON.stringify({ pipeline: syncParamsObj }),
883+
})
884+
expect(res.status).toBe(200)
885+
expect(res.headers.get('Content-Type')).toBe('application/x-ndjson')
886+
const events = await readNdjson<Record<string, unknown>>(res)
887+
const statuses = events.filter((e) => e.type === 'connection_status')
888+
expect(statuses).toHaveLength(2)
889+
})
890+
891+
it('POST /pipeline_setup accepts pipeline in JSON body', async () => {
892+
const app = await createApp(resolver)
893+
const res = await app.request('/pipeline_setup', {
894+
method: 'POST',
895+
headers: { 'Content-Type': 'application/json' },
896+
body: JSON.stringify({ pipeline: syncParamsObj }),
897+
})
898+
expect(res.status).toBe(200)
899+
expect(res.headers.get('Content-Type')).toBe('application/x-ndjson')
900+
})
901+
902+
it('POST /pipeline_teardown accepts pipeline in JSON body', async () => {
903+
const app = await createApp(resolver)
904+
const res = await app.request('/pipeline_teardown', {
905+
method: 'POST',
906+
headers: { 'Content-Type': 'application/json' },
907+
body: JSON.stringify({ pipeline: syncParamsObj }),
908+
})
909+
expect(res.status).toBe(200)
910+
expect(res.headers.get('Content-Type')).toBe('application/x-ndjson')
911+
})
912+
913+
it('POST /source_discover accepts source in JSON body', async () => {
914+
const app = await createApp(resolver)
915+
const res = await app.request('/source_discover', {
916+
method: 'POST',
917+
headers: { 'Content-Type': 'application/json' },
918+
body: JSON.stringify({
919+
source: { type: 'test', test: { streams: { customers: {} } } },
920+
}),
921+
})
922+
expect(res.status).toBe(200)
923+
const events = await readNdjson<Record<string, unknown>>(res)
924+
expect(events.some((e) => e.type === 'catalog')).toBe(true)
925+
})
926+
927+
it('POST /pipeline_read accepts pipeline + state + body array in JSON body', async () => {
928+
const app = await createApp(resolver)
929+
const res = await app.request('/pipeline_read', {
930+
method: 'POST',
931+
headers: { 'Content-Type': 'application/json' },
932+
body: JSON.stringify({
933+
pipeline: syncParamsObj,
934+
body: [
935+
{
936+
type: 'record',
937+
record: {
938+
stream: 'customers',
939+
data: { id: 'cus_1', name: 'Alice' },
940+
emitted_at: new Date().toISOString(),
941+
},
942+
},
943+
{
944+
type: 'source_state',
945+
source_state: { stream: 'customers', data: { status: 'complete' } },
946+
},
947+
],
948+
}),
949+
})
950+
expect(res.status).toBe(200)
951+
const events = await readNdjson<Message>(res)
952+
expect(events).toHaveLength(3)
953+
expect(events[0]!.type).toBe('record')
954+
expect(events[1]!.type).toBe('source_state')
955+
expect(events[2]).toMatchObject({ type: 'eof', eof: { reason: 'complete' } })
956+
})
957+
958+
it('POST /pipeline_read accepts pipeline in JSON body without input', async () => {
959+
const app = await createApp(resolver)
960+
const res = await app.request('/pipeline_read', {
961+
method: 'POST',
962+
headers: { 'Content-Type': 'application/json' },
963+
body: JSON.stringify({ pipeline: syncParamsObj }),
964+
})
965+
expect(res.status).toBe(200)
966+
const events = await readNdjson<Message>(res)
967+
expect(events.some((e) => e.type === 'eof')).toBe(true)
968+
})
969+
970+
it('POST /pipeline_write accepts pipeline + body array in JSON body', async () => {
971+
const app = await createApp(resolver)
972+
const res = await app.request('/pipeline_write', {
973+
method: 'POST',
974+
headers: { 'Content-Type': 'application/json' },
975+
body: JSON.stringify({
976+
pipeline: syncParamsObj,
977+
body: [
978+
{
979+
type: 'record',
980+
record: {
981+
stream: 'customers',
982+
data: { id: 'cus_1' },
983+
emitted_at: '2024-01-01T00:00:00.000Z',
984+
},
985+
},
986+
{
987+
type: 'source_state',
988+
source_state: { stream: 'customers', data: { cursor: 'cus_1' } },
989+
},
990+
],
991+
}),
992+
})
993+
expect(res.status).toBe(200)
994+
const events = await readNdjson<Record<string, unknown>>(res)
995+
expect(events.some((e) => e.type === 'source_state')).toBe(true)
996+
})
997+
998+
it('POST /pipeline_sync accepts pipeline + state + body array in JSON body', async () => {
999+
const app = await createApp(resolver)
1000+
const res = await app.request('/pipeline_sync', {
1001+
method: 'POST',
1002+
headers: { 'Content-Type': 'application/json' },
1003+
body: JSON.stringify({
1004+
pipeline: syncParamsObj,
1005+
body: [
1006+
{
1007+
type: 'record',
1008+
record: {
1009+
stream: 'customers',
1010+
data: { id: 'cus_1', name: 'Alice' },
1011+
emitted_at: new Date().toISOString(),
1012+
},
1013+
},
1014+
{
1015+
type: 'source_state',
1016+
source_state: { stream: 'customers', data: { status: 'complete' } },
1017+
},
1018+
],
1019+
}),
1020+
})
1021+
expect(res.status).toBe(200)
1022+
const events = await readNdjson<Record<string, unknown>>(res)
1023+
expect(events.some((e) => e.type === 'source_state')).toBe(true)
1024+
expect(events.some((e) => e.type === 'eof')).toBe(true)
1025+
})
1026+
1027+
it('POST /pipeline_sync without body array runs backfill mode', async () => {
1028+
const app = await createApp(resolver)
1029+
const res = await app.request('/pipeline_sync', {
1030+
method: 'POST',
1031+
headers: { 'Content-Type': 'application/json' },
1032+
body: JSON.stringify({ pipeline: syncParamsObj }),
1033+
})
1034+
expect(res.status).toBe(200)
1035+
const events = await readNdjson<Record<string, unknown>>(res)
1036+
expect(events.some((e) => e.type === 'eof')).toBe(true)
1037+
})
1038+
1039+
it('returns 400 when JSON body is missing pipeline', async () => {
1040+
const app = await createApp(resolver)
1041+
const res = await app.request('/pipeline_check', {
1042+
method: 'POST',
1043+
headers: { 'Content-Type': 'application/json' },
1044+
body: JSON.stringify({}),
1045+
})
1046+
expect(res.status).toBe(400)
1047+
})
1048+
1049+
it('NDJSON content-type uses header mode even with JSON-like body', async () => {
1050+
const app = await createApp(resolver)
1051+
const res = await app.request('/pipeline_check', {
1052+
method: 'POST',
1053+
headers: {
1054+
'Content-Type': 'application/x-ndjson',
1055+
'X-Pipeline': syncParams,
1056+
},
1057+
})
1058+
expect(res.status).toBe(200)
1059+
})
1060+
1061+
it('no content-type defaults to header mode', async () => {
1062+
const app = await createApp(resolver)
1063+
const res = await app.request('/pipeline_check', {
1064+
method: 'POST',
1065+
headers: { 'X-Pipeline': syncParams },
1066+
})
1067+
expect(res.status).toBe(200)
1068+
})
1069+
1070+
it('mixed-case Content-Type is accepted as JSON body mode', async () => {
1071+
const app = await createApp(resolver)
1072+
const res = await app.request('/pipeline_check', {
1073+
method: 'POST',
1074+
headers: { 'Content-Type': 'Application/JSON; charset=utf-8' },
1075+
body: JSON.stringify({ pipeline: syncParamsObj }),
1076+
})
1077+
expect(res.status).toBe(200)
1078+
expect(res.headers.get('Content-Type')).toBe('application/x-ndjson')
1079+
})
1080+
1081+
it('application/json-seq falls back to header mode, not JSON body', async () => {
1082+
const app = await createApp(resolver)
1083+
const res = await app.request('/pipeline_check', {
1084+
method: 'POST',
1085+
headers: {
1086+
'Content-Type': 'application/json-seq',
1087+
'X-Pipeline': syncParams,
1088+
},
1089+
})
1090+
expect(res.status).toBe(200)
1091+
})
1092+
})
1093+
8701094
// ---------------------------------------------------------------------------
8711095
// POST /internal/query
8721096
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)