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}