1use std::collections::HashSet;
2use std::path::{Path, PathBuf};
3
4use anyhow::Context;
5use serde::Deserialize;
6
7use crate::Result;
8
9fn default_auth_required() -> bool {
10 true
11}
12
13fn default_auth_secret_env() -> String {
14 "API_TEST_AUTH_JSON".to_string()
15}
16
17fn default_auth_token_jq() -> String {
18 ".. | objects | (.accessToken? // .access_token? // .token? // .jwt? // empty) | select(type==\"string\" and length>0) | .".to_string()
19}
20
21fn default_rest_config_dir() -> String {
22 "setup/rest".to_string()
23}
24
25fn default_graphql_config_dir() -> String {
26 "setup/graphql".to_string()
27}
28
29#[derive(Debug, Clone, Deserialize)]
30#[serde(rename_all = "camelCase")]
31pub struct SuiteManifest {
32 pub version: u32,
33 #[serde(default)]
34 pub name: String,
35 #[serde(default)]
36 pub defaults: SuiteDefaults,
37 #[serde(default)]
38 pub auth: Option<SuiteAuth>,
39 pub cases: Vec<SuiteCase>,
40}
41
42#[derive(Debug, Clone, Deserialize, Default)]
43#[serde(rename_all = "camelCase")]
44pub struct SuiteDefaults {
45 #[serde(default)]
46 pub env: String,
47 #[serde(default)]
48 pub no_history: bool,
49 #[serde(default)]
50 pub rest: SuiteDefaultsRest,
51 #[serde(default)]
52 pub graphql: SuiteDefaultsGraphql,
53}
54
55#[derive(Debug, Clone, Deserialize)]
56#[serde(rename_all = "camelCase")]
57pub struct SuiteDefaultsRest {
58 #[serde(default = "default_rest_config_dir")]
59 pub config_dir: String,
60 #[serde(default)]
61 pub url: String,
62 #[serde(default)]
63 pub token: String,
64}
65
66impl Default for SuiteDefaultsRest {
67 fn default() -> Self {
68 Self {
69 config_dir: default_rest_config_dir(),
70 url: String::new(),
71 token: String::new(),
72 }
73 }
74}
75
76#[derive(Debug, Clone, Deserialize)]
77#[serde(rename_all = "camelCase")]
78pub struct SuiteDefaultsGraphql {
79 #[serde(default = "default_graphql_config_dir")]
80 pub config_dir: String,
81 #[serde(default)]
82 pub url: String,
83 #[serde(default)]
84 pub jwt: String,
85}
86
87impl Default for SuiteDefaultsGraphql {
88 fn default() -> Self {
89 Self {
90 config_dir: default_graphql_config_dir(),
91 url: String::new(),
92 jwt: String::new(),
93 }
94 }
95}
96
97#[derive(Debug, Clone, Deserialize)]
98#[serde(rename_all = "camelCase")]
99pub struct SuiteAuth {
100 #[serde(default)]
101 pub provider: String,
102 #[serde(default = "default_auth_required")]
103 pub required: bool,
104 #[serde(default = "default_auth_secret_env")]
105 pub secret_env: String,
106 #[serde(default)]
107 pub rest: Option<SuiteAuthRest>,
108 #[serde(default)]
109 pub graphql: Option<SuiteAuthGraphql>,
110}
111
112#[derive(Debug, Clone, Deserialize)]
113#[serde(rename_all = "camelCase")]
114pub struct SuiteAuthRest {
115 pub login_request_template: String,
116 pub credentials_jq: String,
117 #[serde(default = "default_auth_token_jq")]
118 pub token_jq: String,
119 #[serde(default)]
120 pub config_dir: String,
121 #[serde(default)]
122 pub url: String,
123 #[serde(default)]
124 pub env: String,
125}
126
127#[derive(Debug, Clone, Deserialize)]
128#[serde(rename_all = "camelCase")]
129pub struct SuiteAuthGraphql {
130 pub login_op: String,
131 pub login_vars_template: String,
132 pub credentials_jq: String,
133 #[serde(default = "default_auth_token_jq")]
134 pub token_jq: String,
135 #[serde(default)]
136 pub config_dir: String,
137 #[serde(default)]
138 pub url: String,
139 #[serde(default)]
140 pub env: String,
141}
142
143#[derive(Debug, Clone, Deserialize)]
144#[serde(rename_all = "camelCase")]
145pub struct SuiteCase {
146 pub id: String,
147 #[serde(rename = "type")]
148 pub case_type: String,
149
150 #[serde(default)]
151 pub tags: Vec<String>,
152
153 #[serde(default)]
154 pub env: String,
155
156 #[serde(default)]
157 pub no_history: Option<bool>,
158
159 #[serde(default)]
160 pub allow_write: bool,
161
162 #[serde(default)]
164 pub config_dir: String,
165 #[serde(default)]
166 pub url: String,
167
168 #[serde(default)]
170 pub token: String,
171 #[serde(default)]
172 pub request: String,
173
174 #[serde(default)]
176 pub login_request: String,
177 #[serde(default)]
178 pub token_jq: String,
179
180 #[serde(default)]
182 pub jwt: String,
183 #[serde(default)]
184 pub op: String,
185 #[serde(default)]
186 pub vars: Option<String>,
187 #[serde(default)]
188 pub allow_errors: bool,
189 #[serde(default)]
190 pub expect: Option<SuiteGraphqlExpect>,
191
192 #[serde(default)]
193 pub cleanup: Option<SuiteCleanup>,
194}
195
196#[derive(Debug, Clone, Deserialize)]
197#[serde(rename_all = "camelCase")]
198pub struct SuiteGraphqlExpect {
199 #[serde(default)]
200 pub jq: String,
201}
202
203#[derive(Debug, Clone, Deserialize)]
204#[serde(untagged)]
205pub enum SuiteCleanup {
206 One(Box<SuiteCleanupStep>),
207 Many(Vec<SuiteCleanupStep>),
208}
209
210impl SuiteCleanup {
211 pub fn steps(&self) -> Vec<SuiteCleanupStep> {
212 match self {
213 Self::One(step) => vec![step.as_ref().clone()],
214 Self::Many(steps) => steps.clone(),
215 }
216 }
217}
218
219#[derive(Debug, Clone, Deserialize)]
220#[serde(rename_all = "camelCase")]
221pub struct SuiteCleanupStep {
222 #[serde(rename = "type")]
223 pub step_type: String,
224
225 #[serde(default)]
226 pub config_dir: String,
227 #[serde(default)]
228 pub url: String,
229 #[serde(default)]
230 pub env: String,
231 #[serde(default)]
232 pub no_history: Option<bool>,
233
234 #[serde(default)]
236 pub method: String,
237 #[serde(default)]
238 pub path_template: String,
239 #[serde(default)]
240 pub vars: Option<serde_json::Value>,
241 #[serde(default)]
242 pub token: String,
243 #[serde(default)]
244 pub expect: Option<SuiteCleanupExpect>,
245 #[serde(default)]
246 pub expect_status: Option<u16>,
247 #[serde(default)]
248 pub expect_jq: String,
249
250 #[serde(default)]
252 pub jwt: String,
253 #[serde(default)]
254 pub op: String,
255 #[serde(default)]
256 pub vars_jq: String,
257 #[serde(default)]
258 pub vars_template: String,
259 #[serde(default)]
260 pub allow_errors: bool,
261}
262
263#[derive(Debug, Clone, Deserialize)]
264#[serde(rename_all = "camelCase")]
265pub struct SuiteCleanupExpect {
266 #[serde(default)]
267 pub status: Option<u16>,
268 #[serde(default)]
269 pub jq: String,
270}
271
272fn is_valid_env_var_name(raw: &str) -> bool {
273 let mut chars = raw.chars();
274 let Some(first) = chars.next() else {
275 return false;
276 };
277 if !(first == '_' || first.is_ascii_alphabetic()) {
278 return false;
279 }
280 chars.all(|c| c == '_' || c.is_ascii_alphanumeric())
281}
282
283fn schema_error(
284 path: &str,
285 case_id: Option<&str>,
286 message: impl std::fmt::Display,
287) -> anyhow::Error {
288 match case_id {
289 Some(id) if !id.trim().is_empty() => {
290 anyhow::anyhow!("Suite schema error at {path} (case {id}): {message}")
291 }
292 _ => anyhow::anyhow!("Suite schema error at {path}: {message}"),
293 }
294}
295
296fn canonical_case_type(raw: &str) -> String {
297 raw.trim().to_ascii_lowercase()
298}
299
300pub fn load_suite_manifest(path: impl AsRef<Path>) -> Result<SuiteManifest> {
301 let path = path.as_ref();
302 let bytes =
303 std::fs::read(path).with_context(|| format!("read suite file: {}", path.display()))?;
304
305 let manifest: SuiteManifest = serde_json::from_slice(&bytes)
306 .with_context(|| format!("Suite file is not valid JSON: {}", path.display()))?;
307 Ok(manifest)
308}
309
310pub fn validate_suite_manifest(manifest: &SuiteManifest, suite_path: &Path) -> Result<()> {
311 if manifest.version != 1 {
312 anyhow::bail!(
313 "Unsupported suite version: {} (expected 1): {}",
314 manifest.version,
315 suite_path.display()
316 );
317 }
318
319 if let Some(auth) = &manifest.auth {
320 let secret_env = auth.secret_env.trim();
321 if secret_env.is_empty() {
322 return Err(schema_error("auth.secretEnv", None, "must not be empty"));
323 }
324 if !is_valid_env_var_name(secret_env) {
325 return Err(schema_error(
326 "auth.secretEnv",
327 None,
328 "must be a valid env var name",
329 ));
330 }
331
332 let provider_raw = auth.provider.trim().to_ascii_lowercase();
333 let provider = if provider_raw.is_empty() {
334 match (&auth.rest, &auth.graphql) {
335 (Some(_), None) => "rest".to_string(),
336 (None, Some(_)) => "graphql".to_string(),
337 (Some(_), Some(_)) => {
338 return Err(schema_error(
339 "auth.provider",
340 None,
341 "is required when both auth.rest and auth.graphql are present",
342 ));
343 }
344 (None, None) => {
345 return Err(schema_error(
346 "auth",
347 None,
348 "must include either auth.rest or auth.graphql",
349 ));
350 }
351 }
352 } else if provider_raw == "gql" {
353 "graphql".to_string()
354 } else {
355 provider_raw
356 };
357
358 match provider.as_str() {
359 "rest" => {
360 let Some(rest) = &auth.rest else {
361 return Err(schema_error(
362 "auth.rest",
363 None,
364 "is required for provider=rest",
365 ));
366 };
367 if rest.login_request_template.trim().is_empty() {
368 return Err(schema_error(
369 "auth.rest.loginRequestTemplate",
370 None,
371 "is required",
372 ));
373 }
374 if rest.credentials_jq.trim().is_empty() {
375 return Err(schema_error("auth.rest.credentialsJq", None, "is required"));
376 }
377 }
378 "graphql" => {
379 let Some(graphql) = &auth.graphql else {
380 return Err(schema_error(
381 "auth.graphql",
382 None,
383 "is required for provider=graphql",
384 ));
385 };
386 if graphql.login_op.trim().is_empty() {
387 return Err(schema_error("auth.graphql.loginOp", None, "is required"));
388 }
389 if graphql.login_vars_template.trim().is_empty() {
390 return Err(schema_error(
391 "auth.graphql.loginVarsTemplate",
392 None,
393 "is required",
394 ));
395 }
396 if graphql.credentials_jq.trim().is_empty() {
397 return Err(schema_error(
398 "auth.graphql.credentialsJq",
399 None,
400 "is required",
401 ));
402 }
403 }
404 _ => {
405 return Err(schema_error(
406 "auth.provider",
407 None,
408 "must be one of: rest, graphql",
409 ));
410 }
411 }
412 }
413
414 let mut seen_ids: HashSet<String> = HashSet::new();
415 for (i, c) in manifest.cases.iter().enumerate() {
416 let id = c.id.trim();
417 if id.is_empty() {
418 return Err(schema_error(&format!("cases[{i}].id"), None, "is required"));
419 }
420 if !seen_ids.insert(id.to_string()) {
421 return Err(schema_error(
422 &format!("cases[{i}].id"),
423 Some(id),
424 "must be unique",
425 ));
426 }
427
428 let ty = canonical_case_type(&c.case_type);
429 if ty.is_empty() {
430 return Err(schema_error(
431 &format!("cases[{i}].type"),
432 Some(id),
433 "is required",
434 ));
435 }
436
437 match ty.as_str() {
438 "rest" => {
439 if c.request.trim().is_empty() {
440 return Err(schema_error(
441 &format!("cases[{i}].request"),
442 Some(id),
443 "is required for type=rest",
444 ));
445 }
446 }
447 "rest-flow" | "rest_flow" => {
448 if c.login_request.trim().is_empty() {
449 return Err(schema_error(
450 &format!("cases[{i}].loginRequest"),
451 Some(id),
452 "is required for type=rest-flow",
453 ));
454 }
455 if c.request.trim().is_empty() {
456 return Err(schema_error(
457 &format!("cases[{i}].request"),
458 Some(id),
459 "is required for type=rest-flow",
460 ));
461 }
462 }
463 "graphql" => {
464 if c.op.trim().is_empty() {
465 return Err(schema_error(
466 &format!("cases[{i}].op"),
467 Some(id),
468 "is required for type=graphql",
469 ));
470 }
471
472 if c.allow_errors {
473 let expect_jq = c.expect.as_ref().map(|e| e.jq.trim()).unwrap_or_default();
474 if expect_jq.is_empty() {
475 return Err(schema_error(
476 &format!("cases[{i}].expect.jq"),
477 Some(id),
478 "allowErrors=true requires expect.jq",
479 ));
480 }
481 }
482 }
483 other => {
484 return Err(schema_error(
485 &format!("cases[{i}].type"),
486 Some(id),
487 format!("unknown case type: {other}"),
488 ));
489 }
490 }
491 }
492
493 Ok(())
494}
495
496#[derive(Debug, Clone)]
497pub struct LoadedSuite {
498 pub suite_path: PathBuf,
499 pub manifest: SuiteManifest,
500}
501
502pub fn load_and_validate_suite(path: impl AsRef<Path>) -> Result<LoadedSuite> {
503 let path = path.as_ref();
504 let manifest = load_suite_manifest(path)?;
505 let suite_path = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
506 validate_suite_manifest(&manifest, &suite_path)?;
507 Ok(LoadedSuite {
508 suite_path,
509 manifest,
510 })
511}
512
513#[cfg(test)]
514mod tests {
515 use super::*;
516
517 use tempfile::TempDir;
518
519 fn write_suite(tmp: &TempDir, value: &serde_json::Value) -> PathBuf {
520 let path = tmp.path().join("suite.json");
521 std::fs::write(&path, serde_json::to_vec_pretty(value).unwrap()).unwrap();
522 path
523 }
524
525 fn base_rest_case() -> serde_json::Value {
526 serde_json::json!({
527 "id": "rest.health",
528 "type": "rest",
529 "request": "setup/rest/requests/health.request.json"
530 })
531 }
532
533 fn validate_err(value: serde_json::Value) -> String {
534 let tmp = TempDir::new().unwrap();
535 let path = write_suite(&tmp, &value);
536 let err = load_and_validate_suite(&path).unwrap_err();
537 format!("{err:#}")
538 }
539
540 #[test]
541 fn suite_schema_rejects_unsupported_version() {
542 let err = validate_err(serde_json::json!({
543 "version": 2,
544 "cases": [base_rest_case()]
545 }));
546 assert!(err.contains("Unsupported suite version"));
547 }
548
549 #[test]
550 fn suite_schema_rejects_empty_auth_secret_env() {
551 let err = validate_err(serde_json::json!({
552 "version": 1,
553 "auth": { "secretEnv": " " },
554 "cases": [base_rest_case()]
555 }));
556 assert!(err.contains("auth.secretEnv"));
557 assert!(err.contains("must not be empty"));
558 }
559
560 #[test]
561 fn suite_schema_rejects_invalid_auth_secret_env() {
562 let err = validate_err(serde_json::json!({
563 "version": 1,
564 "auth": { "secretEnv": "123" },
565 "cases": [base_rest_case()]
566 }));
567 assert!(err.contains("auth.secretEnv"));
568 assert!(err.contains("valid env var name"));
569 }
570
571 #[test]
572 fn suite_schema_requires_provider_when_both_auth_blocks_present() {
573 let err = validate_err(serde_json::json!({
574 "version": 1,
575 "auth": {
576 "rest": {
577 "loginRequestTemplate": "setup/rest/requests/login.request.json",
578 "credentialsJq": ".profiles[$profile]"
579 },
580 "graphql": {
581 "loginOp": "setup/graphql/operations/login.graphql",
582 "loginVarsTemplate": "setup/graphql/vars/login.json",
583 "credentialsJq": ".profiles[$profile]"
584 }
585 },
586 "cases": [base_rest_case()]
587 }));
588 assert!(err.contains("auth.provider"));
589 assert!(err.contains("both auth.rest and auth.graphql"));
590 }
591
592 #[test]
593 fn suite_schema_rejects_rest_auth_missing_login_request_template() {
594 let err = validate_err(serde_json::json!({
595 "version": 1,
596 "auth": {
597 "provider": "rest",
598 "rest": {
599 "loginRequestTemplate": " ",
600 "credentialsJq": ".profiles[$profile]"
601 }
602 },
603 "cases": [base_rest_case()]
604 }));
605 assert!(err.contains("auth.rest.loginRequestTemplate"));
606 }
607
608 #[test]
609 fn suite_schema_rejects_rest_auth_missing_credentials_jq() {
610 let err = validate_err(serde_json::json!({
611 "version": 1,
612 "auth": {
613 "provider": "rest",
614 "rest": {
615 "loginRequestTemplate": "setup/rest/requests/login.request.json",
616 "credentialsJq": " "
617 }
618 },
619 "cases": [base_rest_case()]
620 }));
621 assert!(err.contains("auth.rest.credentialsJq"));
622 }
623
624 #[test]
625 fn suite_schema_rejects_graphql_auth_missing_login_op() {
626 let err = validate_err(serde_json::json!({
627 "version": 1,
628 "auth": {
629 "provider": "graphql",
630 "graphql": {
631 "loginOp": " ",
632 "loginVarsTemplate": "setup/graphql/vars/login.json",
633 "credentialsJq": ".profiles[$profile]"
634 }
635 },
636 "cases": [base_rest_case()]
637 }));
638 assert!(err.contains("auth.graphql.loginOp"));
639 }
640
641 #[test]
642 fn suite_schema_rejects_graphql_auth_missing_login_vars_template() {
643 let err = validate_err(serde_json::json!({
644 "version": 1,
645 "auth": {
646 "provider": "graphql",
647 "graphql": {
648 "loginOp": "setup/graphql/operations/login.graphql",
649 "loginVarsTemplate": " ",
650 "credentialsJq": ".profiles[$profile]"
651 }
652 },
653 "cases": [base_rest_case()]
654 }));
655 assert!(err.contains("auth.graphql.loginVarsTemplate"));
656 }
657
658 #[test]
659 fn suite_schema_rejects_graphql_auth_missing_credentials_jq() {
660 let err = validate_err(serde_json::json!({
661 "version": 1,
662 "auth": {
663 "provider": "graphql",
664 "graphql": {
665 "loginOp": "setup/graphql/operations/login.graphql",
666 "loginVarsTemplate": "setup/graphql/vars/login.json",
667 "credentialsJq": " "
668 }
669 },
670 "cases": [base_rest_case()]
671 }));
672 assert!(err.contains("auth.graphql.credentialsJq"));
673 }
674
675 #[test]
676 fn suite_schema_rejects_unknown_auth_provider() {
677 let err = validate_err(serde_json::json!({
678 "version": 1,
679 "auth": { "provider": "soap" },
680 "cases": [base_rest_case()]
681 }));
682 assert!(err.contains("auth.provider"));
683 assert!(err.contains("rest, graphql"));
684 }
685
686 #[test]
687 fn suite_schema_rejects_empty_case_id() {
688 let err = validate_err(serde_json::json!({
689 "version": 1,
690 "cases": [
691 { "id": " ", "type": "rest", "request": "setup/rest/requests/health.request.json" }
692 ]
693 }));
694 assert!(err.contains("cases[0].id"));
695 assert!(err.contains("is required"));
696 }
697
698 #[test]
699 fn suite_schema_rejects_duplicate_case_ids() {
700 let err = validate_err(serde_json::json!({
701 "version": 1,
702 "cases": [
703 { "id": "dup", "type": "rest", "request": "setup/rest/requests/health.request.json" },
704 { "id": "dup", "type": "rest", "request": "setup/rest/requests/health.request.json" }
705 ]
706 }));
707 assert!(err.contains("cases[1].id"));
708 assert!(err.contains("must be unique"));
709 }
710
711 #[test]
712 fn suite_schema_rejects_empty_case_type() {
713 let err = validate_err(serde_json::json!({
714 "version": 1,
715 "cases": [
716 { "id": "x", "type": " ", "request": "setup/rest/requests/health.request.json" }
717 ]
718 }));
719 assert!(err.contains("cases[0].type"));
720 assert!(err.contains("is required"));
721 }
722
723 #[test]
724 fn suite_schema_rejects_rest_case_missing_request() {
725 let err = validate_err(serde_json::json!({
726 "version": 1,
727 "cases": [
728 { "id": "rest.missing", "type": "rest" }
729 ]
730 }));
731 assert!(err.contains("cases[0].request"));
732 assert!(err.contains("type=rest"));
733 }
734
735 #[test]
736 fn suite_schema_rejects_rest_flow_missing_login_request() {
737 let err = validate_err(serde_json::json!({
738 "version": 1,
739 "cases": [
740 { "id": "rest.flow", "type": "rest-flow", "request": "setup/rest/requests/health.request.json" }
741 ]
742 }));
743 assert!(err.contains("cases[0].loginRequest"));
744 assert!(err.contains("type=rest-flow"));
745 }
746
747 #[test]
748 fn suite_schema_rejects_rest_flow_missing_request() {
749 let err = validate_err(serde_json::json!({
750 "version": 1,
751 "cases": [
752 { "id": "rest.flow", "type": "rest-flow", "loginRequest": "setup/rest/requests/login.request.json" }
753 ]
754 }));
755 assert!(err.contains("cases[0].request"));
756 assert!(err.contains("type=rest-flow"));
757 }
758
759 #[test]
760 fn suite_schema_rejects_graphql_case_missing_op() {
761 let err = validate_err(serde_json::json!({
762 "version": 1,
763 "cases": [
764 { "id": "graphql.missing", "type": "graphql" }
765 ]
766 }));
767 assert!(err.contains("cases[0].op"));
768 assert!(err.contains("type=graphql"));
769 }
770
771 #[test]
772 fn suite_cleanup_steps_supports_single_and_many() {
773 let one = SuiteCleanup::One(Box::new(SuiteCleanupStep {
774 step_type: "rest".to_string(),
775 config_dir: String::new(),
776 url: String::new(),
777 env: String::new(),
778 no_history: None,
779 method: "DELETE".to_string(),
780 path_template: "/health".to_string(),
781 vars: None,
782 token: String::new(),
783 expect: None,
784 expect_status: None,
785 expect_jq: String::new(),
786 jwt: String::new(),
787 op: String::new(),
788 vars_jq: String::new(),
789 vars_template: String::new(),
790 allow_errors: false,
791 }));
792 let many = SuiteCleanup::Many(vec![one.steps()[0].clone()]);
793
794 assert_eq!(one.steps().len(), 1);
795 assert_eq!(many.steps().len(), 1);
796 }
797
798 #[test]
799 fn suite_schema_rejects_allow_errors_true_without_expect_jq() {
800 let tmp = TempDir::new().unwrap();
801 let path = write_suite(
802 &tmp,
803 &serde_json::json!({
804 "version": 1,
805 "name": "smoke",
806 "cases": [
807 {
808 "id": "graphql.countries",
809 "type": "graphql",
810 "allowErrors": true,
811 "op": "setup/graphql/ops/countries.graphql"
812 }
813 ]
814 }),
815 );
816
817 let err = load_and_validate_suite(&path).unwrap_err();
818 assert!(format!("{err:#}").contains("graphql.countries"));
819 assert!(format!("{err:#}").contains("allowErrors=true requires expect.jq"));
820 }
821
822 #[test]
823 fn suite_schema_unknown_type_includes_case_id() {
824 let tmp = TempDir::new().unwrap();
825 let path = write_suite(
826 &tmp,
827 &serde_json::json!({
828 "version": 1,
829 "cases": [
830 { "id": "x", "type": "nope" }
831 ]
832 }),
833 );
834 let err = load_and_validate_suite(&path).unwrap_err();
835 assert!(format!("{err:#}").contains("case x"));
836 assert!(format!("{err:#}").contains("unknown case type"));
837 }
838}