Skip to main content

oversync_client/
types.rs

1//! Consumer-safe wire DTOs shared by the Rust SDK and server-side OpenAPI surface.
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use utoipa::ToSchema;
6
7fn deserialize_optional_json_value<'de, D>(
8	deserializer: D,
9) -> Result<Option<Option<serde_json::Value>>, D::Error>
10where
11	D: serde::Deserializer<'de>,
12{
13	Option::<serde_json::Value>::deserialize(deserializer).map(Some)
14}
15
16fn default_true() -> bool {
17	true
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
21#[serde(rename_all = "snake_case")]
22pub enum ApiMissedTickPolicy {
23	Skip,
24	Burst,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
28pub struct ApiScheduleDef {
29	pub interval_secs: Option<u64>,
30	pub missed_tick_policy: Option<ApiMissedTickPolicy>,
31	pub max_requests_per_minute: Option<u32>,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
35#[serde(rename_all = "snake_case")]
36pub enum ApiDiffMode {
37	Db,
38	Memory,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
42pub struct ApiDeltaDef {
43	pub diff_mode: Option<ApiDiffMode>,
44	pub fail_safe_threshold: Option<f64>,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
48pub struct ApiRetryDef {
49	pub max_retries: Option<u32>,
50	pub retry_base_delay_secs: Option<u64>,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
54#[serde(rename_all = "snake_case")]
55pub enum ApiPipeRecipeType {
56	PostgresMetadata,
57	PostgresSnapshot,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
61pub struct ApiPipeRecipeDef {
62	#[serde(rename = "type")]
63	pub recipe_type: ApiPipeRecipeType,
64	pub prefix: String,
65	#[serde(default)]
66	pub entity_type_id: Option<String>,
67	#[serde(default)]
68	pub schema_id: Option<String>,
69	#[serde(default)]
70	pub schemas: Option<Vec<String>>,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
74#[serde(rename_all = "lowercase")]
75pub enum ApiLinkStrategy {
76	Exact,
77	Normalized,
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
81pub struct ApiLinkDef {
82	pub name: String,
83	pub left_field: String,
84	pub right_field: String,
85	pub strategy: Option<ApiLinkStrategy>,
86	pub target_origin: String,
87	pub target_query: String,
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
91#[serde(tag = "type", rename_all = "snake_case")]
92pub enum ApiAuthConfig {
93	Bearer { token: String },
94	Header { name: String, value: String },
95	Basic { username: String, password: String },
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
99pub struct ApiEmptyOriginConfig {}
100
101#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
102pub struct ApiTrinoExtraCredentials {
103	pub username: String,
104	pub password: String,
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
108#[serde(tag = "type", rename_all = "snake_case")]
109pub enum ApiTrinoAuth {
110	Bearer { token: String },
111	Basic { username: String, password: String },
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
115pub struct ApiTrinoOriginConfig {
116	pub user: Option<String>,
117	pub catalog: Option<String>,
118	pub schema: Option<String>,
119	pub timeout_secs: Option<u64>,
120	pub auth: Option<ApiTrinoAuth>,
121	pub extra_credentials: Option<ApiTrinoExtraCredentials>,
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
125pub struct ApiClickHouseOriginConfig {
126	pub user: Option<String>,
127	pub password: Option<String>,
128	pub database: Option<String>,
129	pub timeout_secs: Option<u64>,
130}
131
132#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
133#[serde(tag = "type", rename_all = "snake_case")]
134pub enum ApiHttpPaginationConfig {
135	Offset {
136		page_size: usize,
137		limit_param: Option<String>,
138		offset_param: Option<String>,
139	},
140	Cursor {
141		page_size: usize,
142		cursor_param: Option<String>,
143		cursor_path: String,
144	},
145}
146
147#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
148pub struct ApiHttpOriginConfig {
149	pub headers: Option<std::collections::HashMap<String, String>>,
150	pub auth: Option<ApiAuthConfig>,
151	pub pagination: Option<ApiHttpPaginationConfig>,
152	pub response_path: Option<String>,
153	pub timeout_secs: Option<u64>,
154}
155
156#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
157pub struct ApiGraphqlPagination {
158	pub cursor_variable: Option<String>,
159	pub has_next_path: Option<String>,
160	pub end_cursor_path: Option<String>,
161}
162
163#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
164pub struct ApiGraphqlOriginConfig {
165	pub headers: Option<std::collections::HashMap<String, String>>,
166	pub auth: Option<ApiAuthConfig>,
167	pub response_path: Option<String>,
168	pub timeout_secs: Option<u64>,
169	pub pagination: Option<ApiGraphqlPagination>,
170}
171
172#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
173pub struct ApiMcpOriginConfig {
174	pub args: Option<Vec<String>>,
175	pub key_field: Option<String>,
176	pub response_path: Option<String>,
177}
178
179#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
180pub struct ApiKafkaOriginConfig {
181	pub brokers: String,
182	pub topic: String,
183	pub group_id: String,
184	pub auto_offset_reset: Option<String>,
185	pub auth: Option<ApiKafkaAuth>,
186}
187
188#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
189pub struct ApiSurrealDbOriginConfig {
190	pub url: String,
191	pub namespace: String,
192	pub database: String,
193	pub username: Option<String>,
194	pub password: Option<String>,
195	pub live: Option<bool>,
196	pub table: Option<String>,
197	pub key_column: Option<String>,
198}
199
200#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
201#[serde(untagged)]
202pub enum ApiOriginConfig {
203	Empty(ApiEmptyOriginConfig),
204	Trino(ApiTrinoOriginConfig),
205	ClickHouse(ApiClickHouseOriginConfig),
206	Http(ApiHttpOriginConfig),
207	Graphql(ApiGraphqlOriginConfig),
208	Mcp(ApiMcpOriginConfig),
209	Kafka(ApiKafkaOriginConfig),
210	SurrealDb(ApiSurrealDbOriginConfig),
211}
212
213#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
214pub struct ApiKafkaAuth {
215	pub security_protocol: Option<String>,
216	pub sasl_mechanism: Option<String>,
217	pub sasl_username: Option<String>,
218	pub sasl_password: Option<String>,
219	pub sasl_kerberos_keytab: Option<String>,
220	pub sasl_kerberos_principal: Option<String>,
221	pub ssl_ca_location: Option<String>,
222	pub ssl_certificate_location: Option<String>,
223	pub ssl_key_location: Option<String>,
224}
225
226#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
227#[serde(rename_all = "snake_case")]
228pub enum ApiKafkaKeyFormat {
229	String,
230	JsonObject,
231}
232
233#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
234#[serde(rename_all = "snake_case")]
235pub enum ApiKafkaValueFormat {
236	Envelope,
237	Compact,
238}
239
240#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
241#[serde(rename_all = "snake_case")]
242pub enum ApiSurrealDbSinkMode {
243	Envelope,
244	Document,
245}
246
247#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
248#[serde(rename_all = "snake_case")]
249pub enum ApiFilterOp {
250	Eq,
251	Ne,
252	Gt,
253	Gte,
254	Lt,
255	Lte,
256	Contains,
257	Exists,
258}
259
260#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
261#[serde(tag = "type", rename_all = "snake_case")]
262pub enum ApiTransformStep {
263	Rename {
264		from: String,
265		to: String,
266	},
267	Set {
268		field: String,
269		value: serde_json::Value,
270	},
271	Upper {
272		field: String,
273	},
274	Lower {
275		field: String,
276	},
277	Remove {
278		field: String,
279	},
280	Copy {
281		from: String,
282		to: String,
283	},
284	Default {
285		field: String,
286		value: serde_json::Value,
287	},
288	Filter {
289		field: String,
290		op: ApiFilterOp,
291		#[serde(default)]
292		value: Option<serde_json::Value>,
293	},
294	MapValue {
295		field: String,
296		mapping: std::collections::HashMap<String, serde_json::Value>,
297	},
298	Truncate {
299		field: String,
300		max_len: usize,
301	},
302	Nest {
303		fields: Vec<String>,
304		into: String,
305	},
306	Flatten {
307		field: String,
308	},
309	Hash {
310		field: String,
311	},
312	Coalesce {
313		fields: Vec<String>,
314		into: String,
315	},
316	SchemaFilter {
317		field: String,
318		#[serde(default)]
319		allow: Option<Vec<String>>,
320		#[serde(default)]
321		deny: Option<Vec<String>>,
322	},
323}
324
325#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
326pub struct ApiStdoutSinkConfig {
327	pub pretty: Option<bool>,
328}
329
330#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
331pub struct ApiKafkaSinkConfig {
332	pub brokers: String,
333	pub topic: String,
334	pub auth: Option<ApiKafkaAuth>,
335	pub key_format: Option<ApiKafkaKeyFormat>,
336	pub key_field: Option<String>,
337	pub value_format: Option<ApiKafkaValueFormat>,
338	pub created_change_type: Option<String>,
339}
340
341#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
342pub struct ApiSurrealDbSinkConfig {
343	pub url: String,
344	pub namespace: String,
345	pub database: String,
346	pub table: String,
347	pub username: Option<String>,
348	pub password: Option<String>,
349	pub mode: Option<ApiSurrealDbSinkMode>,
350	pub key_field: Option<String>,
351}
352
353#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
354pub struct ApiMcpSinkConfig {
355	pub dsn: String,
356	pub args: Option<Vec<String>>,
357	pub tool_name: String,
358}
359
360#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
361pub struct ApiMysqlSinkConfig {
362	pub dsn: String,
363	pub table: String,
364}
365
366#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
367pub struct ApiPostgresSinkConfig {
368	pub dsn: String,
369	pub table: String,
370	pub schema: Option<String>,
371}
372
373#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
374pub struct ApiClickHouseSinkConfig {
375	pub url: String,
376	pub table: String,
377	pub database: Option<String>,
378	pub user: Option<String>,
379	pub password: Option<String>,
380	pub timeout_secs: Option<u64>,
381}
382
383#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
384pub struct ApiHttpSinkConfig {
385	pub url: String,
386	pub method: Option<String>,
387	pub headers: Option<std::collections::HashMap<String, String>>,
388	pub auth: Option<ApiAuthConfig>,
389	pub timeout_secs: Option<u64>,
390	pub retry_count: Option<u32>,
391}
392
393#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
394#[serde(untagged)]
395pub enum ApiSinkConfig {
396	Stdout(ApiStdoutSinkConfig),
397	Kafka(ApiKafkaSinkConfig),
398	SurrealDb(ApiSurrealDbSinkConfig),
399	Mcp(ApiMcpSinkConfig),
400	Mysql(ApiMysqlSinkConfig),
401	Postgres(ApiPostgresSinkConfig),
402	ClickHouse(ApiClickHouseSinkConfig),
403	Http(ApiHttpSinkConfig),
404}
405
406#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
407pub struct HealthResponse {
408	pub status: String,
409	pub version: String,
410}
411
412#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
413pub struct CycleInfo {
414	pub cycle_id: u64,
415	pub source: String,
416	pub query: String,
417	pub status: String,
418	pub started_at: DateTime<Utc>,
419	pub finished_at: Option<DateTime<Utc>>,
420	pub rows_created: u64,
421	pub rows_updated: u64,
422	pub rows_deleted: u64,
423	pub duration_ms: Option<u64>,
424	pub error: Option<String>,
425}
426
427#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
428pub struct SinkInfo {
429	pub name: String,
430	pub sink_type: String,
431	#[serde(skip_serializing_if = "Option::is_none")]
432	#[schema(value_type = Option<ApiSinkConfig>)]
433	pub config: Option<serde_json::Value>,
434}
435
436#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
437pub struct ErrorResponse {
438	pub error: String,
439}
440
441#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
442pub struct SinkListResponse {
443	pub sinks: Vec<SinkInfo>,
444}
445
446// ── Mutation request types ──────────────────────────────────
447
448#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
449pub struct CreateSinkRequest {
450	pub name: String,
451	pub sink_type: String,
452	#[serde(default)]
453	#[schema(value_type = ApiSinkConfig)]
454	pub config: serde_json::Value,
455}
456
457#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
458pub struct UpdateSinkRequest {
459	#[serde(default, skip_serializing_if = "Option::is_none")]
460	pub sink_type: Option<String>,
461	#[serde(default, skip_serializing_if = "Option::is_none")]
462	#[schema(value_type = Option<ApiSinkConfig>)]
463	pub config: Option<serde_json::Value>,
464	#[serde(default, skip_serializing_if = "Option::is_none")]
465	pub enabled: Option<bool>,
466}
467
468#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
469pub struct MutationResponse {
470	pub ok: bool,
471	pub message: String,
472}
473
474#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
475pub struct PipeRunQueryResult {
476	pub query_id: String,
477	pub created: usize,
478	pub updated: usize,
479	pub deleted: usize,
480}
481
482#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
483pub struct PipeRunResponse {
484	pub ok: bool,
485	pub message: String,
486	pub results: Vec<PipeRunQueryResult>,
487}
488
489#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
490pub struct HistoryResponse {
491	pub cycles: Vec<CycleInfo>,
492}
493
494#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
495pub struct StatusResponse {
496	pub running: bool,
497	pub paused: bool,
498}
499
500#[derive(Debug, Clone, Copy, Serialize, Deserialize, ToSchema)]
501#[serde(rename_all = "lowercase")]
502pub enum ExportConfigFormat {
503	Toml,
504	Json,
505}
506
507#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
508pub struct ExportConfigQuery {
509	#[serde(default, skip_serializing_if = "Option::is_none")]
510	pub format: Option<ExportConfigFormat>,
511}
512
513#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
514pub struct ExportConfigResponse {
515	pub format: ExportConfigFormat,
516	pub content: String,
517}
518
519#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
520pub struct ImportConfigRequest {
521	pub format: ExportConfigFormat,
522	pub content: String,
523}
524
525#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
526pub struct ImportConfigResponse {
527	pub ok: bool,
528	pub message: String,
529	pub warnings: Vec<String>,
530}
531
532// ── Pipe types ──────────────────────────────────────────────
533
534#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
535pub struct PipeListResponse {
536	pub pipes: Vec<PipeInfo>,
537}
538
539#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
540pub struct PipePresetListResponse {
541	pub presets: Vec<PipePresetInfo>,
542}
543
544#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
545pub struct PipeInfo {
546	pub name: String,
547	pub origin_connector: String,
548	pub origin_dsn: String,
549	pub targets: Vec<String>,
550	pub interval_secs: u64,
551	pub query_count: usize,
552	#[serde(skip_serializing_if = "Option::is_none")]
553	#[schema(value_type = Option<ApiPipeRecipeDef>)]
554	pub recipe: Option<serde_json::Value>,
555	pub enabled: bool,
556}
557
558#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
559pub struct PipePresetSpecInput {
560	pub origin_connector: String,
561	pub origin_dsn: String,
562	#[serde(default, skip_serializing_if = "Option::is_none")]
563	pub origin_credential: Option<String>,
564	#[serde(default, skip_serializing_if = "Option::is_none")]
565	pub trino_url: Option<String>,
566	#[serde(default)]
567	#[schema(value_type = ApiOriginConfig)]
568	pub origin_config: serde_json::Value,
569	#[serde(default)]
570	pub parameters: Vec<PipePresetParameterInput>,
571	#[serde(default)]
572	pub targets: Vec<String>,
573	#[serde(default)]
574	pub queries: Vec<PipeQueryInput>,
575	#[serde(default)]
576	#[schema(value_type = ApiScheduleDef)]
577	pub schedule: serde_json::Value,
578	#[serde(default)]
579	#[schema(value_type = ApiDeltaDef)]
580	pub delta: serde_json::Value,
581	#[serde(default)]
582	#[schema(value_type = ApiRetryDef)]
583	pub retry: serde_json::Value,
584	#[serde(default)]
585	#[schema(value_type = Option<ApiPipeRecipeDef>)]
586	pub recipe: Option<serde_json::Value>,
587	#[serde(default)]
588	#[schema(value_type = Vec<ApiTransformStep>)]
589	pub filters: Vec<serde_json::Value>,
590	#[serde(default)]
591	#[schema(value_type = Vec<ApiTransformStep>)]
592	pub transforms: Vec<serde_json::Value>,
593	#[serde(default)]
594	#[schema(value_type = Vec<ApiLinkDef>)]
595	pub links: Vec<serde_json::Value>,
596}
597
598#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
599pub struct PipePresetParameterInput {
600	pub name: String,
601	#[serde(default)]
602	pub label: Option<String>,
603	#[serde(default)]
604	pub description: Option<String>,
605	#[serde(default)]
606	pub default: Option<String>,
607	#[serde(default = "default_true")]
608	pub required: bool,
609	#[serde(default)]
610	pub secret: bool,
611}
612
613#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
614pub struct PipePresetInfo {
615	pub name: String,
616	#[serde(skip_serializing_if = "Option::is_none")]
617	pub description: Option<String>,
618	#[schema(value_type = PipePresetSpecInput)]
619	pub spec: serde_json::Value,
620}
621
622#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)]
623pub struct PipeQueryInput {
624	pub id: String,
625	pub sql: String,
626	pub key_column: String,
627	#[serde(default, skip_serializing_if = "Option::is_none")]
628	pub sinks: Option<Vec<String>>,
629	#[serde(default, skip_serializing_if = "Option::is_none")]
630	pub transform: Option<String>,
631}
632
633#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
634pub struct CreatePipeRequest {
635	pub name: String,
636	pub origin_connector: String,
637	pub origin_dsn: String,
638	#[serde(default, skip_serializing_if = "Option::is_none")]
639	pub origin_credential: Option<String>,
640	#[serde(default, skip_serializing_if = "Option::is_none")]
641	pub trino_url: Option<String>,
642	#[serde(default)]
643	#[schema(value_type = ApiOriginConfig)]
644	pub origin_config: serde_json::Value,
645	#[serde(default)]
646	pub targets: Vec<String>,
647	#[serde(default)]
648	#[schema(value_type = ApiScheduleDef)]
649	pub schedule: serde_json::Value,
650	#[serde(default)]
651	#[schema(value_type = ApiDeltaDef)]
652	pub delta: serde_json::Value,
653	#[serde(default)]
654	#[schema(value_type = ApiRetryDef)]
655	pub retry: serde_json::Value,
656	#[serde(default)]
657	#[schema(value_type = Option<ApiPipeRecipeDef>)]
658	pub recipe: Option<serde_json::Value>,
659	#[serde(default)]
660	#[schema(value_type = Vec<ApiTransformStep>)]
661	pub filters: Vec<serde_json::Value>,
662	#[serde(default)]
663	#[schema(value_type = Vec<ApiTransformStep>)]
664	pub transforms: Vec<serde_json::Value>,
665	#[serde(default)]
666	#[schema(value_type = Vec<ApiLinkDef>)]
667	pub links: Vec<serde_json::Value>,
668	#[serde(default)]
669	pub queries: Vec<PipeQueryInput>,
670}
671
672#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
673pub struct UpdatePipeRequest {
674	#[serde(default, skip_serializing_if = "Option::is_none")]
675	pub origin_connector: Option<String>,
676	#[serde(default, skip_serializing_if = "Option::is_none")]
677	pub origin_dsn: Option<String>,
678	#[serde(default, skip_serializing_if = "Option::is_none")]
679	pub origin_credential: Option<String>,
680	#[serde(default, skip_serializing_if = "Option::is_none")]
681	pub trino_url: Option<String>,
682	#[serde(default, skip_serializing_if = "Option::is_none")]
683	#[schema(value_type = Option<ApiOriginConfig>)]
684	pub origin_config: Option<serde_json::Value>,
685	#[serde(default, skip_serializing_if = "Option::is_none")]
686	pub targets: Option<Vec<String>>,
687	#[serde(default, skip_serializing_if = "Option::is_none")]
688	#[schema(value_type = Option<ApiScheduleDef>)]
689	pub schedule: Option<serde_json::Value>,
690	#[serde(default, skip_serializing_if = "Option::is_none")]
691	#[schema(value_type = Option<ApiDeltaDef>)]
692	pub delta: Option<serde_json::Value>,
693	#[serde(default, skip_serializing_if = "Option::is_none")]
694	#[schema(value_type = Option<ApiRetryDef>)]
695	pub retry: Option<serde_json::Value>,
696	#[serde(
697		default,
698		deserialize_with = "deserialize_optional_json_value",
699		skip_serializing_if = "Option::is_none"
700	)]
701	#[schema(value_type = Option<ApiPipeRecipeDef>)]
702	pub recipe: Option<Option<serde_json::Value>>,
703	#[serde(default, skip_serializing_if = "Option::is_none")]
704	#[schema(value_type = Option<Vec<ApiTransformStep>>)]
705	pub filters: Option<Vec<serde_json::Value>>,
706	#[serde(default, skip_serializing_if = "Option::is_none")]
707	#[schema(value_type = Option<Vec<ApiTransformStep>>)]
708	pub transforms: Option<Vec<serde_json::Value>>,
709	#[serde(default, skip_serializing_if = "Option::is_none")]
710	#[schema(value_type = Option<Vec<ApiLinkDef>>)]
711	pub links: Option<Vec<serde_json::Value>>,
712	#[serde(default, skip_serializing_if = "Option::is_none")]
713	pub queries: Option<Vec<PipeQueryInput>>,
714	#[serde(default, skip_serializing_if = "Option::is_none")]
715	pub enabled: Option<bool>,
716}
717
718#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
719pub struct CreatePipePresetRequest {
720	pub name: String,
721	#[serde(default, skip_serializing_if = "Option::is_none")]
722	pub description: Option<String>,
723	pub spec: PipePresetSpecInput,
724}
725
726#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
727pub struct UpdatePipePresetRequest {
728	#[serde(default, skip_serializing_if = "Option::is_none")]
729	pub description: Option<String>,
730	#[serde(default, skip_serializing_if = "Option::is_none")]
731	pub spec: Option<PipePresetSpecInput>,
732}
733
734// ── Credential types ────────────────────────────────────────
735
736#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
737pub struct CredentialListResponse {
738	pub credentials: Vec<CredentialInfo>,
739}
740
741#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
742pub struct CredentialInfo {
743	pub name: String,
744	pub credential_type: String,
745	pub created_at: String,
746}
747
748#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
749pub struct CreateCredentialRequest {
750	pub name: String,
751	pub credential_type: String,
752	pub secret: String,
753}
754
755#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
756pub struct UpdateCredentialRequest {
757	#[serde(default, skip_serializing_if = "Option::is_none")]
758	pub secret: Option<String>,
759	#[serde(default, skip_serializing_if = "Option::is_none")]
760	pub credential_type: Option<String>,
761}
762
763#[cfg(test)]
764mod tests {
765	use super::{HealthResponse, UpdatePipeRequest};
766
767	#[test]
768	fn update_pipe_request_distinguishes_null_recipe_from_omitted() {
769		let cleared: UpdatePipeRequest = serde_json::from_value(serde_json::json!({
770			"recipe": null
771		}))
772		.expect("request with null recipe should deserialize");
773		assert!(matches!(cleared.recipe, Some(None)));
774
775		let set: UpdatePipeRequest = serde_json::from_value(serde_json::json!({
776			"recipe": {"type": "postgres_snapshot", "prefix": "demo"}
777		}))
778		.expect("request with recipe object should deserialize");
779		assert!(matches!(set.recipe, Some(Some(_))));
780
781		let omitted: UpdatePipeRequest = serde_json::from_value(serde_json::json!({}))
782			.expect("empty request should deserialize");
783		assert!(omitted.recipe.is_none());
784	}
785
786	#[test]
787	fn update_pipe_request_omits_unset_fields_when_serializing() {
788		let request = UpdatePipeRequest {
789			origin_connector: None,
790			origin_dsn: None,
791			origin_credential: None,
792			trino_url: None,
793			origin_config: None,
794			targets: None,
795			schedule: None,
796			delta: None,
797			retry: None,
798			recipe: Some(None),
799			filters: None,
800			transforms: None,
801			links: None,
802			queries: None,
803			enabled: Some(true),
804		};
805
806		let json = serde_json::to_value(&request).expect("request should serialize");
807		assert_eq!(json, serde_json::json!({ "recipe": null, "enabled": true }));
808	}
809
810	#[test]
811	fn health_response_round_trips_as_owned_strings() {
812		let response = HealthResponse {
813			status: "ok".into(),
814			version: "0.6.1".into(),
815		};
816
817		let json = serde_json::to_value(&response).expect("response should serialize");
818		let round_trip: HealthResponse =
819			serde_json::from_value(json).expect("response should deserialize");
820		assert_eq!(round_trip.status, "ok");
821		assert_eq!(round_trip.version, "0.6.1");
822	}
823}