Skip to main content

ig_client/application/
config.rs

1use crate::constants::{
2    DAYS_TO_BACK_LOOK, DEFAULT_API_VERSION, DEFAULT_CONFIG_RATE_LIMIT_BURST_SIZE,
3    DEFAULT_CONFIG_RATE_LIMIT_MAX_REQUESTS, DEFAULT_CONFIG_RATE_LIMIT_PERIOD_SECONDS,
4    DEFAULT_DATABASE_MAX_CONNECTIONS, DEFAULT_DATABASE_URL, DEFAULT_PAGE_SIZE,
5    DEFAULT_REST_BASE_URL, DEFAULT_REST_TIMEOUT_SECS, DEFAULT_SLEEP_TIME,
6    DEFAULT_WS_RECONNECT_INTERVAL_SECS, DEFAULT_WS_URL,
7};
8use crate::utils::config::get_env_or_default;
9use dotenv::dotenv;
10use pretty_simple_display::{DebugPretty, DisplaySimple};
11use serde::{Deserialize, Serialize};
12use std::env;
13use tracing::error;
14use tracing::log::debug;
15
16/// Configuration for database connections
17///
18/// This is a pure configuration DTO with no I/O. It lives in the application
19/// config module (alongside the other `*Config` types) so the application layer
20/// can embed it in [`Config`] without depending on the storage layer. The
21/// storage layer re-exports it (see `storage::config`) and owns the actual pool
22/// construction (`storage::utils::create_connection_pool`).
23#[derive(Serialize, Deserialize, Clone)]
24pub struct DatabaseConfig {
25    /// Database connection URL
26    pub url: String,
27    /// Maximum number of connections in the connection pool
28    pub max_connections: u32,
29}
30
31// The connection `url` commonly embeds a password, so `Debug`/`Display` must
32// never print it — they redact the URL and show only `max_connections`.
33impl std::fmt::Debug for DatabaseConfig {
34    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35        f.debug_struct("DatabaseConfig")
36            .field("url", &"<redacted>")
37            .field("max_connections", &self.max_connections)
38            .finish()
39    }
40}
41
42impl std::fmt::Display for DatabaseConfig {
43    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        write!(
45            f,
46            "DatabaseConfig {{ url: <redacted>, max_connections: {} }}",
47            self.max_connections
48        )
49    }
50}
51
52#[derive(Serialize, Deserialize, Clone)]
53/// Authentication credentials for the IG Markets API
54pub struct Credentials {
55    /// Username for the IG Markets account
56    pub username: String,
57    /// Password for the IG Markets account
58    pub password: String,
59    /// Account ID for the IG Markets account
60    pub account_id: String,
61    /// API key for the IG Markets API
62    pub api_key: String,
63    /// Client token for the IG Markets API
64    pub client_token: Option<String>,
65    /// Account token for the IG Markets API
66    pub account_token: Option<String>,
67}
68
69// `password`, `api_key` and the tokens are secrets, so `Debug`/`Display` must
70// never print them — they show `<redacted>` and leave only `username` /
71// `account_id` (non-sensitive identifiers) visible.
72impl std::fmt::Debug for Credentials {
73    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74        f.debug_struct("Credentials")
75            .field("username", &self.username)
76            .field("password", &"<redacted>")
77            .field("account_id", &self.account_id)
78            .field("api_key", &"<redacted>")
79            .field(
80                "client_token",
81                &self.client_token.as_ref().map(|_| "<redacted>"),
82            )
83            .field(
84                "account_token",
85                &self.account_token.as_ref().map(|_| "<redacted>"),
86            )
87            .finish()
88    }
89}
90
91impl std::fmt::Display for Credentials {
92    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93        write!(
94            f,
95            "Credentials {{ username: {}, account_id: {}, password: <redacted>, \
96             api_key: <redacted>, client_token: {}, account_token: {} }}",
97            self.username,
98            self.account_id,
99            if self.client_token.is_some() {
100                "<redacted>"
101            } else {
102                "None"
103            },
104            if self.account_token.is_some() {
105                "<redacted>"
106            } else {
107                "None"
108            },
109        )
110    }
111}
112
113#[derive(Debug, Serialize, Deserialize, Clone)]
114/// Main configuration for the IG Markets API client.
115///
116/// `Debug` is derived and delegates to each field's `Debug`, so the redacting
117/// `Credentials` and `DatabaseConfig` impls keep secrets out of the output;
118/// `Display` is manual for the same reason.
119pub struct Config {
120    /// Authentication credentials
121    pub credentials: Credentials,
122    /// REST API configuration
123    pub rest_api: RestApiConfig,
124    /// WebSocket API configuration
125    pub websocket: WebSocketConfig,
126    /// Database configuration for data persistence
127    pub database: DatabaseConfig,
128    /// Rate limiter configuration for API requests
129    pub rate_limiter: RateLimiterConfig,
130    /// Number of hours between transaction fetching operations
131    pub sleep_hours: u64,
132    /// Number of items to retrieve per page in API requests
133    pub page_size: u32,
134    /// Number of days to look back when fetching historical data
135    pub days_to_look_back: i64,
136    /// API version to use for authentication: `Some(2)` for CST /
137    /// X-SECURITY-TOKEN, `Some(3)` for OAuth. Both constructors set
138    /// `Some(3)` ([`crate::constants::DEFAULT_API_VERSION`]); an explicit
139    /// `None` makes login fall back to v2.
140    pub api_version: Option<u8>,
141}
142
143// Manual `Display` (replacing the derived, serde-based `DisplaySimple`, which
144// would serialize the whole tree including credential secrets). It delegates to
145// the nested configs' own `Display` impls — `Credentials` and `DatabaseConfig`
146// redact their secrets — and shows only non-sensitive scalars directly.
147impl std::fmt::Display for Config {
148    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
149        write!(
150            f,
151            "Config {{ credentials: {}, rest_api: {}, websocket: {}, database: {}, \
152             rate_limiter: {}, sleep_hours: {}, page_size: {}, days_to_look_back: {}, \
153             api_version: {:?} }}",
154            self.credentials,
155            self.rest_api,
156            self.websocket,
157            self.database,
158            self.rate_limiter,
159            self.sleep_hours,
160            self.page_size,
161            self.days_to_look_back,
162            self.api_version,
163        )
164    }
165}
166
167#[derive(DebugPretty, DisplaySimple, Serialize, Deserialize, Clone)]
168/// Configuration for the REST API
169pub struct RestApiConfig {
170    /// Base URL for the IG Markets REST API
171    pub base_url: String,
172    /// Timeout in seconds for REST API requests
173    pub timeout: u64,
174}
175
176#[derive(DebugPretty, DisplaySimple, Serialize, Deserialize, Clone)]
177/// Configuration for the WebSocket API
178pub struct WebSocketConfig {
179    /// URL for the IG Markets WebSocket API
180    pub url: String,
181    /// Reconnect interval in seconds for WebSocket connections
182    pub reconnect_interval: u64,
183}
184
185#[derive(DebugPretty, DisplaySimple, Serialize, Deserialize, Clone)]
186/// Configuration for rate limiting API requests
187pub struct RateLimiterConfig {
188    /// Maximum number of requests allowed per period
189    pub max_requests: u32,
190    /// Time period in seconds for the rate limit
191    pub period_seconds: u64,
192    /// Burst size - maximum number of requests that can be made at once
193    pub burst_size: u32,
194}
195
196// The `Default` impls below are the single source of truth for the
197// non-credential defaults: they hold the literals and `Config::new()` uses them
198// as its `get_env_or_default` fallbacks. They read no environment variable and
199// load no `.env` file, so they are usable from the injection path
200// ([`Config::from_credentials`] / `Client::with_config`).
201
202impl Default for RestApiConfig {
203    /// IG **demo** REST gateway with a 30 second request timeout.
204    fn default() -> Self {
205        Self {
206            base_url: String::from(DEFAULT_REST_BASE_URL),
207            timeout: DEFAULT_REST_TIMEOUT_SECS,
208        }
209    }
210}
211
212impl Default for WebSocketConfig {
213    /// IG **demo** Lightstreamer endpoint with a 5 second reconnect interval.
214    fn default() -> Self {
215        Self {
216            url: String::from(DEFAULT_WS_URL),
217            reconnect_interval: DEFAULT_WS_RECONNECT_INTERVAL_SECS,
218        }
219    }
220}
221
222impl Default for RateLimiterConfig {
223    /// The crate-wide non-trading budget: 4 requests per 12 seconds, burst 3.
224    fn default() -> Self {
225        Self {
226            max_requests: DEFAULT_CONFIG_RATE_LIMIT_MAX_REQUESTS,
227            period_seconds: DEFAULT_CONFIG_RATE_LIMIT_PERIOD_SECONDS,
228            burst_size: DEFAULT_CONFIG_RATE_LIMIT_BURST_SIZE,
229        }
230    }
231}
232
233impl Default for DatabaseConfig {
234    /// Credential-less placeholder URL — persistence will not connect until the
235    /// caller supplies a real one.
236    fn default() -> Self {
237        Self {
238            url: String::from(DEFAULT_DATABASE_URL),
239            max_connections: DEFAULT_DATABASE_MAX_CONNECTIONS,
240        }
241    }
242}
243
244impl Credentials {
245    /// Builds credentials from the four required fields, leaving both session
246    /// tokens unset.
247    ///
248    /// The tokens (`client_token` / `account_token`) are populated by the
249    /// session layer on login, so callers never provide them.
250    ///
251    /// # Arguments
252    ///
253    /// * `username` - IG account username
254    /// * `password` - IG account password
255    /// * `account_id` - IG account identifier
256    /// * `api_key` - IG API key
257    #[must_use]
258    pub fn new(username: String, password: String, account_id: String, api_key: String) -> Self {
259        Self {
260            username,
261            password,
262            account_id,
263            api_key,
264            client_token: None,
265            account_token: None,
266        }
267    }
268}
269
270impl Default for Config {
271    /// Delegates to [`Config::new`] and therefore loads a `.env` file and reads
272    /// the `IG_*` namespace — unlike the section-level `Default` impls above,
273    /// which are env-free. `Config { .., ..Config::default() }` still touches
274    /// the environment; use [`Config::from_credentials`] as the base value when
275    /// that is not acceptable.
276    fn default() -> Self {
277        Self::new()
278    }
279}
280
281impl Config {
282    /// Creates a configuration from caller-supplied credentials, reading **no**
283    /// environment variable and loading **no** `.env` file.
284    ///
285    /// This is the injection path for embedding applications that own their
286    /// configuration source (their own namespaced env vars, a config file, a
287    /// secrets manager). Pair it with
288    /// [`Client::with_config`](crate::application::client::Client::with_config).
289    /// [`Config::new`] remains the `.env` / `IG_*` convenience path.
290    ///
291    /// Every non-credential field takes its documented default (IG **demo**
292    /// endpoints — see the `Default` impls of [`RestApiConfig`],
293    /// [`WebSocketConfig`], [`RateLimiterConfig`] and [`DatabaseConfig`]).
294    /// Override individual sections with a struct-update expression, which
295    /// stays env-free because the base value is this constructor:
296    ///
297    /// ```rust
298    /// use ig_client::prelude::*;
299    ///
300    /// let credentials = Credentials::new(
301    ///     "user".to_string(),
302    ///     "password".to_string(),
303    ///     "ABC123".to_string(),
304    ///     "api-key".to_string(),
305    /// );
306    /// let config = Config {
307    ///     rest_api: RestApiConfig {
308    ///         base_url: "https://demo-api.ig.com/gateway/deal".to_string(),
309    ///         timeout: 30,
310    ///     },
311    ///     ..Config::from_credentials(credentials)
312    /// };
313    /// assert_eq!(config.rest_api.base_url, "https://demo-api.ig.com/gateway/deal");
314    /// ```
315    ///
316    /// # Arguments
317    ///
318    /// * `credentials` - IG credentials supplied by the caller
319    ///
320    /// # Returns
321    ///
322    /// A `Config` built entirely from `credentials` plus the documented defaults
323    #[must_use]
324    pub fn from_credentials(credentials: Credentials) -> Self {
325        Config {
326            credentials,
327            rest_api: RestApiConfig::default(),
328            websocket: WebSocketConfig::default(),
329            database: DatabaseConfig::default(),
330            rate_limiter: RateLimiterConfig::default(),
331            sleep_hours: DEFAULT_SLEEP_TIME,
332            page_size: DEFAULT_PAGE_SIZE,
333            days_to_look_back: DAYS_TO_BACK_LOOK,
334            api_version: Some(DEFAULT_API_VERSION),
335        }
336    }
337
338    /// Creates a new configuration instance from the environment.
339    ///
340    /// Loads a local `.env` file (via `dotenv`) and reads the `IG_*` /
341    /// `DATABASE_*` / `TX_*` environment variables, falling back to the
342    /// documented defaults for anything unset. Embedders that must not touch
343    /// the `.env` file or the `IG_*` namespace should use
344    /// [`Config::from_credentials`] instead.
345    ///
346    /// # Returns
347    ///
348    /// A new `Config` instance
349    pub fn new() -> Self {
350        // Explicitly load the .env file
351        match dotenv() {
352            Ok(_) => debug!("Successfully loaded .env file"),
353            Err(e) => debug!("Failed to load .env file: {e}"),
354        }
355
356        // Check if environment variables are configured
357        let username = get_env_or_default("IG_USERNAME", String::from("default_username"));
358        let password = get_env_or_default("IG_PASSWORD", String::from("default_password"));
359        let api_key = get_env_or_default("IG_API_KEY", String::from("default_api_key"));
360        let sleep_hours = get_env_or_default("TX_LOOP_INTERVAL_HOURS", DEFAULT_SLEEP_TIME);
361        let page_size = get_env_or_default("TX_PAGE_SIZE", DEFAULT_PAGE_SIZE);
362        let days_to_look_back = get_env_or_default("TX_DAYS_LOOKBACK", DAYS_TO_BACK_LOOK);
363
364        // Defaults come from the `Default` impls so the env path and the
365        // env-free path (`from_credentials`) cannot drift apart.
366        let rest_defaults = RestApiConfig::default();
367        let ws_defaults = WebSocketConfig::default();
368        let rate_limit_defaults = RateLimiterConfig::default();
369        let database_defaults = DatabaseConfig::default();
370
371        let database_url = get_env_or_default("DATABASE_URL", database_defaults.url);
372
373        // Check if we are using default values
374        if username == "default_username" {
375            error!("IG_USERNAME not found in environment variables or .env file");
376        }
377        if password == "default_password" {
378            error!("IG_PASSWORD not found in environment variables or .env file");
379        }
380        if api_key == "default_api_key" {
381            error!("IG_API_KEY not found in environment variables or .env file");
382        }
383        // Check the variable directly rather than comparing the resolved value
384        // to the placeholder: a user may intentionally set a credential-less URL
385        // equal to the placeholder, which is not the "unset" case we warn about.
386        if env::var("DATABASE_URL").is_err() {
387            // Falls back to the credential-less placeholder; persistence will not
388            // connect until DATABASE_URL is set. We never use a credentialed default.
389            error!(
390                "DATABASE_URL not found in environment variables or .env file; \
391                 using a credential-less placeholder and persistence will not connect"
392            );
393        }
394
395        Config {
396            credentials: Credentials {
397                username,
398                password,
399                account_id: get_env_or_default(
400                    "IG_ACCOUNT_ID",
401                    String::from(crate::constants::DEFAULT_ACCOUNT_ID),
402                ),
403                api_key,
404                client_token: None,
405                account_token: None,
406            },
407            rest_api: RestApiConfig {
408                base_url: get_env_or_default("IG_REST_BASE_URL", rest_defaults.base_url),
409                timeout: get_env_or_default("IG_REST_TIMEOUT", rest_defaults.timeout),
410            },
411            websocket: WebSocketConfig {
412                url: get_env_or_default("IG_WS_URL", ws_defaults.url),
413                reconnect_interval: get_env_or_default(
414                    "IG_WS_RECONNECT_INTERVAL",
415                    ws_defaults.reconnect_interval,
416                ),
417            },
418            database: DatabaseConfig {
419                url: database_url,
420                max_connections: get_env_or_default(
421                    "DATABASE_MAX_CONNECTIONS",
422                    database_defaults.max_connections,
423                ),
424            },
425            rate_limiter: RateLimiterConfig {
426                max_requests: get_env_or_default(
427                    "IG_RATE_LIMIT_MAX_REQUESTS",
428                    rate_limit_defaults.max_requests,
429                ),
430                period_seconds: get_env_or_default(
431                    "IG_RATE_LIMIT_PERIOD_SECONDS",
432                    rate_limit_defaults.period_seconds,
433                ),
434                burst_size: get_env_or_default(
435                    "IG_RATE_LIMIT_BURST_SIZE",
436                    rate_limit_defaults.burst_size,
437                ),
438            },
439            sleep_hours,
440            page_size,
441            days_to_look_back,
442            api_version: env::var("IG_API_VERSION")
443                .ok()
444                .and_then(|v| v.parse::<u8>().ok())
445                .filter(|&v| v == 2 || v == 3)
446                .or(Some(DEFAULT_API_VERSION)), // Default to API v3 (OAuth) if not specified
447        }
448    }
449}
450
451#[cfg(test)]
452mod injection_tests {
453    use super::*;
454
455    fn injected_credentials() -> Credentials {
456        Credentials::new(
457            "embedder-user".to_string(),
458            "embedder-password".to_string(),
459            "EMBEDDER-ACC".to_string(),
460            "embedder-api-key".to_string(),
461        )
462    }
463
464    #[test]
465    fn test_credentials_new_leaves_session_tokens_unset() {
466        let credentials = injected_credentials();
467        assert_eq!(credentials.username, "embedder-user");
468        assert_eq!(credentials.password, "embedder-password");
469        assert_eq!(credentials.account_id, "EMBEDDER-ACC");
470        assert_eq!(credentials.api_key, "embedder-api-key");
471        assert!(credentials.client_token.is_none());
472        assert!(credentials.account_token.is_none());
473    }
474
475    #[test]
476    fn test_config_from_credentials_keeps_injected_credentials_and_env_free_defaults() {
477        // No environment is mutated here (`set_var` is `unsafe` on edition 2024
478        // and racy under the parallel harness), so this asserts the contract
479        // rather than proving env-independence by construction: the injected
480        // credentials survive and every other field equals its documented
481        // default. The end-to-end env-independence check lives in
482        // `tests/unit/application/test_client.rs`, where the injected values
483        // cannot coincide with anything the environment holds.
484        let config = Config::from_credentials(injected_credentials());
485
486        assert_eq!(config.credentials.username, "embedder-user");
487        assert_eq!(config.credentials.api_key, "embedder-api-key");
488        assert_eq!(config.rest_api.base_url, DEFAULT_REST_BASE_URL);
489        assert_eq!(config.rest_api.timeout, DEFAULT_REST_TIMEOUT_SECS);
490        assert_eq!(config.websocket.url, DEFAULT_WS_URL);
491        assert_eq!(
492            config.websocket.reconnect_interval,
493            DEFAULT_WS_RECONNECT_INTERVAL_SECS
494        );
495        assert_eq!(config.database.url, DEFAULT_DATABASE_URL);
496        assert_eq!(
497            config.database.max_connections,
498            DEFAULT_DATABASE_MAX_CONNECTIONS
499        );
500        assert_eq!(
501            config.rate_limiter.max_requests,
502            DEFAULT_CONFIG_RATE_LIMIT_MAX_REQUESTS
503        );
504        assert_eq!(
505            config.rate_limiter.period_seconds,
506            DEFAULT_CONFIG_RATE_LIMIT_PERIOD_SECONDS
507        );
508        assert_eq!(
509            config.rate_limiter.burst_size,
510            DEFAULT_CONFIG_RATE_LIMIT_BURST_SIZE
511        );
512        assert_eq!(config.sleep_hours, DEFAULT_SLEEP_TIME);
513        assert_eq!(config.page_size, DEFAULT_PAGE_SIZE);
514        assert_eq!(config.days_to_look_back, DAYS_TO_BACK_LOOK);
515        assert_eq!(config.api_version, Some(DEFAULT_API_VERSION));
516    }
517
518    #[test]
519    fn test_config_from_credentials_struct_update_overrides_section() {
520        // The documented override pattern must not fall back to `Config::new()`
521        // (which would run `dotenv()`); the base value is the env-free ctor.
522        let config = Config {
523            rest_api: RestApiConfig {
524                base_url: "https://demo-api.ig.com/gateway/deal".to_string(),
525                timeout: 7,
526            },
527            ..Config::from_credentials(injected_credentials())
528        };
529
530        assert_eq!(
531            config.rest_api.base_url,
532            "https://demo-api.ig.com/gateway/deal"
533        );
534        assert_eq!(config.rest_api.timeout, 7);
535        // Untouched sections keep their env-free defaults.
536        assert_eq!(config.websocket.url, DEFAULT_WS_URL);
537    }
538
539    #[test]
540    fn test_section_defaults_match_documented_constants() {
541        // Guards the refactor that made `Config::new()` use these `Default`
542        // impls as its env fallbacks: the two paths must not drift apart.
543        let rest = RestApiConfig::default();
544        let ws = WebSocketConfig::default();
545        let rate_limiter = RateLimiterConfig::default();
546        let database = DatabaseConfig::default();
547
548        assert_eq!(rest.base_url, DEFAULT_REST_BASE_URL);
549        assert_eq!(rest.timeout, DEFAULT_REST_TIMEOUT_SECS);
550        assert_eq!(ws.url, DEFAULT_WS_URL);
551        assert_eq!(ws.reconnect_interval, DEFAULT_WS_RECONNECT_INTERVAL_SECS);
552        assert_eq!(
553            rate_limiter.max_requests,
554            DEFAULT_CONFIG_RATE_LIMIT_MAX_REQUESTS
555        );
556        assert_eq!(
557            rate_limiter.period_seconds,
558            DEFAULT_CONFIG_RATE_LIMIT_PERIOD_SECONDS
559        );
560        assert_eq!(
561            rate_limiter.burst_size,
562            DEFAULT_CONFIG_RATE_LIMIT_BURST_SIZE
563        );
564        assert_eq!(database.url, DEFAULT_DATABASE_URL);
565        assert_eq!(database.max_connections, DEFAULT_DATABASE_MAX_CONNECTIONS);
566        // The rate-limiter default is NOT the zero-burst fallback constant.
567        assert_ne!(
568            rate_limiter.burst_size,
569            crate::constants::DEFAULT_RATE_LIMIT_BURST_SIZE
570        );
571    }
572}
573
574#[cfg(test)]
575mod redaction_tests {
576    use super::*;
577
578    fn secret_credentials() -> Credentials {
579        Credentials {
580            username: "user@example.com".to_string(),
581            password: "SUPER-SECRET-PASSWORD".to_string(),
582            account_id: "ACC123".to_string(),
583            api_key: "SECRET-API-KEY".to_string(),
584            client_token: Some("SECRET-CST".to_string()),
585            account_token: Some("SECRET-XST".to_string()),
586        }
587    }
588
589    #[test]
590    fn test_credentials_debug_and_display_redact_secrets() {
591        let creds = secret_credentials();
592        for rendered in [format!("{creds:?}"), format!("{creds}")] {
593            for secret in [
594                "SUPER-SECRET-PASSWORD",
595                "SECRET-API-KEY",
596                "SECRET-CST",
597                "SECRET-XST",
598            ] {
599                assert!(
600                    !rendered.contains(secret),
601                    "credentials rendering leaked {secret}: {rendered}"
602                );
603            }
604            assert!(rendered.contains("<redacted>"));
605            // Non-sensitive identifiers stay visible.
606            assert!(rendered.contains("user@example.com"));
607            assert!(rendered.contains("ACC123"));
608        }
609    }
610
611    #[test]
612    fn test_config_debug_and_display_redact_credential_and_db_secrets() {
613        let config = Config {
614            credentials: secret_credentials(),
615            database: DatabaseConfig {
616                url: "postgres://dbuser:DB-SECRET-PW@host/db".to_string(),
617                max_connections: 5,
618            },
619            ..Config::default()
620        };
621        for rendered in [format!("{config:?}"), format!("{config}")] {
622            for secret in ["SUPER-SECRET-PASSWORD", "SECRET-API-KEY", "DB-SECRET-PW"] {
623                assert!(
624                    !rendered.contains(secret),
625                    "config rendering leaked {secret}: {rendered}"
626                );
627            }
628            assert!(rendered.contains("<redacted>"));
629        }
630    }
631}