Skip to main content

dynoxide/actions/
create_table.rs

1use crate::actions::{TableDescription, build_table_description};
2use crate::errors::{DynoxideError, Result};
3use crate::storage_backend::{BackendError, StorageBackend};
4use crate::streams;
5use crate::types::{
6    AttributeDefinition, GlobalSecondaryIndex, KeySchemaElement, KeyType, LocalSecondaryIndex,
7    Projection, ProjectionType, ProvisionedThroughput,
8};
9use serde::{Deserialize, Serialize};
10use web_time::{SystemTime, UNIX_EPOCH};
11
12/// Internal raw deserialization struct — uses serde_json::Value for fields
13/// that participate in DynamoDB's multi-field constraint validation.
14#[derive(Debug, Default, Deserialize)]
15struct RawRequest {
16    #[serde(rename = "TableName", default)]
17    table_name: Option<String>,
18    #[serde(rename = "KeySchema", default)]
19    key_schema: Option<serde_json::Value>,
20    #[serde(rename = "AttributeDefinitions", default)]
21    attribute_definitions: Option<serde_json::Value>,
22    #[serde(rename = "GlobalSecondaryIndexes", default)]
23    global_secondary_indexes: Option<serde_json::Value>,
24    #[serde(rename = "LocalSecondaryIndexes", default)]
25    local_secondary_indexes: Option<serde_json::Value>,
26    #[serde(rename = "BillingMode", default)]
27    billing_mode: Option<String>,
28    #[serde(rename = "ProvisionedThroughput", default)]
29    provisioned_throughput: Option<serde_json::Value>,
30    #[serde(rename = "StreamSpecification", default)]
31    stream_specification: Option<StreamSpecification>,
32    #[serde(rename = "SSESpecification", default)]
33    sse_specification: Option<crate::types::SseSpecification>,
34    #[serde(rename = "TableClass", default)]
35    table_class: Option<String>,
36    #[serde(rename = "Tags", default)]
37    tags: Option<Vec<crate::types::Tag>>,
38    #[serde(rename = "DeletionProtectionEnabled", default)]
39    deletion_protection_enabled: Option<bool>,
40    #[serde(rename = "OnDemandThroughput", default)]
41    on_demand_throughput: Option<crate::types::OnDemandThroughput>,
42}
43
44/// Public request type — fully validated, typed fields.
45/// Can be constructed directly (programmatic use) or deserialized from JSON.
46#[derive(Debug, Default)]
47pub struct CreateTableRequest {
48    pub table_name: String,
49    pub key_schema: Vec<KeySchemaElement>,
50    pub attribute_definitions: Vec<AttributeDefinition>,
51    pub global_secondary_indexes: Option<Vec<GlobalSecondaryIndex>>,
52    pub local_secondary_indexes: Option<Vec<LocalSecondaryIndex>>,
53    pub billing_mode: Option<String>,
54    pub provisioned_throughput: Option<ProvisionedThroughput>,
55    pub stream_specification: Option<StreamSpecification>,
56    pub sse_specification: Option<crate::types::SseSpecification>,
57    pub table_class: Option<String>,
58    pub tags: Option<Vec<crate::types::Tag>>,
59    pub deletion_protection_enabled: Option<bool>,
60    pub on_demand_throughput: Option<crate::types::OnDemandThroughput>,
61}
62
63/// Custom Deserialize that does loose JSON parsing first, validates, then builds typed fields.
64/// Validation errors use "VALIDATION:" prefix so server.rs converts them to ValidationException.
65impl<'de> serde::Deserialize<'de> for CreateTableRequest {
66    fn deserialize<D: serde::Deserializer<'de>>(
67        deserializer: D,
68    ) -> std::result::Result<Self, D::Error> {
69        let raw = RawRequest::deserialize(deserializer)?;
70        match validate_raw_and_build(raw) {
71            Ok(req) => Ok(req),
72            Err(msg) => Err(serde::de::Error::custom(format!("VALIDATION:{}", msg))),
73        }
74    }
75}
76
77#[derive(Debug, Default, Deserialize)]
78pub struct StreamSpecification {
79    #[serde(rename = "StreamEnabled", alias = "stream_enabled")]
80    pub stream_enabled: bool,
81    #[serde(rename = "StreamViewType", alias = "stream_view_type", default)]
82    pub stream_view_type: Option<String>,
83}
84
85#[derive(Debug, Default, Serialize)]
86pub struct CreateTableResponse {
87    #[serde(rename = "TableDescription")]
88    pub table_description: TableDescription,
89}
90
91pub async fn execute<S: StorageBackend>(
92    storage: &S,
93    mut request: CreateTableRequest,
94) -> Result<CreateTableResponse> {
95    // An OnDemandThroughput object with no members is equivalent to omitting
96    // it: real DynamoDB accepts it at creation and stores nothing (eu-west-2
97    // capture, 2026-07-24), so normalise before any gate looks at it.
98    if request.on_demand_throughput.as_ref().is_some_and(|odt| {
99        odt.max_read_request_units.is_none() && odt.max_write_request_units.is_none()
100    }) {
101        request.on_demand_throughput = None;
102    }
103
104    // Structural validation (runs for both programmatic and JSON paths)
105    validate_typed_request(&request)?;
106
107    if let Some(ref bm) = request.billing_mode {
108        if bm != "PROVISIONED" && bm != "PAY_PER_REQUEST" {
109            return Err(DynoxideError::ValidationException(format!(
110                "1 validation error detected: Value '{bm}' at 'billingMode' failed to satisfy \
111                 constraint: Member must satisfy enum value set: \
112                 [PROVISIONED, PAY_PER_REQUEST]"
113            )));
114        }
115    }
116
117    if let Some(ref tc) = request.table_class {
118        if tc != "STANDARD" && tc != "STANDARD_INFREQUENT_ACCESS" {
119            return Err(DynoxideError::ValidationException(format!(
120                "1 validation error detected: Value '{tc}' at 'tableClass' failed to satisfy \
121                 constraint: Member must satisfy enum value set: \
122                 [STANDARD, STANDARD_INFREQUENT_ACCESS]"
123            )));
124        }
125    }
126
127    // OnDemandThroughput is only valid alongside PAY_PER_REQUEST billing, and
128    // its members must be at least 1 at creation (-1 is an UpdateTable-only
129    // removal marker). Both messages captured from real DynamoDB (eu-west-2,
130    // 2026-07-24); the gate names the first present member, read checked
131    // first, and the create wording drops "the" and carries a full stop.
132    if let Some(ref odt) = request.on_demand_throughput {
133        let members = [
134            ("MaxReadRequestUnits", odt.max_read_request_units),
135            ("MaxWriteRequestUnits", odt.max_write_request_units),
136        ];
137        if let Some((member, _)) = members.iter().find(|(_, v)| v.is_some()) {
138            let billing_mode_str = request.billing_mode.as_deref().unwrap_or("PROVISIONED");
139            if billing_mode_str == "PROVISIONED" {
140                return Err(DynoxideError::ValidationException(format!(
141                    "One or more parameter values were invalid: {member} for \
142                     OnDemandThroughput cannot be specified when table BillingMode \
143                     is PROVISIONED."
144                )));
145            }
146        }
147        for (member, value) in members {
148            if value.is_some_and(|v| v < 1) {
149                return Err(DynoxideError::ValidationException(format!(
150                    "One or more parameter values were invalid: Requested {member} for \
151                     OnDemandThroughput for table is outside of valid range"
152                )));
153            }
154        }
155    }
156
157    if storage.table_exists(&request.table_name).await? {
158        return Err(DynoxideError::ResourceInUseException(format!(
159            "Table already exists: {}",
160            request.table_name
161        )));
162    }
163
164    let now = SystemTime::now()
165        .duration_since(UNIX_EPOCH)
166        .unwrap_or_default()
167        .as_secs() as i64;
168
169    let key_schema_json = serde_json::to_string(&request.key_schema)
170        .map_err(|e| DynoxideError::InternalServerError(e.to_string()))?;
171    let attr_defs_json = serde_json::to_string(&request.attribute_definitions)
172        .map_err(|e| DynoxideError::InternalServerError(e.to_string()))?;
173    let gsi_json = request
174        .global_secondary_indexes
175        .as_ref()
176        .map(serde_json::to_string)
177        .transpose()
178        .map_err(|e| DynoxideError::InternalServerError(e.to_string()))?;
179    let lsi_json = request
180        .local_secondary_indexes
181        .as_ref()
182        .map(serde_json::to_string)
183        .transpose()
184        .map_err(|e| DynoxideError::InternalServerError(e.to_string()))?;
185    let pt_json = request
186        .provisioned_throughput
187        .as_ref()
188        .map(serde_json::to_string)
189        .transpose()
190        .map_err(|e| DynoxideError::InternalServerError(e.to_string()))?;
191    // Normalise the SSE spec so a DescribeTable round-trip matches AWS: when
192    // encryption is enabled without an explicit type/key, AWS reports SSEType=KMS
193    // and a KMS key ARN (the AWS-managed `aws/dynamodb` key). Persisting the
194    // synthesised key id keeps the reported ARN stable across DescribeTable calls.
195    let normalized_sse = request.sse_specification.as_ref().map(|spec| {
196        if spec.enabled == Some(true) {
197            crate::types::SseSpecification {
198                enabled: Some(true),
199                sse_type: spec.sse_type.clone().or_else(|| Some("KMS".to_string())),
200                kms_master_key_id: spec.kms_master_key_id.clone().or_else(|| {
201                    Some(crate::streams::kms_key_arn(
202                        &uuid::Uuid::new_v4().to_string(),
203                    ))
204                }),
205            }
206        } else {
207            spec.clone()
208        }
209    });
210    let sse_json = normalized_sse
211        .as_ref()
212        .map(serde_json::to_string)
213        .transpose()
214        .map_err(|e| DynoxideError::InternalServerError(e.to_string()))?;
215    let on_demand_json = request
216        .on_demand_throughput
217        .as_ref()
218        .map(serde_json::to_string)
219        .transpose()
220        .map_err(|e| DynoxideError::InternalServerError(e.to_string()))?;
221    let deletion_protection = request.deletion_protection_enabled.unwrap_or(false);
222
223    // Refuse capability gaps before creating anything. A refusal after
224    // `insert_table_metadata` would leave the table half-created: the client
225    // sees an error, retries, and finds ResourceInUseException where the real
226    // answer is that the backend cannot honour the request at all.
227    if let Some(ref spec) = request.stream_specification {
228        if spec.stream_enabled && !storage.supports_streams() {
229            return Err(BackendError::Unsupported {
230                capability: "streams",
231            }
232            .into());
233        }
234    }
235    if request.tags.as_ref().is_some_and(|tags| !tags.is_empty()) && !storage.supports_tags() {
236        return Err(BackendError::Unsupported { capability: "tags" }.into());
237    }
238
239    let billing_mode_str = request.billing_mode.as_deref().unwrap_or("PROVISIONED");
240    storage
241        .insert_table_metadata(&crate::storage::CreateTableMetadata {
242            table_name: &request.table_name,
243            key_schema: &key_schema_json,
244            attribute_definitions: &attr_defs_json,
245            gsi_definitions: gsi_json.as_deref(),
246            lsi_definitions: lsi_json.as_deref(),
247            provisioned_throughput: pt_json.as_deref(),
248            created_at: now,
249            sse_specification: sse_json.as_deref(),
250            table_class: request.table_class.as_deref(),
251            deletion_protection_enabled: deletion_protection,
252            billing_mode: Some(billing_mode_str),
253            on_demand_throughput: on_demand_json.as_deref(),
254        })
255        .await?;
256
257    storage.create_data_table(&request.table_name).await?;
258
259    if let Some(ref gsis) = request.global_secondary_indexes {
260        for gsi in gsis {
261            storage
262                .create_gsi_table(&request.table_name, &gsi.index_name)
263                .await?;
264        }
265    }
266
267    if let Some(ref lsis) = request.local_secondary_indexes {
268        for lsi in lsis {
269            storage
270                .create_lsi_table(&request.table_name, &lsi.index_name)
271                .await?;
272        }
273    }
274
275    if let Some(ref spec) = request.stream_specification {
276        if spec.stream_enabled {
277            let view_type = spec
278                .stream_view_type
279                .as_deref()
280                .unwrap_or("NEW_AND_OLD_IMAGES");
281            let label = streams::generate_stream_label(storage.clock());
282            storage
283                .enable_stream(&request.table_name, view_type, &label)
284                .await?;
285        }
286    }
287
288    if let Some(ref tags) = request.tags {
289        if !tags.is_empty() {
290            storage.set_tags(&request.table_name, tags).await?;
291        }
292    }
293
294    let meta = storage
295        .get_table_metadata(&request.table_name)
296        .await?
297        .ok_or_else(|| {
298            DynoxideError::InternalServerError("Table metadata not found after creation".into())
299        })?;
300
301    let mut desc = build_table_description(&meta, Some(0), Some(0));
302    // CreateTable response shows CREATING status (table is usable immediately
303    // but DynamoDB API contract says newly-created tables start as CREATING)
304    desc.table_status = "CREATING".to_string();
305
306    // Override billing mode fields based on the actual request
307    let billing_mode_str = request.billing_mode.as_deref().unwrap_or("PROVISIONED");
308    if billing_mode_str == "PROVISIONED" {
309        desc.billing_mode_summary = None;
310        desc.table_throughput_mode_summary = None;
311    } else if billing_mode_str == "PAY_PER_REQUEST" {
312        desc.billing_mode_summary = Some(crate::actions::BillingModeSummary {
313            billing_mode: "PAY_PER_REQUEST".to_string(),
314            last_update_to_pay_per_request_date_time: None,
315        });
316        desc.table_throughput_mode_summary = Some(crate::actions::TableThroughputModeSummary {
317            table_throughput_mode: "PAY_PER_REQUEST".to_string(),
318            last_update_to_pay_per_request_date_time: None,
319        });
320        // Ensure provisioned throughput shows zeros for PAY_PER_REQUEST
321        desc.provisioned_throughput = Some(crate::actions::TableProvisionedThroughputDescription {
322            read_capacity_units: 0,
323            write_capacity_units: 0,
324            number_of_decreases_today: 0,
325            last_increase_date_time: None,
326            last_decrease_date_time: None,
327        });
328    }
329
330    // Set all GSI statuses to CREATING for newly created tables
331    if let Some(ref mut gsis) = desc.global_secondary_indexes {
332        for gsi in gsis {
333            gsi.index_status = "CREATING".to_string();
334        }
335    }
336
337    // Remove DeletionProtectionEnabled from response if not explicitly set
338    // (DynamoDB doesn't include it in basic CreateTable response)
339    if request.deletion_protection_enabled.is_none() {
340        desc.deletion_protection_enabled = None;
341    }
342
343    Ok(CreateTableResponse {
344        table_description: desc,
345    })
346}
347
348/// Convert a String error to DynoxideError::ValidationException.
349fn ve(msg: String) -> DynoxideError {
350    DynoxideError::ValidationException(msg)
351}
352
353/// Validate a programmatically-constructed request (used when not deserialised from JSON).
354///
355/// The validation order matches DynamoDB's actual behaviour (as verified by the Dynalite
356/// conformance suite):
357///
358/// 1. Table name (missing, length, pattern)
359/// 2. BillingMode + ProvisionedThroughput consistency
360/// 3. ProvisionedThroughput out-of-bounds
361/// 4. Missing ProvisionedThroughput (default PROVISIONED billing)
362/// 5. Key attribute definition checks ("Invalid KeySchema" / detailed missing-attr message)
363/// 6. Key schema structure (duplicate names, wrong types)
364/// 7. Empty LSI/GSI lists
365/// 8. LSI/GSI structural validation (key schema, projections, duplicates, limits)
366/// 9. Cross-index duplicate names
367/// 10. Attribute definition count mismatch
368/// 11. StreamSpecification consistency (a disabled stream must not set a view type)
369fn validate_typed_request(request: &CreateTableRequest) -> Result<()> {
370    if request.table_name.is_empty() {
371        return Err(DynoxideError::ValidationException(
372            "The parameter 'TableName' is required but was not present in the request".to_string(),
373        ));
374    }
375    if request.table_name.len() < 3 || request.table_name.len() > 255 {
376        return Err(DynoxideError::ValidationException(
377            "TableName must be at least 3 characters long and at most 255 characters long"
378                .to_string(),
379        ));
380    }
381
382    // Table name pattern
383    if !request
384        .table_name
385        .chars()
386        .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
387    {
388        return Err(DynoxideError::ValidationException(format!(
389            "1 validation error detected: Value '{}' at 'tableName' failed to satisfy constraint: \
390             Member must satisfy regular expression pattern: [a-zA-Z0-9_.-]+",
391            request.table_name
392        )));
393    }
394
395    // BillingMode + ProvisionedThroughput consistency
396    let billing_mode_str = request.billing_mode.as_deref().unwrap_or("PROVISIONED");
397    if billing_mode_str == "PAY_PER_REQUEST" && request.provisioned_throughput.is_some() {
398        return Err(DynoxideError::ValidationException(
399            "One or more parameter values were invalid: Neither ReadCapacityUnits nor \
400             WriteCapacityUnits can be specified when BillingMode is PAY_PER_REQUEST"
401                .to_string(),
402        ));
403    }
404
405    // ProvisionedThroughput out-of-bounds
406    if let Some(ref pt) = request.provisioned_throughput {
407        const MAX_THROUGHPUT: i64 = 1_000_000_000_000;
408        let rcu = pt.read_capacity_units.unwrap_or(0);
409        let wcu = pt.write_capacity_units.unwrap_or(0);
410        if rcu > MAX_THROUGHPUT {
411            return Err(DynoxideError::ValidationException(format!(
412                "Given value {} for ReadCapacityUnits is out of bounds",
413                rcu
414            )));
415        }
416        if wcu > MAX_THROUGHPUT {
417            return Err(DynoxideError::ValidationException(format!(
418                "Given value {} for WriteCapacityUnits is out of bounds",
419                wcu
420            )));
421        }
422    }
423
424    // Missing ProvisionedThroughput when billing mode is explicitly PROVISIONED.
425    // For the programmatic API, when BillingMode is not specified we default to
426    // PAY_PER_REQUEST for convenience. The HTTP/JSON path (validate_raw_and_build)
427    // applies the stricter DynamoDB default of PROVISIONED.
428    if request.billing_mode.is_some()
429        && billing_mode_str == "PROVISIONED"
430        && request.provisioned_throughput.is_none()
431    {
432        return Err(DynoxideError::ValidationException(
433            "One or more parameter values were invalid: ReadCapacityUnits and \
434             WriteCapacityUnits must both be specified when BillingMode is PROVISIONED"
435                .to_string(),
436        ));
437    }
438
439    // Key attribute definition checks (before key schema structure)
440    validate_key_attrs_in_defs(&request.key_schema, &request.attribute_definitions).map_err(ve)?;
441
442    // Key schema structure
443    validate_key_schema_structure(&request.key_schema).map_err(ve)?;
444
445    // Empty LSI/GSI lists (before structural validation and attr count)
446    if let Some(ref lsis) = request.local_secondary_indexes {
447        if lsis.is_empty() {
448            return Err(ve(
449                "One or more parameter values were invalid: List of LocalSecondaryIndexes is empty"
450                    .to_string(),
451            ));
452        }
453    }
454    if let Some(ref gsis) = request.global_secondary_indexes {
455        if gsis.is_empty() {
456            return Err(ve(
457                "One or more parameter values were invalid: List of GlobalSecondaryIndexes is empty"
458                    .to_string(),
459            ));
460        }
461    }
462
463    // LSI structural validation
464    if let Some(ref lsis) = request.local_secondary_indexes {
465        validate_lsi_list(lsis, &request.key_schema, &request.attribute_definitions).map_err(ve)?;
466    }
467
468    // GSI structural validation
469    if let Some(ref gsis) = request.global_secondary_indexes {
470        let bm = request.billing_mode.as_deref().unwrap_or("PROVISIONED");
471        validate_gsi_list(gsis, &request.attribute_definitions, bm).map_err(ve)?;
472    }
473
474    // Cross-index duplicate names (checked before attr def count)
475    check_cross_index_duplicates(
476        &request.local_secondary_indexes,
477        &request.global_secondary_indexes,
478    )
479    .map_err(ve)?;
480
481    // Attribute definition count (last of the key/index checks)
482    validate_attr_def_count(
483        &request.key_schema,
484        &request.attribute_definitions,
485        &request.local_secondary_indexes,
486        &request.global_secondary_indexes,
487    )
488    .map_err(ve)?;
489
490    // StreamSpecification consistency: a disabled stream must not carry a view
491    // type. Real DynamoDB rejects the combination, because a view type only has
492    // meaning when the stream is enabled.
493    if let Some(ref spec) = request.stream_specification {
494        if !spec.stream_enabled && spec.stream_view_type.is_some() {
495            return Err(DynoxideError::ValidationException(
496                "One or more parameter values were invalid: Table is being created with a stream \
497                 disabled, UpdateViewType should not be specified"
498                    .to_string(),
499            ));
500        }
501    }
502
503    Ok(())
504}
505
506fn check_cross_index_duplicates(
507    lsis: &Option<Vec<LocalSecondaryIndex>>,
508    gsis: &Option<Vec<GlobalSecondaryIndex>>,
509) -> std::result::Result<(), String> {
510    if let (Some(lsis), Some(gsis)) = (lsis, gsis) {
511        let mut all_names = std::collections::HashSet::new();
512        for lsi in lsis {
513            all_names.insert(&lsi.index_name);
514        }
515        for gsi in gsis {
516            if !all_names.insert(&gsi.index_name) {
517                return Err(format!(
518                    "One or more parameter values were invalid: Duplicate index name: {}",
519                    gsi.index_name
520                ));
521            }
522        }
523    }
524    Ok(())
525}
526
527// ---- Raw JSON validation (for deserialization path) ----
528
529fn validate_raw_and_build(raw: RawRequest) -> std::result::Result<CreateTableRequest, String> {
530    // Missing TableName is a different error format from invalid TableName
531    if raw.table_name.is_none() {
532        return Err(
533            "The parameter 'TableName' is required but was not present in the request".to_string(),
534        );
535    }
536
537    // Use the shared constraint error collector for table name validation.
538    // This produces the correct multi-field constraint format for empty,
539    // too-short, too-long, or invalid-pattern table names.
540    let name_errors = crate::validation::table_name_constraint_errors(
541        raw.table_name.as_deref(),
542        crate::validation::TableNameContext::CreateTable,
543    );
544    if !name_errors.is_empty() {
545        let msg = format!(
546            "{} validation error{} detected: {}",
547            name_errors.len(),
548            if name_errors.len() > 1 { "s" } else { "" },
549            name_errors.join("; ")
550        );
551        return Err(msg);
552    }
553    let table_name = raw.table_name.unwrap();
554
555    let mut errors = Vec::new();
556
557    if let Some(ref bm) = raw.billing_mode {
558        if bm != "PROVISIONED" && bm != "PAY_PER_REQUEST" {
559            errors.push(format!(
560                "Value '{}' at 'billingMode' failed to satisfy constraint: \
561                 Member must satisfy enum value set: [PROVISIONED, PAY_PER_REQUEST]",
562                bm
563            ));
564        }
565    }
566
567    collect_pt_errors(&raw.provisioned_throughput, &mut errors);
568    collect_ks_errors(&raw.key_schema, &mut errors);
569    collect_ad_errors(&raw.attribute_definitions, &mut errors);
570    collect_lsi_errors(&raw.local_secondary_indexes, &mut errors);
571    collect_gsi_errors(&raw.global_secondary_indexes, &mut errors);
572
573    // DynamoDB caps multi-field constraint errors at 10
574    errors.truncate(10);
575
576    if !errors.is_empty() {
577        let prefix = format!(
578            "{} validation error{} detected: ",
579            errors.len(),
580            if errors.len() == 1 { "" } else { "s" }
581        );
582        return Err(format!("{}{}", prefix, errors.join("; ")));
583    }
584
585    // BillingMode + ProvisionedThroughput consistency (HTTP path only)
586    let billing_mode_str = raw.billing_mode.as_deref().unwrap_or("PROVISIONED");
587    if billing_mode_str == "PAY_PER_REQUEST" && raw.provisioned_throughput.is_some() {
588        return Err(
589            "One or more parameter values were invalid: Neither ReadCapacityUnits nor \
590             WriteCapacityUnits can be specified when BillingMode is PAY_PER_REQUEST"
591                .to_string(),
592        );
593    }
594
595    // ProvisionedThroughput out-of-bounds (after multi-field but before struct checks)
596    if let Some(ref pt) = raw.provisioned_throughput {
597        if let Some(obj) = pt.as_object() {
598            let rcu = obj
599                .get("ReadCapacityUnits")
600                .and_then(|v| v.as_i64())
601                .unwrap_or(0);
602            let wcu = obj
603                .get("WriteCapacityUnits")
604                .and_then(|v| v.as_i64())
605                .unwrap_or(0);
606            const MAX_THROUGHPUT: i64 = 1_000_000_000_000;
607            if rcu > MAX_THROUGHPUT {
608                return Err(format!(
609                    "Given value {} for ReadCapacityUnits is out of bounds",
610                    rcu
611                ));
612            }
613            if wcu > MAX_THROUGHPUT {
614                return Err(format!(
615                    "Given value {} for WriteCapacityUnits is out of bounds",
616                    wcu
617                ));
618            }
619        }
620    }
621
622    // Missing ProvisionedThroughput when BillingMode is explicitly PROVISIONED.
623    if raw.billing_mode.as_deref() == Some("PROVISIONED") && raw.provisioned_throughput.is_none() {
624        return Err(
625            "One or more parameter values were invalid: ReadCapacityUnits and \
626             WriteCapacityUnits must both be specified when BillingMode is PROVISIONED"
627                .to_string(),
628        );
629    }
630
631    // Parse typed structures
632    let key_schema: Vec<KeySchemaElement> = raw
633        .key_schema
634        .as_ref()
635        .map(|v| serde_json::from_value(v.clone()))
636        .transpose()
637        .map_err(|e| e.to_string())?
638        .unwrap_or_default();
639    let attribute_definitions: Vec<AttributeDefinition> = raw
640        .attribute_definitions
641        .as_ref()
642        .map(|v| serde_json::from_value(v.clone()))
643        .transpose()
644        .map_err(|e| e.to_string())?
645        .unwrap_or_default();
646    let provisioned_throughput: Option<ProvisionedThroughput> = raw
647        .provisioned_throughput
648        .as_ref()
649        .map(|v| serde_json::from_value(v.clone()))
650        .transpose()
651        .map_err(|e| e.to_string())?;
652    let global_secondary_indexes: Option<Vec<GlobalSecondaryIndex>> = raw
653        .global_secondary_indexes
654        .as_ref()
655        .map(|v| serde_json::from_value(v.clone()))
656        .transpose()
657        .map_err(|e| e.to_string())?;
658    let local_secondary_indexes: Option<Vec<LocalSecondaryIndex>> = raw
659        .local_secondary_indexes
660        .as_ref()
661        .map(|v| serde_json::from_value(v.clone()))
662        .transpose()
663        .map_err(|e| e.to_string())?;
664
665    Ok(CreateTableRequest {
666        table_name,
667        key_schema,
668        attribute_definitions,
669        global_secondary_indexes,
670        local_secondary_indexes,
671        billing_mode: raw.billing_mode,
672        provisioned_throughput,
673        stream_specification: raw.stream_specification,
674        sse_specification: raw.sse_specification,
675        table_class: raw.table_class,
676        tags: raw.tags,
677        deletion_protection_enabled: raw.deletion_protection_enabled,
678        on_demand_throughput: raw.on_demand_throughput,
679    })
680}
681
682// ---- Multi-field constraint error collectors ----
683
684fn collect_pt_errors(pt_val: &Option<serde_json::Value>, errors: &mut Vec<String>) {
685    if let Some(v) = pt_val {
686        if let Some(obj) = v.as_object() {
687            let wcu = obj.get("WriteCapacityUnits");
688            let rcu = obj.get("ReadCapacityUnits");
689            if wcu.is_none() || wcu == Some(&serde_json::Value::Null) {
690                errors.push("Value null at 'provisionedThroughput.writeCapacityUnits' failed to satisfy constraint: Member must not be null".to_string());
691            } else if let Some(w) = wcu.and_then(|v| v.as_i64()) {
692                if w < 1 {
693                    errors.push(format!("Value '{}' at 'provisionedThroughput.writeCapacityUnits' failed to satisfy constraint: Member must have value greater than or equal to 1", w));
694                }
695            }
696            if rcu.is_none() || rcu == Some(&serde_json::Value::Null) {
697                errors.push("Value null at 'provisionedThroughput.readCapacityUnits' failed to satisfy constraint: Member must not be null".to_string());
698            } else if let Some(r) = rcu.and_then(|v| v.as_i64()) {
699                if r < 1 {
700                    errors.push(format!("Value '{}' at 'provisionedThroughput.readCapacityUnits' failed to satisfy constraint: Member must have value greater than or equal to 1", r));
701                }
702            }
703        }
704    }
705}
706
707fn collect_ks_errors(ks_val: &Option<serde_json::Value>, errors: &mut Vec<String>) {
708    match ks_val {
709        None => {
710            errors.push(
711                "Value null at 'keySchema' failed to satisfy constraint: Member must not be null"
712                    .to_string(),
713            );
714        }
715        Some(v) => {
716            if let Some(arr) = v.as_array() {
717                if arr.is_empty() {
718                    errors.push("Value '[]' at 'keySchema' failed to satisfy constraint: Member must have length greater than or equal to 1".to_string());
719                } else if arr.len() > 2 {
720                    let dump = render_key_schema_java_toString(arr);
721                    errors.push(format!("Value '{}' at 'keySchema' failed to satisfy constraint: Member must have length less than or equal to 2", dump));
722                }
723                for (i, elem) in arr.iter().enumerate().take(10) {
724                    collect_ks_elem_errors(elem, i + 1, errors);
725                }
726            }
727        }
728    }
729}
730
731fn collect_ks_elem_errors(elem: &serde_json::Value, idx: usize, errors: &mut Vec<String>) {
732    if let Some(obj) = elem.as_object() {
733        if !obj.contains_key("AttributeName")
734            || obj.get("AttributeName") == Some(&serde_json::Value::Null)
735        {
736            errors.push(format!("Value null at 'keySchema.{}.member.attributeName' failed to satisfy constraint: Member must not be null", idx));
737        }
738        let kt = obj.get("KeyType");
739        if kt.is_none() || kt == Some(&serde_json::Value::Null) {
740            errors.push(format!("Value null at 'keySchema.{}.member.keyType' failed to satisfy constraint: Member must not be null", idx));
741        } else if let Some(s) = kt.and_then(|v| v.as_str()) {
742            if s != "HASH" && s != "RANGE" {
743                errors.push(format!("Value '{}' at 'keySchema.{}.member.keyType' failed to satisfy constraint: Member must satisfy enum value set: [HASH, RANGE]", s, idx));
744            }
745        }
746    }
747}
748
749/// Render a KeySchema array the way AWS does in validation messages: Java's
750/// default toString() shape over the SDK's KeySchemaElement model, e.g.
751/// `[KeySchemaElement(attributeName=pk, keyType=HASH), KeySchemaElement(attributeName=sk, keyType=RANGE)]`.
752/// Missing attribute fields render as the empty string, matching what AWS
753/// emits when the upstream object hasn't been populated.
754#[allow(non_snake_case)]
755fn render_key_schema_java_toString(arr: &[serde_json::Value]) -> String {
756    let parts: Vec<String> = arr
757        .iter()
758        .map(|elem| {
759            let an = elem
760                .get("AttributeName")
761                .and_then(|v| v.as_str())
762                .unwrap_or("");
763            let kt = elem.get("KeyType").and_then(|v| v.as_str()).unwrap_or("");
764            format!("KeySchemaElement(attributeName={an}, keyType={kt})")
765        })
766        .collect();
767    format!("[{}]", parts.join(", "))
768}
769
770fn collect_ad_errors(ad_val: &Option<serde_json::Value>, errors: &mut Vec<String>) {
771    match ad_val {
772        None => {
773            errors.push("Value null at 'attributeDefinitions' failed to satisfy constraint: Member must not be null".to_string());
774        }
775        Some(v) => {
776            if let Some(arr) = v.as_array() {
777                for (i, elem) in arr.iter().enumerate() {
778                    if let Some(obj) = elem.as_object() {
779                        if !obj.contains_key("AttributeName")
780                            || obj.get("AttributeName") == Some(&serde_json::Value::Null)
781                        {
782                            errors.push(format!("Value null at 'attributeDefinitions.{}.member.attributeName' failed to satisfy constraint: Member must not be null", i + 1));
783                        }
784                        let at = obj.get("AttributeType");
785                        if at.is_none() || at == Some(&serde_json::Value::Null) {
786                            errors.push(format!("Value null at 'attributeDefinitions.{}.member.attributeType' failed to satisfy constraint: Member must not be null", i + 1));
787                        } else if let Some(s) = at.and_then(|v| v.as_str()) {
788                            if s != "S" && s != "N" && s != "B" {
789                                errors.push(format!("Value '{}' at 'attributeDefinitions.{}.member.attributeType' failed to satisfy constraint: Member must satisfy enum value set: [B, N, S]", s, i + 1));
790                            }
791                        }
792                    }
793                }
794            }
795        }
796    }
797}
798
799fn collect_lsi_errors(lsi_val: &Option<serde_json::Value>, errors: &mut Vec<String>) {
800    if let Some(v) = lsi_val {
801        if let Some(arr) = v.as_array() {
802            for (i, elem) in arr.iter().enumerate().take(10) {
803                if let Some(obj) = elem.as_object() {
804                    // Order: indexName, keySchema, projection
805                    if !obj.contains_key("IndexName")
806                        || obj.get("IndexName") == Some(&serde_json::Value::Null)
807                    {
808                        errors.push(format!("Value null at 'localSecondaryIndexes.{}.member.indexName' failed to satisfy constraint: Member must not be null", i + 1));
809                    } else if let Some(name) = obj.get("IndexName").and_then(|v| v.as_str()) {
810                        collect_idx_name_errors(name, "localSecondaryIndexes", i + 1, errors);
811                    }
812                    if !obj.contains_key("KeySchema")
813                        || obj.get("KeySchema") == Some(&serde_json::Value::Null)
814                    {
815                        errors.push(format!("Value null at 'localSecondaryIndexes.{}.member.keySchema' failed to satisfy constraint: Member must not be null", i + 1));
816                    } else if let Some(ks) = obj.get("KeySchema").and_then(|v| v.as_array()) {
817                        if ks.is_empty() {
818                            errors.push(format!("Value '[]' at 'localSecondaryIndexes.{}.member.keySchema' failed to satisfy constraint: Member must have length greater than or equal to 1", i + 1));
819                        }
820                    }
821                    if !obj.contains_key("Projection")
822                        || obj.get("Projection") == Some(&serde_json::Value::Null)
823                    {
824                        errors.push(format!("Value null at 'localSecondaryIndexes.{}.member.projection' failed to satisfy constraint: Member must not be null", i + 1));
825                    } else if let Some(p) = obj.get("Projection").and_then(|v| v.as_object()) {
826                        collect_proj_errors(p, &format!("localSecondaryIndexes.{}", i + 1), errors);
827                    }
828                }
829            }
830        }
831    }
832}
833
834fn collect_gsi_errors(gsi_val: &Option<serde_json::Value>, errors: &mut Vec<String>) {
835    if let Some(v) = gsi_val {
836        if let Some(arr) = v.as_array() {
837            for (i, elem) in arr.iter().enumerate().take(10) {
838                if let Some(obj) = elem.as_object() {
839                    // Order for GSI: keySchema, projection, indexName
840                    if !obj.contains_key("KeySchema")
841                        || obj.get("KeySchema") == Some(&serde_json::Value::Null)
842                    {
843                        errors.push(format!("Value null at 'globalSecondaryIndexes.{}.member.keySchema' failed to satisfy constraint: Member must not be null", i + 1));
844                    } else if let Some(ks) = obj.get("KeySchema").and_then(|v| v.as_array()) {
845                        if ks.is_empty() {
846                            errors.push(format!("Value '[]' at 'globalSecondaryIndexes.{}.member.keySchema' failed to satisfy constraint: Member must have length greater than or equal to 1", i + 1));
847                        }
848                    }
849                    if !obj.contains_key("Projection")
850                        || obj.get("Projection") == Some(&serde_json::Value::Null)
851                    {
852                        errors.push(format!("Value null at 'globalSecondaryIndexes.{}.member.projection' failed to satisfy constraint: Member must not be null", i + 1));
853                    } else if let Some(p) = obj.get("Projection").and_then(|v| v.as_object()) {
854                        collect_proj_errors(
855                            p,
856                            &format!("globalSecondaryIndexes.{}", i + 1),
857                            errors,
858                        );
859                    }
860                    if !obj.contains_key("IndexName")
861                        || obj.get("IndexName") == Some(&serde_json::Value::Null)
862                    {
863                        errors.push(format!("Value null at 'globalSecondaryIndexes.{}.member.indexName' failed to satisfy constraint: Member must not be null", i + 1));
864                    } else if let Some(name) = obj.get("IndexName").and_then(|v| v.as_str()) {
865                        collect_idx_name_errors(name, "globalSecondaryIndexes", i + 1, errors);
866                    }
867                    // GSI ProvisionedThroughput
868                    if let Some(pt) = obj.get("ProvisionedThroughput").and_then(|v| v.as_object()) {
869                        let wcu = pt.get("WriteCapacityUnits");
870                        let rcu = pt.get("ReadCapacityUnits");
871                        if let Some(w) = wcu.and_then(|v| v.as_i64()) {
872                            if w < 1 {
873                                errors.push(format!("Value '{}' at 'globalSecondaryIndexes.{}.member.provisionedThroughput.writeCapacityUnits' failed to satisfy constraint: Member must have value greater than or equal to 1", w, i + 1));
874                            }
875                        } else if wcu.is_none() || wcu == Some(&serde_json::Value::Null) {
876                            errors.push(format!("Value null at 'globalSecondaryIndexes.{}.member.provisionedThroughput.writeCapacityUnits' failed to satisfy constraint: Member must not be null", i + 1));
877                        }
878                        if let Some(r) = rcu.and_then(|v| v.as_i64()) {
879                            if r < 1 {
880                                errors.push(format!("Value '{}' at 'globalSecondaryIndexes.{}.member.provisionedThroughput.readCapacityUnits' failed to satisfy constraint: Member must have value greater than or equal to 1", r, i + 1));
881                            }
882                        } else if rcu.is_none() || rcu == Some(&serde_json::Value::Null) {
883                            errors.push(format!("Value null at 'globalSecondaryIndexes.{}.member.provisionedThroughput.readCapacityUnits' failed to satisfy constraint: Member must not be null", i + 1));
884                        }
885                    }
886                }
887            }
888        }
889    }
890}
891
892fn collect_idx_name_errors(name: &str, prefix: &str, idx: usize, errors: &mut Vec<String>) {
893    if !name
894        .chars()
895        .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
896    {
897        errors.push(format!("Value '{}' at '{}.{}.member.indexName' failed to satisfy constraint: Member must satisfy regular expression pattern: [a-zA-Z0-9_.-]+", name, prefix, idx));
898    }
899    if name.len() < 3 {
900        errors.push(format!("Value '{}' at '{}.{}.member.indexName' failed to satisfy constraint: Member must have length greater than or equal to 3", name, prefix, idx));
901    }
902    if name.len() > 255 {
903        errors.push(format!("Value '{}' at '{}.{}.member.indexName' failed to satisfy constraint: Member must have length less than or equal to 255", name, prefix, idx));
904    }
905}
906
907fn collect_proj_errors(
908    proj: &serde_json::Map<String, serde_json::Value>,
909    prefix: &str,
910    errors: &mut Vec<String>,
911) {
912    if let Some(pt) = proj.get("ProjectionType") {
913        if let Some(s) = pt.as_str() {
914            if s != "ALL" && s != "KEYS_ONLY" && s != "INCLUDE" {
915                errors.push(format!("Value '{}' at '{}.member.projection.projectionType' failed to satisfy constraint: Member must satisfy enum value set: [ALL, INCLUDE, KEYS_ONLY]", s, prefix));
916            }
917        }
918    }
919    if let Some(nka) = proj.get("NonKeyAttributes") {
920        if let Some(arr) = nka.as_array() {
921            if arr.is_empty() {
922                errors.push(format!("Value '[]' at '{}.member.projection.nonKeyAttributes' failed to satisfy constraint: Member must have length greater than or equal to 1", prefix));
923            }
924        }
925    }
926}
927
928// ---- Structural validation helpers ----
929
930fn validate_key_schema_structure(ks: &[KeySchemaElement]) -> std::result::Result<(), String> {
931    if ks.is_empty() {
932        return Err("1 validation error detected: Value null at 'keySchema' failed to satisfy constraint: Member must have length less than or equal to 2".to_string());
933    }
934    if ks[0].key_type != KeyType::HASH {
935        return Err(
936            "Invalid KeySchema: The first KeySchemaElement is not a HASH key type".to_string(),
937        );
938    }
939    if ks.len() == 2 && ks[0].attribute_name == ks[1].attribute_name {
940        return Err(
941            "Both the Hash Key and the Range Key element in the KeySchema have the same name"
942                .to_string(),
943        );
944    }
945    if ks.len() == 2 && ks[1].key_type != KeyType::RANGE {
946        return Err(
947            "Invalid KeySchema: The second KeySchemaElement is not a RANGE key type".to_string(),
948        );
949    }
950    Ok(())
951}
952
953fn validate_key_attrs_in_defs(
954    ks: &[KeySchemaElement],
955    defs: &[AttributeDefinition],
956) -> std::result::Result<(), String> {
957    // Collect missing key attribute names
958    let missing: Vec<&str> = ks
959        .iter()
960        .filter(|k| !defs.iter().any(|d| d.attribute_name == k.attribute_name))
961        .map(|k| k.attribute_name.as_str())
962        .collect();
963
964    if missing.is_empty() {
965        // Even if no keys are missing, check for structural issues (dup names/types)
966        // which DynamoDB reports as generic "no definition" when defs exist
967        let has_dup_names = ks.len() == 2 && ks[0].attribute_name == ks[1].attribute_name;
968        if has_dup_names {
969            return Err(
970                "Invalid KeySchema: Some index key attribute have no definition".to_string(),
971            );
972        }
973        return Ok(());
974    }
975
976    // Use generic message when:
977    // - defs is empty (fewer defs than unique key attrs)
978    // - key schema has 2 elements (regardless of structural validity)
979    // - key schema has structural issues (dup names, dup types)
980    // Use detailed message only when defs is non-empty AND key schema has 1 element
981    let has_dup_names = ks.len() == 2 && ks[0].attribute_name == ks[1].attribute_name;
982    let has_dup_types = ks.len() == 2 && ks[0].key_type == ks[1].key_type;
983    let use_generic = defs.is_empty() || ks.len() >= 2 || has_dup_names || has_dup_types;
984
985    if use_generic {
986        return Err("Invalid KeySchema: Some index key attribute have no definition".to_string());
987    }
988
989    // Detailed message for single-key schema with non-empty defs
990    let key_names: Vec<&str> = missing.to_vec();
991    let def_names: Vec<&str> = defs.iter().map(|d| d.attribute_name.as_str()).collect();
992    Err(format!(
993        "One or more parameter values were invalid: Some index key attributes are not defined in \
994         AttributeDefinitions. Keys: [{}], AttributeDefinitions: [{}]",
995        key_names.join(", "),
996        def_names.join(", ")
997    ))
998}
999
1000fn validate_attr_def_count(
1001    ks: &[KeySchemaElement],
1002    defs: &[AttributeDefinition],
1003    lsis: &Option<Vec<LocalSecondaryIndex>>,
1004    gsis: &Option<Vec<GlobalSecondaryIndex>>,
1005) -> std::result::Result<(), String> {
1006    let mut all_key_attrs = std::collections::HashSet::new();
1007    for k in ks {
1008        all_key_attrs.insert(k.attribute_name.as_str());
1009    }
1010    if let Some(lsis) = lsis {
1011        for lsi in lsis {
1012            for k in &lsi.key_schema {
1013                all_key_attrs.insert(k.attribute_name.as_str());
1014            }
1015        }
1016    }
1017    if let Some(gsis) = gsis {
1018        for gsi in gsis {
1019            for k in &gsi.key_schema {
1020                all_key_attrs.insert(k.attribute_name.as_str());
1021            }
1022        }
1023    }
1024    if defs.len() != all_key_attrs.len() {
1025        return Err("One or more parameter values were invalid: Number of attributes in KeySchema does not exactly match number of attributes defined in AttributeDefinitions".to_string());
1026    }
1027    Ok(())
1028}
1029
1030fn validate_lsi_list(
1031    lsis: &[LocalSecondaryIndex],
1032    ks: &[KeySchemaElement],
1033    defs: &[AttributeDefinition],
1034) -> std::result::Result<(), String> {
1035    // Empty check is done earlier in validate_typed_request
1036
1037    if !ks.iter().any(|k| k.key_type == KeyType::RANGE) {
1038        return Err("One or more parameter values were invalid: Table KeySchema does not have a range key, which is required when specifying a LocalSecondaryIndex".to_string());
1039    }
1040
1041    // Check missing attribute definitions across all LSI keys
1042    let def_names: Vec<&str> = defs.iter().map(|d| d.attribute_name.as_str()).collect();
1043    let mut missing_keys = Vec::new();
1044    for lsi in lsis {
1045        for k in &lsi.key_schema {
1046            if !def_names.contains(&k.attribute_name.as_str())
1047                && !missing_keys.contains(&k.attribute_name.as_str())
1048            {
1049                missing_keys.push(k.attribute_name.as_str());
1050            }
1051        }
1052    }
1053    if !missing_keys.is_empty() {
1054        let mut all_keys = Vec::new();
1055        for lsi in lsis {
1056            for k in &lsi.key_schema {
1057                if !all_keys.contains(&k.attribute_name.as_str()) {
1058                    all_keys.push(k.attribute_name.as_str());
1059                }
1060            }
1061        }
1062        return Err(format!(
1063            "One or more parameter values were invalid: Some index key attributes are not defined in AttributeDefinitions. Keys: [{}], AttributeDefinitions: [{}]",
1064            all_keys.join(", "),
1065            def_names.join(", ")
1066        ));
1067    }
1068
1069    // Structural validation for each LSI
1070    for lsi in lsis {
1071        validate_lsi_structure(lsi, ks)?;
1072    }
1073
1074    // Duplicate index names
1075    let mut seen = std::collections::HashSet::new();
1076    for lsi in lsis {
1077        if !seen.insert(&lsi.index_name) {
1078            return Err(format!(
1079                "One or more parameter values were invalid: Duplicate index name: {}",
1080                lsi.index_name
1081            ));
1082        }
1083    }
1084
1085    // Count limit
1086    if lsis.len() > 5 {
1087        return Err("One or more parameter values were invalid: Number of LocalSecondaryIndexes exceeds per-table limit of 5".to_string());
1088    }
1089
1090    Ok(())
1091}
1092
1093fn validate_gsi_list(
1094    gsis: &[GlobalSecondaryIndex],
1095    defs: &[AttributeDefinition],
1096    bm: &str,
1097) -> std::result::Result<(), String> {
1098    // Empty check is done earlier in validate_typed_request
1099
1100    // Check missing attribute definitions across all GSI keys
1101    let def_names: Vec<&str> = defs.iter().map(|d| d.attribute_name.as_str()).collect();
1102    let mut missing_keys = Vec::new();
1103    for gsi in gsis {
1104        for k in &gsi.key_schema {
1105            if !def_names.contains(&k.attribute_name.as_str())
1106                && !missing_keys.contains(&k.attribute_name.as_str())
1107            {
1108                missing_keys.push(k.attribute_name.as_str());
1109            }
1110        }
1111    }
1112    if !missing_keys.is_empty() {
1113        let mut all_keys = Vec::new();
1114        for gsi in gsis {
1115            for k in &gsi.key_schema {
1116                if !all_keys.contains(&k.attribute_name.as_str()) {
1117                    all_keys.push(k.attribute_name.as_str());
1118                }
1119            }
1120        }
1121        return Err(format!(
1122            "One or more parameter values were invalid: Some index key attributes are not defined in AttributeDefinitions. Keys: [{}], AttributeDefinitions: [{}]",
1123            all_keys.join(", "),
1124            def_names.join(", ")
1125        ));
1126    }
1127
1128    // Structural validation for each GSI
1129    for gsi in gsis {
1130        validate_gsi_structure(gsi)?;
1131    }
1132
1133    // Duplicate index names
1134    let mut seen = std::collections::HashSet::new();
1135    for gsi in gsis {
1136        if !seen.insert(&gsi.index_name) {
1137            return Err(format!(
1138                "One or more parameter values were invalid: Duplicate index name: {}",
1139                gsi.index_name
1140            ));
1141        }
1142    }
1143
1144    // Count limit
1145    if gsis.len() > 20 {
1146        return Err("One or more parameter values were invalid: GlobalSecondaryIndex count exceeds the per-table limit of 20".to_string());
1147    }
1148
1149    // PAY_PER_REQUEST billing mode check
1150    if bm == "PAY_PER_REQUEST" {
1151        for gsi in gsis {
1152            if gsi.provisioned_throughput.is_some() {
1153                return Err(format!(
1154                    "One or more parameter values were invalid: ProvisionedThroughput should not be specified for index: {} when BillingMode is PAY_PER_REQUEST",
1155                    gsi.index_name
1156                ));
1157            }
1158        }
1159    }
1160
1161    Ok(())
1162}
1163
1164fn validate_lsi_structure(
1165    lsi: &LocalSecondaryIndex,
1166    table_ks: &[KeySchemaElement],
1167) -> std::result::Result<(), String> {
1168    // Key schema structure first
1169    validate_key_schema_structure(&lsi.key_schema)?;
1170
1171    // Range key presence (before projection, per DynamoDB ordering)
1172    let lsi_sk = lsi.key_schema.iter().find(|k| k.key_type == KeyType::RANGE);
1173    if lsi_sk.is_none() {
1174        return Err(format!(
1175            "One or more parameter values were invalid: Index KeySchema does not have a range key for index: {}",
1176            lsi.index_name
1177        ));
1178    }
1179
1180    // Hash key must match table hash key (before projection)
1181    let table_pk = table_ks
1182        .iter()
1183        .find(|k| k.key_type == KeyType::HASH)
1184        .map(|k| k.attribute_name.as_str());
1185    let lsi_pk = lsi
1186        .key_schema
1187        .iter()
1188        .find(|k| k.key_type == KeyType::HASH)
1189        .map(|k| k.attribute_name.as_str());
1190    if lsi_pk != table_pk {
1191        return Err(format!(
1192            "One or more parameter values were invalid: \
1193             Index KeySchema does not have the same leading hash key as table KeySchema \
1194             for index: {}. index hash key: {}, table hash key: {}",
1195            lsi.index_name,
1196            lsi_pk.unwrap_or("null"),
1197            table_pk.unwrap_or("null")
1198        ));
1199    }
1200
1201    // Projection (after range key and hash key checks)
1202    validate_proj_structure(&lsi.projection)?;
1203
1204    Ok(())
1205}
1206
1207fn validate_gsi_structure(gsi: &GlobalSecondaryIndex) -> std::result::Result<(), String> {
1208    validate_key_schema_structure(&gsi.key_schema)?;
1209    validate_proj_structure(&gsi.projection)?;
1210    Ok(())
1211}
1212
1213fn validate_proj_structure(p: &Projection) -> std::result::Result<(), String> {
1214    // Matched on the projection type regardless of whether NonKeyAttributes is
1215    // present, so the INCLUDE-without-list case (DynamoDB rejects it) is
1216    // reachable. ALL and KEYS_ONLY must not carry a list; INCLUDE requires a
1217    // non-empty one. Reached by both GSI and LSI validation.
1218    match &p.projection_type {
1219        None => Err(
1220            "One or more parameter values were invalid: Unknown ProjectionType: null".to_string(),
1221        ),
1222        Some(ProjectionType::ALL) => match &p.non_key_attributes {
1223            Some(_) => Err("One or more parameter values were invalid: ProjectionType is ALL, but NonKeyAttributes is specified".to_string()),
1224            None => Ok(()),
1225        },
1226        Some(ProjectionType::KEYS_ONLY) => match &p.non_key_attributes {
1227            Some(_) => Err("One or more parameter values were invalid: ProjectionType is KEYS_ONLY, but NonKeyAttributes is specified".to_string()),
1228            None => Ok(()),
1229        },
1230        Some(ProjectionType::INCLUDE) => match &p.non_key_attributes {
1231            None => Err("One or more parameter values were invalid: ProjectionType is INCLUDE, but NonKeyAttributes is not specified".to_string()),
1232            Some(nka) if nka.is_empty() => Err("One or more parameter values were invalid: NonKeyAttributes must not be empty".to_string()),
1233            Some(_) => Ok(()),
1234        },
1235    }
1236}