Skip to main content

parse_rust_server/routes/
schemas.rs

1//! `/schemas` and `DELETE /purge/:className`. Master key only, on every verb.
2//!
3//! Upstream: `src/Routers/SchemasRouter.js`. Every route here is wrapped in
4//! `promiseEnforceMasterKeyAccess` (`SchemasRouter.js:130-158`), which is the same gate
5//! `/serverInfo` uses and which **maintenance does not satisfy**.
6//!
7//! **These handlers read `_SCHEMA` afresh rather than using the request snapshot.** Upstream does
8//! the same, `loadSchema({clearCache: true})` then `getAllClasses({clearCache: true})`
9//! (`SchemasRouter.js:18-28`), and here it also keeps the server-level `protectedFields` option
10//! out of what a client reads back: the snapshot has that option folded into its CLP blocks, and
11//! rendering it would turn a configuration value into a database value on the next write-back.
12
13use parse_rust_core::{ClassLevelPermissions, ErrorCode, ParseError, ParseMap, ParseValue};
14use parse_rust_schema::{plan_update, validate_new_class};
15use parse_rust_storage::{AddFieldOutcome, ClassSchema, FieldType, Query, StorageAdapter};
16use serde_json::{json, Value as Json};
17
18use crate::state::AppState;
19
20/// `GET /schemas`. `{results: [...]}`.
21pub async fn get_all(state: &AppState) -> Result<Json, ParseError> {
22    let classes = state.storage().all_schemas().await?;
23    Ok(json!({
24        "results": classes.iter().map(render).collect::<Vec<_>>(),
25    }))
26}
27
28/// `GET /schemas/:className`. The schema object itself, not wrapped.
29///
30/// A class that does not exist is `INVALID_CLASS_NAME` (103) `Class <X> does not exist.`, not a
31/// 404 (`SchemasRouter.js:30-36`).
32pub async fn get_one(state: &AppState, class_name: &str) -> Result<Json, ParseError> {
33    let schema = load_one(state, class_name).await?;
34    Ok(render(&schema))
35}
36
37/// `POST /schemas` and `POST /schemas/:className`.
38pub async fn create(
39    state: &AppState,
40    path: &str,
41    class_name: Option<&str>,
42    body: &Json,
43) -> Result<Json, ParseError> {
44    let body = as_object(body)?;
45    // A truthy non-string can never equal the path's name, so it is a mismatch wherever the path
46    // supplies one, and a class name this server cannot use where it does not.
47    let body_class = match class_name_field(&body) {
48        ClassNameField::Absent => None,
49        ClassNameField::Named(name) => Some(name),
50        ClassNameField::Malformed(rendered) => {
51            return Err(mismatch(&rendered, class_name.unwrap_or(path)))
52        }
53    };
54
55    // The mismatch check runs before the missing-name check, and only when both are present
56    // (`SchemasRouter.js:82-86`).
57    if let (Some(path_class), Some(body_class)) = (class_name, body_class.as_deref()) {
58        if path_class != body_class {
59            return Err(mismatch(body_class, path_class));
60        }
61    }
62
63    let Some(class_name) = class_name.or(body_class.as_deref()) else {
64        // `throw new Parse.Error(135, ...)` (`SchemasRouter.js:90`). 135 is `MissingClassName`.
65        return Err(ParseError::new(
66            ErrorCode::MissingClassName,
67            format!("POST {path} needs a class name."),
68        ));
69    };
70
71    let fields = fields_block(&body, class_name)?;
72    let clp = clp_block(&body, class_name)?;
73    let mut schema = validate_new_class(class_name, &fields, clp, state.config().clp_validation())?;
74    // Built before the schema row is written, and recorded from what was built
75    // (`MongoStorageAdapter.js:449-452`). `createClass` passes no existing block, because there is
76    // no class yet.
77    schema.indexes = apply_indexes(
78        state,
79        class_name,
80        index_block(&body, class_name)?.as_ref(),
81        None,
82        &schema,
83        true,
84    )
85    .await?;
86
87    // **An insert, and the already-exists answer comes from it** (`MongoSchemaCollection.js:183`,
88    // reached through `createClass`). Reading the schema list first and then upserting is the
89    // shape this had until a review, and it lets two concurrent creates for one class both pass
90    // the read and both write, so both report success and the loser's fields and CLP replace the
91    // winner's. Only the write is atomic, so only the write can answer the question.
92    //
93    // `insert_schema` writes `_metadata.class_permissions` only when `clp` is `Some`, which is
94    // exactly the distinction that has to survive: an absent block and an empty one are two
95    // different documents to a parse-server node reading the same database.
96    //
97    // The relabelling is upstream's too: the storage layer reports `DUPLICATE_VALUE`, and
98    // `addClassIfNotExists` turns it into `INVALID_CLASS_NAME` (`SchemaController.js:861-864`).
99    state.storage().insert_schema(&schema).await.map_err(|e| {
100        if e.code == ErrorCode::DuplicateValue {
101            ParseError::new(
102                ErrorCode::InvalidClassName,
103                format!("Class {class_name} already exists."),
104            )
105        } else {
106            e
107        }
108    })?;
109    Ok(render(&schema))
110}
111
112/// `PUT /schemas/:className`: field add, field delete, CLP replace.
113pub async fn update(state: &AppState, class_name: &str, body: &Json) -> Result<Json, ParseError> {
114    let body = as_object(body)?;
115    match class_name_field(&body) {
116        ClassNameField::Absent => {}
117        ClassNameField::Named(body_class) if body_class == class_name => {}
118        ClassNameField::Named(body_class) => return Err(mismatch(&body_class, class_name)),
119        ClassNameField::Malformed(rendered) => return Err(mismatch(&rendered, class_name)),
120    }
121
122    let existing = load_one(state, class_name).await?;
123    let fields = fields_block(&body, class_name)?;
124    let clp = clp_block(&body, class_name)?;
125    let mutation = plan_update(&existing, &fields, clp, state.config().clp_validation())?;
126
127    // Deletions first, matching `updateClass`' order (`SchemaController.js:899-923`): a field
128    // deleted and re-added in one request ends up added.
129    if !mutation.deleted.is_empty() {
130        state
131            .storage()
132            .delete_fields(&existing, &mutation.deleted)
133            .await?;
134    }
135
136    // The merged view the index field check runs against, which is upstream's `fullNewSchema`
137    // (`SchemaController.js:941-946`). It is a local value for validation only; nothing writes it.
138    let mut merged = existing.clone();
139    for name in &mutation.deleted {
140        merged.fields.shift_remove(name.as_str());
141    }
142    for field in &mutation.set_fields {
143        merged
144            .fields
145            .insert(field.name.clone(), field.field_type.clone());
146    }
147
148    // **Every write below is a delta, and nothing writes the whole schema.** Upstream's
149    // `updateClass` touches exactly what the request named: `enforceFieldExists` per submitted
150    // field (`SchemaController.js:930-934`), `setPermissions` only when a CLP block was sent
151    // (`:1097-1099`), and `setIndexesWithSchemaFormat` only when an `indexes` block was
152    // (`MongoStorageAdapter.js:353-355`).
153    //
154    // Sending a whole-schema upsert instead, which is what this did until a review, is not safe
155    // even with the added fields excluded. Two interleavings it permits, both silent:
156    //
157    // - one request deletes field `old`, and a later request holding a stale snapshot `$set`s
158    //   `old` back into `_SCHEMA` as part of writing something unrelated;
159    // - two requests each add a different field with options, each reserves its own type, and
160    //   then each replaces the whole `_metadata.fields_options` block, so the second erases the
161    //   first field's options while leaving its type in place.
162    for field in &mutation.set_fields {
163        if field.is_new {
164            // Type and options in one conditional update, because a field reserved without its
165            // options is a field whose options a concurrent writer can still win.
166            let options = (!field.options.is_empty()).then(|| field.options.clone());
167            match state
168                .storage()
169                .reserve_field(class_name, &field.name, &field.field_type, options.as_ref())
170                .await?
171            {
172                AddFieldOutcome::Added | AddFieldOutcome::AlreadyPresentSameType => {}
173                AddFieldOutcome::Conflict { existing } => {
174                    return Err(parse_rust_schema::infer::schema_mismatch(
175                        class_name,
176                        &field.name,
177                        &existing,
178                        &field.field_type,
179                    ))
180                }
181            }
182            continue;
183        }
184
185        // An already-stored field. `enforceFieldExists` still runs its own `defaultValue` type
186        // check on it, which is the one option rule that reaches an existing field: everything
187        // else in `validateSchemaData` sits behind the `existingFieldNames` guard.
188        parse_rust_schema::check_default_value_type(
189            class_name,
190            &field.name,
191            &field.field_type,
192            &field.options,
193        )?;
194
195        // Then `updateFieldOptions`, unconditionally. Upstream skips it only when the stored spec
196        // and the submitted one stringify identically, which key ordering makes rare, and the
197        // write is idempotent either way. **The empty case is the point**: resubmitting
198        // `{"type":"String"}` for a field stored as `{"type":"String","required":true}` writes an
199        // empty options object, which is how a client clears options. Recording nothing, which is
200        // what this did until a review, left the field required with no way to undo it.
201        state
202            .storage()
203            .set_field_options(class_name, &field.name, &field.options)
204            .await?;
205    }
206
207    // **CLP before indexes**, which is `updateClass`' order (`SchemaController.js:938-947`). It
208    // decides what survives a half-failed request: an index on an unknown field is refused by both
209    // servers, and upstream has already written the CLP by then. Writing indexes first would keep
210    // the index and drop the permissions, which is the more dangerous half to lose.
211    //
212    // `None` on the mutation means the body carried no `classLevelPermissions` at all, which
213    // leaves the stored block alone; an empty block replaces it.
214    if let Some(clp) = mutation.clp.clone() {
215        state
216            .storage()
217            .set_class_permissions(class_name, Some(&clp))
218            .await?;
219    }
220
221    if let Some(recorded) = apply_indexes(
222        state,
223        class_name,
224        index_block(&body, class_name)?.as_ref(),
225        merged.indexes.as_ref(),
226        &merged,
227        false,
228    )
229    .await?
230    {
231        state.storage().set_indexes(class_name, &recorded).await?;
232    }
233
234    // **Rendered from a re-read, not from a reconstruction.** Upstream reloads and answers from the
235    // reloaded schema (`SchemaController.js:948-962`), and the difference is observable: a
236    // reservation that loses a race writes nothing, so a response assembled from the request would
237    // report options the database does not have.
238    let reloaded = load_one(state, class_name).await?;
239    Ok(render(&reloaded))
240}
241
242/// `setIndexesWithSchemaFormat` (`MongoStorageAdapter.js:347-410`): build what was asked for, drop
243/// what was marked deleted, and answer with the block to record.
244///
245/// **The order is the whole point.** Indexes are built first and `_metadata.indexes` is written
246/// from the result, so a failed build leaves no claim behind. Storing the submitted block without
247/// building anything, which is what 0.2.0 did until this review, produces a `_SCHEMA` row asserting
248/// an index that does not exist; a parse-server node on the same database reads that row, concludes
249/// the index is already there, and never creates it either. The class then runs unindexed forever
250/// with both servers believing otherwise.
251///
252/// `None` means the request carried no `indexes` key at all, which changes nothing
253/// (`MongoStorageAdapter.js:353-355`). That is not the same as an empty block.
254async fn apply_indexes(
255    state: &AppState,
256    class_name: &str,
257    submitted: Option<&ParseMap>,
258    existing: Option<&ParseMap>,
259    fields: &ClassSchema,
260    is_create: bool,
261) -> Result<Option<ParseMap>, ParseError> {
262    let Some(submitted) = submitted else {
263        return Ok(None);
264    };
265
266    // The `_id_` seeding is upstream's and it is wire-visible: a class whose index request arrives
267    // with no recorded block reads back afterwards with `_id_` listed beside whatever was added
268    // (`MongoStorageAdapter.js:356-358`).
269    //
270    // **It does not happen on create, and the reason is an ordering detail rather than a rule.**
271    // `setIndexesWithSchemaFormat` runs *before* `insertSchema` there
272    // (`MongoStorageAdapter.js:449-451`), and its trailing write is a plain `updateOne` with no
273    // upsert (`MongoSchemaCollection.js:197-199`), so on a class that does not exist yet it matches
274    // nothing and is a no-op. What lands is the insert of the submitted schema
275    // (`MongoStorageAdapter.js:130-133`). Seeding here regardless put a phantom `_id_` into
276    // `_metadata.indexes` on a database parse-server also reads, and
277    // `spec/schemas.spec.js:3150-3184` asserts its absence on create against `:3248-3252` asserting
278    // its presence on update.
279    let mut recorded = match existing {
280        Some(existing) if !existing.is_empty() => existing.clone(),
281        _ if is_create => ParseMap::new(),
282        _ => {
283            let mut seed = ParseMap::new();
284            let mut id = ParseMap::new();
285            id.insert("_id".to_string(), ParseValue::Number(1.0));
286            seed.insert("_id_".to_string(), ParseValue::Object(id));
287            seed
288        }
289    };
290
291    let mut to_build: Vec<parse_rust_storage::SchemaIndex> = Vec::new();
292    let mut to_drop: Vec<String> = Vec::new();
293
294    for (name, spec) in submitted {
295        let is_delete = matches!(spec, ParseValue::Object(m)
296            if matches!(m.get("__op"), Some(ParseValue::String(op)) if op == "Delete"));
297
298        if recorded.contains_key(name.as_str()) && !is_delete {
299            return Err(ParseError::invalid_query(format!(
300                "Index {name} exists, cannot update."
301            )));
302        }
303        if !recorded.contains_key(name.as_str()) && is_delete {
304            return Err(ParseError::invalid_query(format!(
305                "Index {name} does not exist, cannot delete."
306            )));
307        }
308        if is_delete {
309            to_drop.push(name.clone());
310            recorded.shift_remove(name.as_str());
311            continue;
312        }
313
314        let ParseValue::Object(keys) = spec else {
315            return Err(ParseError::invalid_query(format!(
316                "Index {name} is not an object."
317            )));
318        };
319        let mut lowered = Vec::with_capacity(keys.len());
320        for (key, direction) in keys {
321            // `_p_`-prefixed keys name the storage column of a pointer field, so the check strips
322            // the prefix before looking the field up (`MongoStorageAdapter.js:380-383`).
323            let field = key.strip_prefix("_p_").unwrap_or(key);
324            if fields.field(field).is_none()
325                && !parse_rust_schema::infer::is_default_column(class_name, field)
326            {
327                return Err(ParseError::invalid_query(format!(
328                    "Field {key} does not exist, cannot add index."
329                )));
330            }
331            lowered.push((key.clone(), direction.clone()));
332        }
333        to_build.push(parse_rust_storage::SchemaIndex {
334            name: name.clone(),
335            keys: lowered,
336        });
337        recorded.insert(name.clone(), spec.clone());
338    }
339
340    for name in &to_drop {
341        state.storage().drop_index(class_name, name).await?;
342    }
343    state
344        .storage()
345        .create_indexes(class_name, &to_build)
346        .await?;
347    Ok(Some(recorded))
348}
349
350/// `DELETE /schemas/:className`.
351///
352/// A non-empty class is code `255` `Class <X> is not empty, contains <N> objects, cannot drop
353/// schema.` (`DatabaseController.js:1621-1626`). Count first, then drop.
354pub async fn delete(state: &AppState, class_name: &str) -> Result<Json, ParseError> {
355    if !parse_rust_schema::class_name_is_valid(class_name) {
356        return Err(ParseError::new(
357            ErrorCode::InvalidClassName,
358            parse_rust_schema::infer::invalid_class_name_message(class_name),
359        ));
360    }
361    // A class with no `_SCHEMA` row is not an error, and it is **not** a reason to stop either.
362    // `getOneSchema` rejects with `undefined`, the caller substitutes `{fields: {}}`, and the count
363    // and the drop then run against that (`DatabaseController.js:1603-1627`). The
364    // `collectionExists` result is discarded by the `.then(() => ...)` that follows it, so nothing
365    // upstream short-circuits on it.
366    //
367    // Returning `{}` here instead meant a collection whose schema row was missing could never be
368    // dropped: the request answered 200 and did nothing, which is what a dashboard "delete class"
369    // on such a class looked like. The substituted schema has no fields, which is exactly what
370    // upstream counts and drops with.
371    let schema = match find_schema(state, class_name).await? {
372        Some(schema) => schema,
373        None => ClassSchema::new(class_name),
374    };
375    let count = state.storage().count(&schema, &Query::new()).await?;
376    if count > 0 {
377        return Err(ParseError::new(
378            ErrorCode::InvalidSchemaOperation,
379            format!(
380                "Class {class_name} is not empty, contains {count} objects, cannot drop schema."
381            ),
382        ));
383    }
384    state.storage().delete_class(&schema).await?;
385    Ok(json!({}))
386}
387
388/// `DELETE /purge/:className`.
389///
390/// Deletes every row and keeps the class, its schema entry and its join tables
391/// (`DatabaseController.js:461-465`, `PurgeRouter.js:19-23`).
392///
393/// Upstream additionally clears the user cache for `_Session` and the role cache for `_Role`
394/// (`PurgeRouter.js:19-23`). parse-rust caches neither, so there is nothing to clear; stating that
395/// here rather than leaving the missing branch to be read as an oversight.
396pub async fn purge(state: &AppState, class_name: &str) -> Result<Json, ParseError> {
397    let Some(schema) = find_schema(state, class_name).await? else {
398        // `purgeCollection` rejects on an unknown class and the router swallows it as `{}`
399        // (`PurgeRouter.js:26-30`).
400        return Ok(json!({}));
401    };
402    state.storage().delete(&schema, &Query::new()).await?;
403    Ok(json!({}))
404}
405
406// -------------------------------------------------------------------------------------------
407// Rendering
408// -------------------------------------------------------------------------------------------
409
410/// The wire shape of a schema: `{className, fields, classLevelPermissions, indexes}`
411/// (`MongoSchemaCollection.js:106-111`).
412fn render(schema: &ClassSchema) -> Json {
413    let mut fields = serde_json::Map::new();
414    for (name, ty) in &schema.fields {
415        let mut entry = serde_json::Map::new();
416        entry.insert("type".to_string(), json!(ty.wire_type()));
417        if let Some(target) = ty.target_class() {
418            entry.insert("targetClass".to_string(), json!(target));
419        }
420        // `_metadata.fields_options` is merged onto the field entry, unknown keys included
421        // (`MongoSchemaCollection.js:47-57`).
422        if let Some(ParseValue::Object(options)) = schema
423            .field_options
424            .as_ref()
425            .and_then(|o| o.get(name.as_str()))
426        {
427            if let Json::Object(rendered) = to_json(options) {
428                for (key, value) in rendered {
429                    entry.insert(key, value);
430                }
431            }
432        }
433        fields.insert(name.clone(), Json::Object(entry));
434    }
435    // `mongoSchemaFieldsToParseSchemaFields` appends these unconditionally (`:60-63`). A key that
436    // is already present keeps its position, which is what an order-preserving map does too.
437    for (name, ty) in [
438        ("ACL", "ACL"),
439        ("createdAt", "Date"),
440        ("updatedAt", "Date"),
441        ("objectId", "String"),
442    ] {
443        if !fields.contains_key(name) {
444            fields.insert(name.to_string(), json!({ "type": ty }));
445        }
446    }
447
448    // **`indexes` is omitted entirely when the class has none, rather than rendered as `{}`.**
449    // All three of upstream's renderers guard the key the same way (`SchemaController.js:552-554`
450    // for POST, `:628-630` for GET, `:958-960` for PUT), and `spec/schemas.spec.js:3200-3211`
451    // compares the whole response object, so an extra key fails the assertion outright. A client
452    // distinguishing "no indexes" from "indexes not reported" reads the key's presence.
453    let mut body = json!({
454        "className": schema.class_name,
455        "fields": Json::Object(fields),
456        "classLevelPermissions": render_clp(schema.clp.as_ref()),
457    });
458    if let Some(indexes) = schema.indexes.as_ref().filter(|i| !i.is_empty()) {
459        if let Some(map) = body.as_object_mut() {
460            map.insert("indexes".to_string(), to_json(indexes));
461        }
462    }
463    body
464}
465
466/// The two different defaults, which is the rule that breaks a mixed fleet if it is normalized
467/// away (`MongoSchemaCollection.js:67-112`).
468///
469/// A class whose `_metadata.class_permissions` is **absent** reads back as `defaultCLPS`, fully
470/// public and carrying an `ACL` key. A class whose block is **present** reads back as that block
471/// merged over `emptyCLPS`, which has no `ACL` key and whose unspecified operations are `{}`. The
472/// Mongo adapter already does the present-case merge and keeps `clp` as `None` for absent, so all
473/// that is left here is choosing which default to render.
474fn render_clp(clp: Option<&ClassLevelPermissions>) -> Json {
475    match clp {
476        Some(clp) => to_json(clp.raw()),
477        None => json!({
478            "ACL": { "*": { "read": true, "write": true } },
479            "find": { "*": true },
480            "count": { "*": true },
481            "get": { "*": true },
482            "create": { "*": true },
483            "update": { "*": true },
484            "delete": { "*": true },
485            "addField": { "*": true },
486            "protectedFields": { "*": [] },
487        }),
488    }
489}
490
491fn to_json(map: &ParseMap) -> Json {
492    serde_json::from_str(&ParseValue::Object(map.clone()).to_json()).unwrap_or(Json::Null)
493}
494
495// -------------------------------------------------------------------------------------------
496// Helpers
497// -------------------------------------------------------------------------------------------
498
499async fn find_schema(
500    state: &AppState,
501    class_name: &str,
502) -> Result<Option<ClassSchema>, ParseError> {
503    Ok(state
504        .storage()
505        .all_schemas()
506        .await?
507        .into_iter()
508        .find(|s| s.class_name == class_name))
509}
510
511async fn load_one(state: &AppState, class_name: &str) -> Result<ClassSchema, ParseError> {
512    find_schema(state, class_name).await?.ok_or_else(|| {
513        ParseError::new(
514            ErrorCode::InvalidClassName,
515            format!("Class {class_name} does not exist."),
516        )
517    })
518}
519
520fn mismatch(body_class: &str, path_class: &str) -> ParseError {
521    ParseError::new(
522        ErrorCode::InvalidClassName,
523        format!("Class name mismatch between {body_class} and {path_class}."),
524    )
525}
526
527/// Decode a schema body **without interpreting any `__type` envelope**.
528///
529/// Nothing in a schema body is a column value. `className`, `type` and `targetClass` are strings, a
530/// CLP block is objects and booleans, an `indexes` block is numbers and strings, and a
531/// `defaultValue` is an opaque value this server stores and never enforces. Running the ordinary
532/// decoder over it re-rendered every envelope it recognized: an offset instant became UTC, unpadded
533/// base64 was re-padded, and any key the envelope does not declare was dropped, because a decoded
534/// `Date` has nowhere to keep it.
535///
536/// The loss is invisible from here, since parse-rust reads its own storage back the same way it
537/// wrote it. It is visible to a parse-server node, which stores what it was sent and therefore
538/// reads back something the client never wrote.
539///
540/// The one consumer that needs the meaning rather than the text is the `defaultValue` type check,
541/// and it reads the tag itself; see `check_default_value_type`.
542fn as_object(body: &Json) -> Result<ParseMap, ParseError> {
543    match parse_rust_core::classify_raw(body.clone())? {
544        ParseValue::Object(map) => Ok(map),
545        _ => Err(ParseError::invalid_json("body must be an object")),
546    }
547}
548
549/// The body's `className`: absent, a name, or malformed.
550///
551/// **A non-string must not read as absent, because absent means the path name wins.** Upstream's
552/// check is truthiness plus `!==` (`SchemasRouter.js:82-86`), so every truthy non-string is a
553/// mismatch: `PUT /schemas/Widget` carrying `"className": ["Gadget"]` answers 103. Reading it as
554/// absent instead lets the request through as an ordinary update of `Widget`, so a body naming one
555/// class silently edits another.
556///
557/// Falsy values are absent, which is upstream exactly. Measured against parse-server
558/// 9.10.1-alpha.6: `"className": null` and `"className": ""` both answer 200 and act on the path's
559/// class, while `["Gadget"]`, `7`, `true` and `{"a":1}` all answer 103.
560enum ClassNameField {
561    Absent,
562    Named(String),
563    /// Present, truthy, and not a string. Carries the JSON rendering for the message.
564    Malformed(String),
565}
566
567fn class_name_field(body: &ParseMap) -> ClassNameField {
568    match body.get("className") {
569        None | Some(ParseValue::Null) | Some(ParseValue::Bool(false)) => ClassNameField::Absent,
570        Some(ParseValue::Number(n)) if *n == 0.0 => ClassNameField::Absent,
571        Some(ParseValue::String(s)) if s.is_empty() => ClassNameField::Absent,
572        Some(ParseValue::String(s)) => ClassNameField::Named(s.clone()),
573        Some(other) => ClassNameField::Malformed(render_as_js_string(other)),
574    }
575}
576
577/// The value as JavaScript's string conversion would render it, which is what upstream
578/// interpolates into the mismatch message.
579///
580/// Not a general `String()`: only the shapes a `className` can actually arrive as. A number goes
581/// through the ECMAScript formatter this project already carries, an object is the famous
582/// `[object Object]`, and an array is its elements joined by commas, which is why `["Gadget"]`
583/// reports `Gadget` upstream rather than anything bracketed. Verified against parse-server
584/// 9.10.1-alpha.6 for `["Gadget"]`, `7`, `true` and `{"a":1}`.
585fn render_as_js_string(value: &ParseValue) -> String {
586    match value {
587        ParseValue::String(s) => s.clone(),
588        ParseValue::Number(n) => parse_rust_core::js_number::to_ecma_string(*n),
589        ParseValue::Bool(b) => b.to_string(),
590        // `String(null)` is `"null"`, but `Array.prototype.join` renders a null *element* as the
591        // empty string, so `[null]` is `""` and not `"null"`. Two different rules for the same
592        // value depending on where it sits, which is why the array arm cannot just recurse here.
593        ParseValue::Null => "null".to_string(),
594        ParseValue::Array(items) => items
595            .iter()
596            .map(|item| match item {
597                ParseValue::Null => String::new(),
598                other => render_as_js_string(other),
599            })
600            .collect::<Vec<_>>()
601            .join(","),
602        _ => "[object Object]".to_string(),
603    }
604}
605
606/// The `fields` block, distinguishing "absent" from "present and not an object".
607///
608/// The third member of the family that already holds [`index_block`] and [`clp_block`], and it was
609/// the last one still collapsing the two cases with `unwrap_or_default()`. `"fields": "typo"`
610/// therefore created a class with only its default columns, and made a `PUT` a successful no-op,
611/// in response to a request that was trying to define fields.
612///
613/// Upstream's outcome is decided by JSON type, as with the other two. Measured against parse-server
614/// 9.10.1-alpha.6: a **string** or non-empty array enumerates to its indices and fails as 105
615/// `invalid field name: 0`; a **number**, **boolean** or empty array enumerates to nothing and
616/// answers 200 having created the class with no fields; **null** reaches `Object.keys(null)` and
617/// answers `{"code":1,"error":"Internal server error."}`.
618///
619/// Blast radius: a client sending `"fields": []` where it meant `{}` gets a refusal here and a
620/// success upstream. No spec file submits a malformed block.
621fn fields_block(body: &ParseMap, class_name: &str) -> Result<ParseMap, ParseError> {
622    match body.get("fields") {
623        None => Ok(ParseMap::new()),
624        Some(ParseValue::Object(map)) => Ok(map.clone()),
625        Some(_) => Err(ParseError::invalid_json(format!(
626            "Invalid fields for class {class_name}: expected an object."
627        ))),
628    }
629}
630
631/// The `classLevelPermissions` block, distinguishing "absent" from "present and not an object".
632///
633/// **This is the same defect [`index_block`] exists to prevent, in the one place where the silent
634/// outcome is an open class.** [`object_field`] collapses both cases to `None`, and `None` means
635/// "the request said nothing about permissions". So `{"classLevelPermissions": "typo"}` created a
636/// class with no CLP at all, which is default-open, in response to a request that was trying to
637/// restrict it. A typo produced the opposite of what it asked for and answered 200.
638///
639/// Upstream refuses that body. `validateCLP` returns early only on a *falsy* `perms`
640/// (`SchemaController.js:272-274`); anything else reaches `for (const operationKey in perms)`, and
641/// enumerating a string yields its indices, so `"typo"` throws `INVALID_JSON` `0 is not a valid
642/// operation for class level permissions` (`:275-281`).
643///
644/// **Tier 2, refusing uniformly, for the reason `index_block` gives.** Upstream's outcome is
645/// decided by JSON type rather than by any rule about permissions: a non-empty string or array
646/// throws, while a number, a boolean or an empty array enumerates to no keys and is accepted, and
647/// a falsy value is read as absent. Reproducing that means accepting three malformed spellings and
648/// rejecting a fourth. Blast radius: a client sending `"classLevelPermissions": []` or `0` where
649/// it meant `{}` gets a refusal here and a success upstream. No spec file submits a malformed
650/// block.
651fn clp_block(body: &ParseMap, class_name: &str) -> Result<Option<ParseMap>, ParseError> {
652    match body.get("classLevelPermissions") {
653        None => Ok(None),
654        Some(ParseValue::Object(map)) => Ok(Some(map.clone())),
655        Some(_) => Err(ParseError::invalid_json(format!(
656            "Invalid classLevelPermissions for class {class_name}: expected an object."
657        ))),
658    }
659}
660
661/// The `indexes` block, distinguishing "absent" from "present and not an object".
662///
663/// [`object_field`] collapses the two into `None`, which for `indexes` means a malformed block is
664/// silently ignored and the request succeeds. Upstream never silently ignores it: `submittedIndexes
665/// === undefined` is the only early return (`MongoStorageAdapter.js:353-355`), and everything else
666/// goes through `Object.keys`.
667///
668/// **Tier 2, and worth stating why rather than reproducing.** What upstream then does depends on
669/// the JSON type, through JavaScript coercion rather than through any rule about indexes:
670///
671/// - a **string** is indexable, so `Object.keys("x")` is `["0"]` and the block is read as one index
672///   named `0` whose key document is the string again, which fails the field check as
673///   `Field 0 does not exist, cannot add index.`;
674/// - a **number**, **boolean** or **empty array** has no own keys, so the loop body never runs and
675///   the request succeeds having recorded only the seeded `_id_`;
676/// - a **non-empty array** behaves like the string case, through its elements;
677/// - **null** reaches `Object.keys(null)`, which throws a `TypeError` and answers
678///   `{"code":1,"error":"Internal server error."}`.
679///
680/// Three different outcomes for one malformed field, none of which a client could depend on
681/// deliberately, and one of which is a crash. So parse-rust answers one thing for all of them. The
682/// divergence is recorded under the deliberate differences in `CHANGELOG.md`, with its blast
683/// radius: a client sending `"indexes": []` where it meant `{}` gets a refusal here and a success
684/// upstream.
685fn index_block(body: &ParseMap, class_name: &str) -> Result<Option<ParseMap>, ParseError> {
686    match body.get("indexes") {
687        None => Ok(None),
688        Some(ParseValue::Object(map)) => Ok(Some(map.clone())),
689        Some(_) => Err(ParseError::invalid_query(format!(
690            "Invalid indexes for class {class_name}: expected an object."
691        ))),
692    }
693}
694
695/// The Parse-side type name, which is not the `_SCHEMA` storage string.
696trait WireType {
697    fn wire_type(&self) -> &'static str;
698}
699
700impl WireType for FieldType {
701    fn wire_type(&self) -> &'static str {
702        match self {
703            FieldType::String => "String",
704            FieldType::Number => "Number",
705            FieldType::Boolean => "Boolean",
706            FieldType::Date => "Date",
707            FieldType::Object => "Object",
708            FieldType::Array => "Array",
709            FieldType::GeoPoint => "GeoPoint",
710            FieldType::File => "File",
711            FieldType::Bytes => "Bytes",
712            FieldType::Polygon => "Polygon",
713            FieldType::Acl => "ACL",
714            FieldType::Pointer { .. } => "Pointer",
715            FieldType::Relation { .. } => "Relation",
716        }
717    }
718}
719
720#[cfg(test)]
721mod tests {
722    use super::*;
723
724    /// The asymmetry that breaks a mixed fleet if it is normalized away.
725    #[test]
726    fn an_absent_clp_block_and_a_present_one_render_differently() {
727        let absent = render_clp(None);
728        assert!(
729            absent.get("ACL").is_some(),
730            "defaultCLPS carries an ACL key"
731        );
732        assert_eq!(absent["find"], json!({ "*": true }));
733
734        let present = ClassLevelPermissions::from_map(
735            match parse_rust_core::classify(
736                serde_json::from_str(r#"{"find":{"*":true},"count":{},"get":{},"create":{},"update":{},"delete":{},"addField":{},"protectedFields":{}}"#)
737                    .expect("literal"),
738            )
739            .expect("classify")
740            {
741                ParseValue::Object(m) => m,
742                _ => unreachable!(),
743            },
744        );
745        let rendered = render_clp(Some(&present));
746        assert!(
747            rendered.get("ACL").is_none(),
748            "emptyCLPS has no ACL key, so a present block never grows one"
749        );
750        assert_eq!(
751            rendered["count"],
752            json!({}),
753            "unspecified operations are {{}}"
754        );
755    }
756
757    #[test]
758    fn a_rendered_schema_carries_the_four_implicit_columns() {
759        let schema = ClassSchema::new("Post").with_field("title", FieldType::String);
760        let rendered = render(&schema);
761        assert_eq!(rendered["className"], json!("Post"));
762        assert_eq!(rendered["fields"]["title"], json!({ "type": "String" }));
763        assert_eq!(rendered["fields"]["ACL"], json!({ "type": "ACL" }));
764        assert_eq!(rendered["fields"]["objectId"], json!({ "type": "String" }));
765        // Absent, not `{}`. All three upstream renderers guard the key, and the spec suite
766        // compares the whole object, so an extra key is a failure rather than a nicety.
767        assert!(
768            rendered.get("indexes").is_none(),
769            "a class with no indexes must not carry the key: {rendered}"
770        );
771    }
772
773    /// The other half: a class that does have indexes reports them.
774    #[test]
775    fn a_class_with_indexes_renders_them() {
776        let mut schema = ClassSchema::new("Post").with_field("title", FieldType::String);
777        let mut indexes = ParseMap::new();
778        let mut key = ParseMap::new();
779        key.insert("title".to_string(), ParseValue::Number(1.0));
780        indexes.insert("title_1".to_string(), ParseValue::Object(key));
781        schema.indexes = Some(indexes);
782
783        let rendered = render(&schema);
784        assert_eq!(rendered["indexes"]["title_1"]["title"], json!(1));
785    }
786
787    #[test]
788    fn a_parametric_type_renders_its_target_class_as_a_separate_key() {
789        let schema = ClassSchema::new("_Role").with_field(
790            "users",
791            FieldType::Relation {
792                target_class: "_User".into(),
793            },
794        );
795        assert_eq!(
796            render(&schema)["fields"]["users"],
797            json!({ "type": "Relation", "targetClass": "_User" }),
798            "not the `relation<_User>` storage spelling"
799        );
800    }
801}