     1	import { RestApiValidationError, RestApiPayloadError } from '../../../../lib/rest-api-errors.js'
     2	
     3	/**
     4	 * Validates that include paths don't exceed maximum depth
     5	 *
     6	 * @param {string[]} includes - Array of include paths to validate
     7	 * @param {number} maxDepth - Maximum allowed depth
     8	 * @returns {void}
     9	 * @throws {RestApiValidationError} If depth is exceeded
    10	 *
    11	 * @example
    12	 * // Input: Valid include paths
    13	 * validateIncludeDepth(['author', 'comments.author'], 3);
    14	 * // Output: Validation succeeds (no error thrown)
    15	 *
    16	 * @example
    17	 * // Input: Path exceeding depth limit
    18	 * validateIncludeDepth(['author.company.employees.manager'], 3);
    19	 * // Throws: RestApiValidationError
    20	 * // "Include path 'author.company.employees.manager' exceeds maximum depth of 3"
    21	 *
    22	 * @private
    23	 */
    24	function validateIncludeDepth (includes, maxDepth) {
    25	  if (!includes || !Array.isArray(includes)) {
    26	    return
    27	  }
    28	
    29	  for (const includePath of includes) {
    30	    if (typeof includePath !== 'string') {
    31	      continue // Let other validators handle type errors
    32	    }
    33	
    34	    const depth = includePath.split('.').length
    35	    if (depth > maxDepth) {
    36	      throw new RestApiValidationError(
    37	        `Include path '${includePath}' exceeds maximum depth of ${maxDepth}`,
    38	        {
    39	          fields: ['include'],
    40	          violations: [{
    41	            field: 'include',
    42	            rule: 'max_depth',
    43	            message: `Path '${includePath}' has depth ${depth}, maximum allowed is ${maxDepth}`
    44	          }]
    45	        }
    46	      )
    47	    }
    48	  }
    49	}
    50	
    51	/**
    52	 * Validates a JSON:API resource identifier object
    53	 *
    54	 * @param {Object} identifier - Resource identifier to validate
    55	 * @param {string} context - Context for error messages
    56	 * @param {Object} scopes - Scopes proxy to verify resource types
    57	 * @returns {boolean} True if valid
    58	 * @throws {RestApiPayloadError|RestApiValidationError} If validation fails
    59	 *
    60	 * @example
    61	 * // Input: Valid identifier
    62	 * validateResourceIdentifier(
    63	 *   { type: 'articles', id: '123' },
    64	 *   "Relationship 'author'",
    65	 *   scopes
    66	 * );
    67	 * // Output: true
    68	 *
    69	 * @example
    70	 * // Input: Missing type field
    71	 * validateResourceIdentifier(
    72	 *   { id: '123' },
    73	 *   "Relationship 'comments'",
    74	 *   scopes
    75	 * );
    76	 * // Throws: RestApiPayloadError
    77	 * // "Relationship 'comments': Resource identifier must have a non-empty 'type' string"
    78	 *
    79	 * @example
    80	 * // Input: Unknown resource type
    81	 * validateResourceIdentifier(
    82	 *   { type: 'unicorns', id: '456' },
    83	 *   "Included resource",
    84	 *   scopes  // scopes['unicorns'] doesn't exist
    85	 * );
    86	 * // Throws: RestApiValidationError
    87	 * // "Included resource: Unknown resource type 'unicorns'. No scope with this name exists."
    88	 *
    89	 * @description
    90	 * Used by:
    91	 * - validateRelationship to check relationship data
    92	 * - validatePostPayload to validate included resources
    93	 * - Internal validation of resource references
    94	 *
    95	 * Purpose:
    96	 * - Ensures JSON:API compliance for resource identifiers
    97	 * - Validates resource types exist in the system
    98	 * - Provides clear error context for debugging
    99	 *
   100	 * @private
   101	 */
   102	function validateResourceIdentifier (identifier, context, scopes = null) {
   103	  if (!identifier || typeof identifier !== 'object') {
   104	    throw new RestApiPayloadError(
   105	      `${context}: Resource identifier must be an object`,
   106	      { path: context, expected: 'object', received: typeof identifier }
   107	    )
   108	  }
   109	
   110	  if (typeof identifier.type !== 'string' || !identifier.type) {
   111	    throw new RestApiPayloadError(
   112	      `${context}: Resource identifier must have a non-empty 'type' string`,
   113	      { path: `${context}.type`, expected: 'non-empty string', received: identifier.type }
   114	    )
   115	  }
   116	
   117	  // Check if type is valid by checking if scope exists
   118	  if (scopes && !scopes[identifier.type]) {
   119	    throw new RestApiValidationError(
   120	      `${context}: Unknown resource type '${identifier.type}'. No scope with this name exists.`,
   121	      {
   122	        fields: [`${context}.type`],
   123	        violations: [{ field: `${context}.type`, rule: 'valid_resource_type', message: `Resource type '${identifier.type}' does not exist` }]
   124	      }
   125	    )
   126	  }
   127	
   128	  if (!('id' in identifier)) {
   129	    throw new RestApiPayloadError(
   130	      `${context}: Resource identifier must have an 'id' property`,
   131	      { path: `${context}.id`, expected: 'property exists', received: 'missing' }
   132	    )
   133	  }
   134	
   135	  if (identifier.id !== null && typeof identifier.id !== 'string' && typeof identifier.id !== 'number') {
   136	    throw new RestApiPayloadError(
   137	      `${context}: Resource identifier 'id' must be a string, number, or null`,
   138	      { path: `${context}.id`, expected: 'string, number, or null', received: typeof identifier.id }
   139	    )
   140	  }
   141	}
   142	
   143	/**
   144	 * Validates a relationship object in JSON:API format
   145	 *
   146	 * @param {Object} relationship - Relationship object to validate
   147	 * @param {string} relationshipName - Name for error context
   148	 * @param {Object} scopes - Scopes proxy to verify resource types
   149	 * @returns {void}
   150	 * @throws {RestApiPayloadError} If validation fails
   151	 *
   152	 * @example
   153	 * // Input: To-one relationship
   154	 * validateRelationship(
   155	 *   { data: { type: 'users', id: '42' } },
   156	 *   'author',
   157	 *   scopes
   158	 * );
   159	 * // Output: Validation succeeds (no error thrown)
   160	 *
   161	 * @example
   162	 * // Input: To-many relationship
   163	 * validateRelationship(
   164	 *   {
   165	 *     data: [
   166	 *       { type: 'tags', id: '1' },
   167	 *       { type: 'tags', id: '2' }
   168	 *     ]
   169	 *   },
   170	 *   'tags',
   171	 *   scopes
   172	 * );
   173	 * // Output: Validation succeeds (no error thrown)
   174	 *
   175	 * @example
   176	 * // Input: Null relationship (remove association)
   177	 * validateRelationship(
   178	 *   { data: null },
   179	 *   'featuredImage',
   180	 *   scopes
   181	 * );
   182	 * // Output: Validation succeeds (null is valid for clearing)
   183	 *
   184	 * @example
   185	 * // Input: Missing data property
   186	 * validateRelationship(
   187	 *   { type: 'users', id: '1' },  // Wrong structure
   188	 *   'author',
   189	 *   scopes
   190	 * );
   191	 * // Throws: RestApiPayloadError
   192	 * // "Relationship 'author' must have a 'data' property"
   193	 *
   194	 * @description
   195	 * Used by:
   196	 * - validatePostPayload for new resource relationships
   197	 * - validatePutPayload for relationship replacement
   198	 * - validatePatchPayload for relationship updates
   199	 *
   200	 * Purpose:
   201	 * - Ensures JSON:API relationship structure
   202	 * - Validates both to-one and to-many relationships
   203	 * - Handles null/empty cases for clearing relationships
   204	 * - Validates each resource identifier in arrays
   205	 *
   206	 * @private
   207	 */
   208	function validateRelationship (relationship, relationshipName, scopes = null) {
   209	  if (!relationship || typeof relationship !== 'object') {
   210	    throw new RestApiPayloadError(
   211	      `Relationship '${relationshipName}' must be an object`,
   212	      { path: `relationships.${relationshipName}`, expected: 'object', received: typeof relationship }
   213	    )
   214	  }
   215	
   216	  if (!('data' in relationship)) {
   217	    throw new RestApiPayloadError(
   218	      `Relationship '${relationshipName}' must have a 'data' property`,
   219	      { path: `relationships.${relationshipName}.data`, expected: 'property exists', received: 'missing' }
   220	    )
   221	  }
   222	
   223	  const { data } = relationship
   224	
   225	  // data can be null (empty to-one relationship)
   226	  if (data === null) {
   227	    return
   228	  }
   229	
   230	  // data can be a single resource identifier (to-one)
   231	  if (!Array.isArray(data)) {
   232	    validateResourceIdentifier(data, `Relationship '${relationshipName}'`, scopes)
   233	    return
   234	  }
   235	
   236	  // data can be an array of resource identifiers (to-many)
   237	  if (data.length === 0) {
   238	    return // Empty to-many relationship is valid
   239	  }
   240	
   241	  data.forEach((identifier, index) => {
   242	    validateResourceIdentifier(identifier, `Relationship '${relationshipName}[${index}]'`, scopes)
   243	  })
   244	}
   245	
   246	/**
   247	 * Validates query parameters for GET requests (single resource retrieval)
   248	 *
   249	 * @param {Object} params - Parameters containing id and queryParams
   250	 * @param {string|number} params.id - Resource ID to fetch
   251	 * @param {Object} [params.queryParams] - Optional query parameters
   252	 * @param {string[]} [params.queryParams.include] - Related resources to include
   253	 * @param {Object} [params.queryParams.fields] - Sparse fieldsets by resource type
   254	 * @param {number} maxIncludeDepth - Maximum allowed include depth
   255	 * @returns {boolean} True if valid
   256	 * @throws {RestApiValidationError|RestApiPayloadError} If validation fails
   257	 *
   258	 * @example
   259	 * // Input: Basic GET request
   260	 * validateGetPayload({
   261	 *   id: '123'
   262	 * });
   263	 * // Output: true
   264	 *
   265	 * @example
   266	 * // Input: GET with nested includes
   267	 * validateGetPayload({
   268	 *   id: '123',
   269	 *   queryParams: {
   270	 *     include: ['author', 'comments.author']
   271	 *   }
   272	 * });
   273	 * // Output: true (include depths are 1 and 2)
   274	 *
   275	 * @example
   276	 * // Input: GET with sparse fieldsets
   277	 * validateGetPayload({
   278	 *   id: '123',
   279	 *   queryParams: {
   280	 *     include: ['author'],
   281	 *     fields: {
   282	 *       articles: 'title,body',      // Only these article fields
   283	 *       users: 'name,email'          // Only these user fields
   284	 *     }
   285	 *   }
   286	 * });
   287	 * // Output: true
   288	 *
   289	 * @example
   290	 * // Input: Missing ID parameter
   291	 * validateGetPayload({
   292	 *   queryParams: { include: ['author'] }
   293	 * });
   294	 * // Throws: RestApiValidationError
   295	 * // "GET request must include an id parameter"
   296	 *
   297	 * @example
   298	 * // Input: Invalid field specification
   299	 * validateGetPayload({
   300	 *   id: '123',
   301	 *   queryParams: {
   302	 *     fields: {
   303	 *       articles: ['title', 'body']  // Should be string, not array
   304	 *     }
   305	 *   }
   306	 * });
   307	 * // Throws: RestApiPayloadError
   308	 * // "queryParams.fields['articles'] must be a comma-separated string"
   309	 *
   310	 * @description
   311	 * Used by:
   312	 * - rest-api-plugin.get() method before data fetching
   313	 * - Validates parameters before passing to storage layer
   314	 *
   315	 * Purpose:
   316	 * - Ensures required ID parameter is present and valid
   317	 * - Validates JSON:API query parameter structure
   318	 * - Prevents invalid queries from reaching database
   319	 * - Enforces include depth limits for performance
   320	 * - Provides consistent error messages across storage backends
   321	 *
   322	 * Data flow:
   323	 * 1. Checks params object structure
   324	 * 2. Validates required ID parameter
   325	 * 3. If queryParams exist, validates each type
   326	 * 4. Validates include paths don't exceed depth limit
   327	 * 5. Returns true or throws descriptive error
   328	 */
   329	export function validateGetPayload (params, maxIncludeDepth = 3) {
   330	  if (!params || typeof params !== 'object') {
   331	    throw new RestApiPayloadError(
   332	      'GET parameters must be an object',
   333	      { path: 'params', expected: 'object', received: typeof params }
   334	    )
   335	  }
   336	
   337	  // Validate ID
   338	  if (!('id' in params)) {
   339	    throw new RestApiValidationError(
   340	      'GET request must include an id parameter',
   341	      { fields: ['id'], violations: [{ field: 'id', rule: 'required', message: 'ID parameter is required' }] }
   342	    )
   343	  }
   344	
   345	  if (params.id === null || params.id === undefined || params.id === '') {
   346	    throw new RestApiValidationError(
   347	      'GET request id cannot be null, undefined, or empty',
   348	      { fields: ['id'], violations: [{ field: 'id', rule: 'not_empty', message: 'ID cannot be null, undefined, or empty' }] }
   349	    )
   350	  }
   351	
   352	  // Validate queryParams if present
   353	  if (params.queryParams) {
   354	    if (typeof params.queryParams !== 'object') {
   355	      throw new RestApiPayloadError(
   356	        'queryParams must be an object',
   357	        { path: 'queryParams', expected: 'object', received: typeof params.queryParams }
   358	      )
   359	    }
   360	
   361	    const { include, fields } = params.queryParams
   362	
   363	    // Validate include
   364	    if (include !== undefined) {
   365	      if (!Array.isArray(include)) {
   366	        throw new RestApiPayloadError(
   367	          'queryParams.include must be an array of strings',
   368	          { path: 'queryParams.include', expected: 'array', received: typeof include }
   369	        )
   370	      }
   371	
   372	      include.forEach((path, index) => {
   373	        if (typeof path !== 'string') {
   374	          throw new RestApiPayloadError(
   375	            `queryParams.include[${index}] must be a string`,
   376	            { path: `queryParams.include[${index}]`, expected: 'string', received: typeof path }
   377	          )
   378	        }
   379	      })
   380	
   381	      // Validate include depth
   382	      validateIncludeDepth(include, maxIncludeDepth)
   383	    }
   384	
   385	    // Validate fields
   386	    if (fields !== undefined) {
   387	      if (typeof fields !== 'object' || fields === null || Array.isArray(fields)) {
   388	        throw new RestApiPayloadError(
   389	          'queryParams.fields must be an object',
   390	          { path: 'queryParams.fields', expected: 'object', received: Array.isArray(fields) ? 'array' : typeof fields }
   391	        )
   392	      }
   393	
   394	      Object.entries(fields).forEach(([resourceType, fieldList]) => {
   395	        if (typeof fieldList !== 'string') {
   396	          throw new RestApiPayloadError(
   397	            `queryParams.fields['${resourceType}'] must be a comma-separated string`,
   398	            { path: `queryParams.fields['${resourceType}']`, expected: 'string', received: typeof fieldList }
   399	          )
   400	        }
   401	      })
   402	    }
   403	  }
   404	
   405	  return true
   406	}
   407	
   408	/**
   409	 * Validates query parameters for collection requests (query/list operations)
   410	 *
   411	 * @param {Object} params - Parameters object
   412	 * @param {Object} [params.queryParams] - Query parameters for the collection
   413	 * @param {string[]} [params.queryParams.include] - Related resources to include
   414	 * @param {Object} [params.queryParams.fields] - Sparse fieldsets by resource type
   415	 * @param {Object} [params.queryParams.filters] - Filter conditions
   416	 * @param {string[]} [params.queryParams.sort] - Sort fields (prefix with - for DESC)
   417	 * @param {Object} [params.queryParams.page] - Pagination parameters
   418	 * @param {string[]} sortableFields - Fields allowed for sorting
   419	 * @param {number} maxIncludeDepth - Maximum allowed include depth
   420	 * @returns {boolean} True if valid
   421	 * @throws {RestApiValidationError|RestApiPayloadError} If validation fails
   422	 *
   423	 * @example
   424	 * // Input: Empty query (fetch all)
   425	 * validateQueryPayload({}, ['title', 'createdAt']);
   426	 * // Output: true
   427	 *
   428	 * @example
   429	 * // Input: Query with filters and sorting
   430	 * validateQueryPayload({
   431	 *   queryParams: {
   432	 *     filters: {
   433	 *       status: 'published',
   434	 *       'author.name': 'John Doe'    // Dot notation for relationships
   435	 *     },
   436	 *     sort: ['-publishedAt', 'title'] // DESC publishedAt, ASC title
   437	 *   }
   438	 * }, ['publishedAt', 'title', 'createdAt']);
   439	 * // Output: true
   440	 *
   441	 * @example
   442	 * // Input: Different pagination styles
   443	 * // Page-based:
   444	 * validateQueryPayload({
   445	 *   queryParams: {
   446	 *     page: { number: 2, size: 20 }  // Page 2, 20 items per page
   447	 *   }
   448	 * }, []);
   449	 * // Output: true
   450	 *
   451	 * // Offset-based:
   452	 * validateQueryPayload({
   453	 *   queryParams: {
   454	 *     page: { offset: 40, limit: 20 } // Skip 40, take 20
   455	 *   }
   456	 * }, []);
   457	 * // Output: true
   458	 *
   459	 * @example
   460	 * // Input: Complex query with all features
   461	 * validateQueryPayload({
   462	 *   queryParams: {
   463	 *     include: ['author', 'tags', 'comments.author'],
   464	 *     fields: {
   465	 *       articles: 'title,summary,publishedAt',
   466	 *       users: 'name,avatar',
   467	 *       tags: 'name'
   468	 *     },
   469	 *     filters: {
   470	 *       status: 'published',
   471	 *       'tags.name': 'javascript'
   472	 *     },
   473	 *     sort: ['-publishedAt'],
   474	 *     page: { number: 1, size: 10 }
   475	 *   }
   476	 * }, ['publishedAt', 'title', 'updatedAt']);
   477	 * // Output: true (all parameters valid)
   478	 *
   479	 * @example
   480	 * // Input: Invalid sort field
   481	 * validateQueryPayload({
   482	 *   queryParams: {
   483	 *     sort: ['password']  // Not in sortableFields
   484	 *   }
   485	 * }, ['title', 'createdAt']);
   486	 * // Throws: RestApiValidationError
   487	 * // "Field 'password' is not sortable. Sortable fields are: title, createdAt"
   488	 *
   489	 * @description
   490	 * Used by:
   491	 * - rest-api-plugin.query() method before data fetching
   492	 * - Validates collection request parameters
   493	 *
   494	 * Purpose:
   495	 * - Validates all JSON:API query parameters for collections
   496	 * - Prevents SQL injection via filter key validation
   497	 * - Enforces sortable field restrictions for performance
   498	 * - Ensures consistent pagination across storage backends
   499	 * - Validates include depth limits
   500	 *
   501	 * Data flow:
   502	 * 1. Validates params object structure
   503	 * 2. For each query parameter type, validates format
   504	 * 3. Checks sort fields against allowed list
   505	 * 4. Validates pagination parameters are numbers/strings
   506	 * 5. Returns true or throws descriptive error
   507	 */
   508	export function validateQueryPayload (params, sortableFields = [], maxIncludeDepth = 3) {
   509	  if (!params || typeof params !== 'object') {
   510	    throw new RestApiPayloadError(
   511	      'Query parameters must be an object',
   512	      { path: 'params', expected: 'object', received: typeof params }
   513	    )
   514	  }
   515	
   516	  // queryParams is optional but if present must be an object
   517	  if (params.queryParams) {
   518	    if (typeof params.queryParams !== 'object') {
   519	      throw new RestApiPayloadError(
   520	        'queryParams must be an object',
   521	        { path: 'queryParams', expected: 'object', received: typeof params.queryParams }
   522	      )
   523	    }
   524	
   525	    const { include, fields, filters, sort, page } = params.queryParams
   526	
   527	    // Validate include
   528	    if (include !== undefined) {
   529	      if (!Array.isArray(include)) {
   530	        throw new RestApiPayloadError(
   531	          'queryParams.include must be an array of strings',
   532	          { path: 'queryParams.include', expected: 'array', received: typeof include }
   533	        )
   534	      }
   535	
   536	      include.forEach((path, index) => {
   537	        if (typeof path !== 'string') {
   538	          throw new RestApiPayloadError(
   539	            `queryParams.include[${index}] must be a string`,
   540	            { path: `queryParams.include[${index}]`, expected: 'string', received: typeof path }
   541	          )
   542	        }
   543	      })
   544	
   545	      // Validate include depth
   546	      validateIncludeDepth(include, maxIncludeDepth)
   547	    }
   548	
   549	    // Validate fields
   550	    if (fields !== undefined) {
   551	      if (typeof fields !== 'object' || fields === null || Array.isArray(fields)) {
   552	        throw new RestApiPayloadError(
   553	          'queryParams.fields must be an object',
   554	          { path: 'queryParams.fields', expected: 'object', received: Array.isArray(fields) ? 'array' : typeof fields }
   555	        )
   556	      }
   557	
   558	      Object.entries(fields).forEach(([resourceType, fieldList]) => {
   559	        if (typeof fieldList !== 'string') {
   560	          throw new RestApiPayloadError(
   561	            `queryParams.fields['${resourceType}'] must be a comma-separated string`,
   562	            { path: `queryParams.fields['${resourceType}']`, expected: 'string', received: typeof fieldList }
   563	          )
   564	        }
   565	      })
   566	    }
   567	
   568	    // Validate filters
   569	    if (filters !== undefined) {
   570	      if (typeof filters !== 'object' || filters === null || Array.isArray(filters)) {
   571	        throw new RestApiPayloadError(
   572	          'queryParams.filters must be an object',
   573	          { path: 'queryParams.filters', expected: 'object', received: Array.isArray(filters) ? 'array' : typeof filters }
   574	        )
   575	      }
   576	    }
   577	
   578	    // Validate sort
   579	    if (sort !== undefined) {
   580	      if (!Array.isArray(sort)) {
   581	        throw new RestApiPayloadError(
   582	          'queryParams.sort must be an array of strings',
   583	          { path: 'queryParams.sort', expected: 'array', received: typeof sort }
   584	        )
   585	      }
   586	
   587	      sort.forEach((field, index) => {
   588	        if (typeof field !== 'string') {
   589	          throw new RestApiPayloadError(
   590	            `queryParams.sort[${index}] must be a string`,
   591	            { path: `queryParams.sort[${index}]`, expected: 'string', received: typeof field }
   592	          )
   593	        }
   594	
   595	        // Check if field is sortable (remove leading - for descending sort)
   596	        const fieldName = field.startsWith('-') ? field.substring(1) : field
   597	        if (sortableFields.length > 0 && !sortableFields.includes(fieldName)) {
   598	          throw new RestApiValidationError(
   599	            `Field '${fieldName}' is not sortable. Sortable fields are: ${sortableFields.join(', ')}`,
   600	            { fields: ['sort'], violations: [{ field: 'sort', rule: 'sortable_field', message: `Field '${fieldName}' is not in the list of sortable fields` }] }
   601	          )
   602	        }
   603	      })
   604	    }
   605	
   606	    // Validate page
   607	    if (page !== undefined) {
   608	      if (typeof page !== 'object' || page === null || Array.isArray(page)) {
   609	        throw new RestApiPayloadError(
   610	          'queryParams.page must be an object',
   611	          { path: 'queryParams.page', expected: 'object', received: Array.isArray(page) ? 'array' : typeof page }
   612	        )
   613	      }
   614	
   615	      // Common pagination parameters
   616	      if ('number' in page && typeof page.number !== 'number' && typeof page.number !== 'string') {
   617	        throw new RestApiPayloadError(
   618	          'queryParams.page.number must be a number or string',
   619	          { path: 'queryParams.page.number', expected: 'number or string', received: typeof page.number }
   620	        )
   621	      }
   622	
   623	      if ('size' in page && typeof page.size !== 'number' && typeof page.size !== 'string') {
   624	        throw new RestApiPayloadError(
   625	          'queryParams.page.size must be a number or string',
   626	          { path: 'queryParams.page.size', expected: 'number or string', received: typeof page.size }
   627	        )
   628	      }
   629	
   630	      if ('limit' in page && typeof page.limit !== 'number' && typeof page.limit !== 'string') {
   631	        throw new RestApiPayloadError(
   632	          'queryParams.page.limit must be a number or string',
   633	          { path: 'queryParams.page.limit', expected: 'number or string', received: typeof page.limit }
   634	        )
   635	      }
   636	
   637	      if ('offset' in page && typeof page.offset !== 'number' && typeof page.offset !== 'string') {
   638	        throw new RestApiPayloadError(
   639	          'queryParams.page.offset must be a number or string',
   640	          { path: 'queryParams.page.offset', expected: 'number or string', received: typeof page.offset }
   641	        )
   642	      }
   643	    }
   644	  }
   645	
   646	  return true
   647	}
   648	
   649	/**
   650	 * Validates a JSON:API document for POST requests (resource creation)
   651	 *
   652	 * @param {Object} inputRecord - JSON:API document to validate
   653	 * @param {Object} inputRecord.data - Primary resource to create
   654	 * @param {string} inputRecord.data.type - Resource type (must match a scope)
   655	 * @param {string|number} [inputRecord.data.id] - Optional client-generated ID
   656	 * @param {Object} [inputRecord.data.attributes] - Resource attributes
   657	 * @param {Object} [inputRecord.data.relationships] - Resource relationships
   658	 * @param {Object[]} [inputRecord.included] - Related resources for compound documents
   659	 * @param {Object} scopes - Scopes proxy to verify resource types exist
   660	 * @returns {boolean} True if valid
   661	 * @throws {RestApiValidationError|RestApiPayloadError} If validation fails
   662	 *
   663	 * @example
   664	 * // Input: Simple resource creation
   665	 * validatePostPayload({
   666	 *   data: {
   667	 *     type: 'articles',
   668	 *     attributes: {
   669	 *       title: 'Hello World',
   670	 *       body: 'Welcome to my blog...'
   671	 *     }
   672	 *   }
   673	 * }, scopes);
   674	 * // Output: true
   675	 *
   676	 * @example
   677	 * // Input: Create with relationships
   678	 * validatePostPayload({
   679	 *   data: {
   680	 *     type: 'articles',
   681	 *     attributes: {
   682	 *       title: 'REST API Design'
   683	 *     },
   684	 *     relationships: {
   685	 *       author: {
   686	 *         data: { type: 'users', id: '42' }      // To-one
   687	 *       },
   688	 *       tags: {
   689	 *         data: [                                 // To-many
   690	 *           { type: 'tags', id: '1' },
   691	 *           { type: 'tags', id: '2' }
   692	 *         ]
   693	 *       }
   694	 *     }
   695	 *   }
   696	 * }, scopes);
   697	 * // Output: true (validates each relationship)
   698	 *
   699	 * @example
   700	 * // Input: Client-generated ID
   701	 * validatePostPayload({
   702	 *   data: {
   703	 *     type: 'articles',
   704	 *     id: 'article-2023-11-15',     // Client provides ID
   705	 *     attributes: {
   706	 *       title: 'Hello World'
   707	 *     }
   708	 *   }
   709	 * }, scopes);
   710	 * // Output: true (ID is optional for POST)
   711	 *
   712	 * @example
   713	 * // Input: Compound document with included
   714	 * validatePostPayload({
   715	 *   data: {
   716	 *     type: 'articles',
   717	 *     attributes: { title: 'My Article' },
   718	 *     relationships: {
   719	 *       author: {
   720	 *         data: { type: 'users', id: '42' }
   721	 *       }
   722	 *     }
   723	 *   },
   724	 *   included: [            // Related resource data
   725	 *     {
   726	 *       type: 'users',
   727	 *       id: '42',          // Must have ID
   728	 *       attributes: {
   729	 *         name: 'John Doe',
   730	 *         email: 'john@example.com'
   731	 *       }
   732	 *     }
   733	 *   ]
   734	 * }, scopes);
   735	 * // Output: true (included resources validated)
   736	 *
   737	 * @example
   738	 * // Input: Missing required type
   739	 * validatePostPayload({
   740	 *   data: {
   741	 *     attributes: { title: 'Missing Type' }
   742	 *   }
   743	 * }, scopes);
   744	 * // Throws: RestApiPayloadError
   745	 * // 'POST request "data" must have a non-empty "type" string'
   746	 *
   747	 * @description
   748	 * Used by:
   749	 * - rest-api-plugin.post() method before creating resources
   750	 * - Validates complete document structure
   751	 *
   752	 * Purpose:
   753	 * - Ensures JSON:API compliance for resource creation
   754	 * - Validates resource types exist in system
   755	 * - Checks relationship references are valid
   756	 * - Validates included resources have required fields
   757	 * - Enables compound document creation patterns
   758	 *
   759	 * Data flow:
   760	 * 1. Validates document has required 'data' property
   761	 * 2. Checks primary resource type exists in scopes
   762	 * 3. Validates attributes object if present
   763	 * 4. Validates each relationship if present
   764	 * 5. Validates included resources array if present
   765	 * 6. Returns true or throws descriptive error
   766	 */
   767	export function validatePostPayload (inputRecord, scopes = null) {
   768	  if (!inputRecord || typeof inputRecord !== 'object') {
   769	    throw new RestApiPayloadError(
   770	      'POST request body must be a JSON:API document object',
   771	      { path: 'body', expected: 'object', received: typeof inputRecord }
   772	    )
   773	  }
   774	
   775	  // Validate required 'data' property
   776	  if (!('data' in inputRecord)) {
   777	    throw new RestApiPayloadError(
   778	      'POST request body must have a "data" property',
   779	      { path: 'data', expected: 'property exists', received: 'missing' }
   780	    )
   781	  }
   782	
   783	  const { data, included } = inputRecord
   784	
   785	  // Validate primary data
   786	  if (!data || typeof data !== 'object' || Array.isArray(data)) {
   787	    throw new RestApiPayloadError(
   788	      'POST request "data" must be a single resource object',
   789	      { path: 'data', expected: 'object', received: Array.isArray(data) ? 'array' : typeof data }
   790	    )
   791	  }
   792	
   793	  if (typeof data.type !== 'string' || !data.type) {
   794	    throw new RestApiPayloadError(
   795	      'POST request "data" must have a non-empty "type" string',
   796	      { path: 'data.type', expected: 'non-empty string', received: data.type || 'empty' }
   797	    )
   798	  }
   799	
   800	  // Check if primary resource type is valid
   801	  if (scopes && !scopes[data.type]) {
   802	    throw new RestApiValidationError(
   803	      `POST request "data.type" '${data.type}' is not a valid resource type. No scope with this name exists.`,
   804	      { fields: ['data.type'], violations: [{ field: 'data.type', rule: 'valid_resource_type', message: `Resource type '${data.type}' does not exist` }] }
   805	    )
   806	  }
   807	
   808	  // For POST, id is optional (server may generate it)
   809	  if ('id' in data && data.id !== null && typeof data.id !== 'string' && typeof data.id !== 'number') {
   810	    throw new RestApiPayloadError(
   811	      'POST request "data.id" if present must be a string, number, or null',
   812	      { path: 'data.id', expected: 'string, number, or null', received: typeof data.id }
   813	    )
   814	  }
   815	
   816	  // Validate attributes if present
   817	  if ('attributes' in data) {
   818	    if (typeof data.attributes !== 'object' || data.attributes === null || Array.isArray(data.attributes)) {
   819	      throw new RestApiPayloadError(
   820	        'POST request "data.attributes" must be an object',
   821	        { path: 'data.attributes', expected: 'object', received: Array.isArray(data.attributes) ? 'array' : typeof data.attributes }
   822	      )
   823	    }
   824	  }
   825	
   826	  // Validate relationships if present
   827	  if ('relationships' in data) {
   828	    if (typeof data.relationships !== 'object' || data.relationships === null || Array.isArray(data.relationships)) {
   829	      throw new RestApiPayloadError(
   830	        'POST request "data.relationships" must be an object',
   831	        { path: 'data.relationships', expected: 'object', received: Array.isArray(data.relationships) ? 'array' : typeof data.relationships }
   832	      )
   833	    }
   834	
   835	    Object.entries(data.relationships).forEach(([relName, relationship]) => {
   836	      validateRelationship(relationship, relName, scopes)
   837	    })
   838	  }
   839	
   840	  // Validate included resources if present
   841	  if (included !== undefined) {
   842	    if (!Array.isArray(included)) {
   843	      throw new RestApiPayloadError(
   844	        'POST request "included" must be an array',
   845	        { path: 'included', expected: 'array', received: typeof included }
   846	      )
   847	    }
   848	
   849	    included.forEach((resource, index) => {
   850	      if (!resource || typeof resource !== 'object') {
   851	        throw new RestApiPayloadError(
   852	          `POST request "included[${index}]" must be a resource object`,
   853	          { path: `included[${index}]`, expected: 'object', received: typeof resource }
   854	        )
   855	      }
   856	
   857	      if (typeof resource.type !== 'string' || !resource.type) {
   858	        throw new RestApiPayloadError(
   859	          `POST request "included[${index}]" must have a non-empty "type" string`,
   860	          { path: `included[${index}].type`, expected: 'non-empty string', received: resource.type || 'empty' }
   861	        )
   862	      }
   863	
   864	      // Check if included resource type is valid
   865	      if (scopes && !scopes[resource.type]) {
   866	        throw new RestApiValidationError(
   867	          `POST request "included[${index}].type" '${resource.type}' is not a valid resource type. No scope with this name exists.`,
   868	          { fields: [`included[${index}].type`], violations: [{ field: `included[${index}].type`, rule: 'valid_resource_type', message: `Resource type '${resource.type}' does not exist` }] }
   869	        )
   870	      }
   871	
   872	      if (!('id' in resource) || resource.id === null || resource.id === undefined) {
   873	        throw new RestApiPayloadError(
   874	          `POST request "included[${index}]" must have a non-null "id"`,
   875	          { path: `included[${index}].id`, expected: 'non-null value', received: 'null or undefined' }
   876	        )
   877	      }
   878	
   879	      if (typeof resource.id !== 'string' && typeof resource.id !== 'number') {
   880	        throw new RestApiPayloadError(
   881	          `POST request "included[${index}].id" must be a string or number`,
   882	          { path: `included[${index}].id`, expected: 'string or number', received: typeof resource.id }
   883	        )
   884	      }
   885	
   886	      if ('attributes' in resource) {
   887	        if (typeof resource.attributes !== 'object' || resource.attributes === null || Array.isArray(resource.attributes)) {
   888	          throw new RestApiPayloadError(
   889	            `POST request "included[${index}].attributes" must be an object`,
   890	            { path: `included[${index}].attributes`, expected: 'object', received: Array.isArray(resource.attributes) ? 'array' : typeof resource.attributes }
   891	          )
   892	        }
   893	      }
   894	    })
   895	  }
   896	
   897	  return true
   898	}
   899	
   900	/**
   901	 * Validates a JSON:API document for PUT requests (full resource replacement)
   902	 *
   903	 * @param {Object} inputRecord - JSON:API document to validate
   904	 * @param {Object} inputRecord.data - Complete resource representation
   905	 * @param {string} inputRecord.data.type - Resource type (must match existing)
   906	 * @param {string|number} inputRecord.data.id - Resource ID (required)
   907	 * @param {Object} [inputRecord.data.attributes] - Complete attributes
   908	 * @param {Object} [inputRecord.data.relationships] - Complete relationships
   909	 * @param {Object} scopes - Scopes proxy to verify resource types exist
   910	 * @returns {boolean} True if valid
   911	 * @throws {RestApiValidationError|RestApiPayloadError} If validation fails
   912	 *
   913	 * @example
   914	 * // Input: Complete resource replacement
   915	 * validatePutPayload({
   916	 *   data: {
   917	 *     type: 'articles',
   918	 *     id: '123',                   // Required for PUT
   919	 *     attributes: {
   920	 *       title: 'Updated Title',
   921	 *       body: 'Completely new body text',
   922	 *       status: 'published'
   923	 *     }
   924	 *   }
   925	 * }, scopes);
   926	 * // Output: true
   927	 *
   928	 * @example
   929	 * // Input: Replace with new relationships
   930	 * validatePutPayload({
   931	 *   data: {
   932	 *     type: 'articles',
   933	 *     id: '123',
   934	 *     attributes: {
   935	 *       title: 'REST API Best Practices',
   936	 *       body: 'Here are some tips...'
   937	 *     },
   938	 *     relationships: {
   939	 *       author: {
   940	 *         data: { type: 'users', id: '99' }    // New author
   941	 *       },
   942	 *       tags: {
   943	 *         data: [                              // Replace all tags
   944	 *           { type: 'tags', id: '5' },
   945	 *           { type: 'tags', id: '6' },
   946	 *           { type: 'tags', id: '7' }
   947	 *         ]
   948	 *       },
   949	 *       featuredImage: {
   950	 *         data: null                           // Remove relationship
   951	 *       }
   952	 *     }
   953	 *   }
   954	 * }, scopes);
   955	 * // Output: true (complete replacement)
   956	 *
   957	 * @example
   958	 * // Input: Missing required ID
   959	 * validatePutPayload({
   960	 *   data: {
   961	 *     type: 'articles',
   962	 *     attributes: { title: 'No ID' }
   963	 *   }
   964	 * }, scopes);
   965	 * // Throws: RestApiPayloadError
   966	 * // 'PUT request "data" must have an "id" property'
   967	 *
   968	 * @example
   969	 * // Input: Includes not allowed
   970	 * validatePutPayload({
   971	 *   data: {
   972	 *     type: 'articles',
   973	 *     id: '123',
   974	 *     attributes: { title: 'Updated' }
   975	 *   },
   976	 *   included: [                        // Not allowed!
   977	 *     { type: 'users', id: '1' }
   978	 *   ]
   979	 * }, scopes);
   980	 * // Throws: RestApiPayloadError
   981	 * // 'PUT requests cannot include an "included" array for creating new resources'
   982	 *
   983	 * @description
   984	 * Used by:
   985	 * - rest-api-plugin.put() method for full replacement
   986	 * - Enforces PUT semantics vs PATCH
   987	 *
   988	 * Purpose:
   989	 * - Ensures complete resource replacement semantics
   990	 * - Requires ID to match URL parameter
   991	 * - Prevents included resources (no side effects)
   992	 * - Maintains idempotency of PUT operations
   993	 * - Differentiates from PATCH partial updates
   994	 *
   995	 * Data flow:
   996	 * 1. Validates document structure with data property
   997	 * 2. Ensures no included array present
   998	 * 3. Validates resource type exists in scopes
   999	 * 4. Requires ID property (unlike POST)
  1000	 * 5. Validates attributes and relationships if present
  1001	 * 6. Returns true or throws descriptive error
  1002	 */
  1003	export function validatePutPayload (inputRecord, scopes = null) {
  1004	  if (!inputRecord || typeof inputRecord !== 'object') {
  1005	    throw new RestApiPayloadError(
  1006	      'PUT request body must be a JSON:API document object',
  1007	      { path: 'body', expected: 'object', received: typeof inputRecord }
  1008	    )
  1009	  }
  1010	
  1011	  // Validate required 'data' property
  1012	  if (!('data' in inputRecord)) {
  1013	    throw new RestApiPayloadError(
  1014	      'PUT request body must have a "data" property',
  1015	      { path: 'data', expected: 'property exists', received: 'missing' }
  1016	    )
  1017	  }
  1018	
  1019	  const { data, included } = inputRecord
  1020	
  1021	  // PUT cannot have included array
  1022	  if (included !== undefined) {
  1023	    throw new RestApiPayloadError(
  1024	      'PUT requests cannot include an "included" array for creating new resources',
  1025	      { path: 'included', expected: 'undefined', received: 'array' }
  1026	    )
  1027	  }
  1028	
  1029	  // Validate primary data
  1030	  if (!data || typeof data !== 'object' || Array.isArray(data)) {
  1031	    throw new RestApiPayloadError(
  1032	      'PUT request "data" must be a single resource object',
  1033	      { path: 'data', expected: 'object', received: Array.isArray(data) ? 'array' : typeof data }
  1034	    )
  1035	  }
  1036	
  1037	  if (typeof data.type !== 'string' || !data.type) {
  1038	    throw new RestApiPayloadError(
  1039	      'PUT request "data" must have a non-empty "type" string',
  1040	      { path: 'data.type', expected: 'non-empty string', received: data.type || 'empty' }
  1041	    )
  1042	  }
  1043	
  1044	  // Check if resource type is valid
  1045	  if (scopes && !scopes[data.type]) {
  1046	    throw new RestApiValidationError(
  1047	      `PUT request "data.type" '${data.type}' is not a valid resource type. No scope with this name exists.`,
  1048	      { fields: ['data.type'], violations: [{ field: 'data.type', rule: 'valid_resource_type', message: `Resource type '${data.type}' does not exist` }] }
  1049	    )
  1050	  }
  1051	
  1052	  // For PUT, id is required
  1053	  if (!('id' in data)) {
  1054	    throw new RestApiPayloadError(
  1055	      'PUT request "data" must have an "id" property',
  1056	      { path: 'data.id', expected: 'property exists', received: 'missing' }
  1057	    )
  1058	  }
  1059	
  1060	  if (data.id === null || data.id === undefined || data.id === '') {
  1061	    throw new RestApiValidationError(
  1062	      'PUT request "data.id" cannot be null, undefined, or empty',
  1063	      { fields: ['data.id'], violations: [{ field: 'data.id', rule: 'not_empty', message: 'ID cannot be null, undefined, or empty' }] }
  1064	    )
  1065	  }
  1066	
  1067	  if (typeof data.id !== 'string' && typeof data.id !== 'number') {
  1068	    throw new RestApiPayloadError(
  1069	      'PUT request "data.id" must be a string or number',
  1070	      { path: 'data.id', expected: 'string or number', received: typeof data.id }
  1071	    )
  1072	  }
  1073	
  1074	  // Validate attributes if present
  1075	  if ('attributes' in data) {
  1076	    if (typeof data.attributes !== 'object' || data.attributes === null || Array.isArray(data.attributes)) {
  1077	      throw new RestApiPayloadError(
  1078	        'PUT request "data.attributes" must be an object',
  1079	        { path: 'data.attributes', expected: 'object', received: Array.isArray(data.attributes) ? 'array' : typeof data.attributes }
  1080	      )
  1081	    }
  1082	  }
  1083	
  1084	  // Validate relationships if present
  1085	  if ('relationships' in data) {
  1086	    if (typeof data.relationships !== 'object' || data.relationships === null || Array.isArray(data.relationships)) {
  1087	      throw new RestApiPayloadError(
  1088	        'PUT request "data.relationships" must be an object',
  1089	        { path: 'data.relationships', expected: 'object', received: Array.isArray(data.relationships) ? 'array' : typeof data.relationships }
  1090	      )
  1091	    }
  1092	
  1093	    Object.entries(data.relationships).forEach(([relName, relationship]) => {
  1094	      validateRelationship(relationship, relName, scopes)
  1095	    })
  1096	  }
  1097	
  1098	  return true
  1099	}
  1100	
  1101	/**
  1102	 * Validates a JSON:API document for PATCH requests (partial resource updates)
  1103	 *
  1104	 * @param {Object} inputRecord - JSON:API document to validate
  1105	 * @param {Object} inputRecord.data - Partial resource representation
  1106	 * @param {string} inputRecord.data.type - Resource type (must match existing)
  1107	 * @param {string|number} inputRecord.data.id - Resource ID (required)
  1108	 * @param {Object} [inputRecord.data.attributes] - Attributes to update
  1109	 * @param {Object} [inputRecord.data.relationships] - Relationships to update
  1110	 * @param {Object} scopes - Scopes proxy to verify resource types exist
  1111	 * @returns {boolean} True if valid
  1112	 * @throws {RestApiValidationError|RestApiPayloadError} If validation fails
  1113	 *
  1114	 * @example
  1115	 * // Input: Update single field
  1116	 * validatePatchPayload({
  1117	 *   data: {
  1118	 *     type: 'articles',
  1119	 *     id: '123',
  1120	 *     attributes: {
  1121	 *       title: 'New Title Only'     // Only title changes
  1122	 *       // body, status, etc. remain unchanged
  1123	 *     }
  1124	 *   }
  1125	 * }, scopes);
  1126	 * // Output: true
  1127	 *
  1128	 * @example
  1129	 * // Input: Update multiple fields
  1130	 * validatePatchPayload({
  1131	 *   data: {
  1132	 *     type: 'articles',
  1133	 *     id: '123',
  1134	 *     attributes: {
  1135	 *       status: 'published',
  1136	 *       publishedAt: '2023-11-15T10:00:00Z'
  1137	 *       // Other fields untouched
  1138	 *     }
  1139	 *   }
  1140	 * }, scopes);
  1141	 * // Output: true (partial update)
  1142	 *
  1143	 * @example
  1144	 * // Input: Update relationships only
  1145	 * validatePatchPayload({
  1146	 *   data: {
  1147	 *     type: 'articles',
  1148	 *     id: '123',
  1149	 *     relationships: {
  1150	 *       author: {
  1151	 *         data: { type: 'users', id: '456' }  // Change author
  1152	 *       }
  1153	 *       // Other relationships unchanged
  1154	 *     }
  1155	 *   }
  1156	 * }, scopes);
  1157	 * // Output: true
  1158	 *
  1159	 * @example
  1160	 * // Input: Clear relationships
  1161	 * validatePatchPayload({
  1162	 *   data: {
  1163	 *     type: 'articles',
  1164	 *     id: '123',
  1165	 *     relationships: {
  1166	 *       featuredImage: {
  1167	 *         data: null                // Remove to-one
  1168	 *       },
  1169	 *       tags: {
  1170	 *         data: []                  // Clear to-many
  1171	 *       }
  1172	 *     }
  1173	 *   }
  1174	 * }, scopes);
  1175	 * // Output: true (clearing is valid)
  1176	 *
  1177	 * @example
  1178	 * // Input: No changes specified
  1179	 * validatePatchPayload({
  1180	 *   data: {
  1181	 *     type: 'articles',
  1182	 *     id: '123'
  1183	 *     // Missing both attributes and relationships!
  1184	 *   }
  1185	 * }, scopes);
  1186	 * // Throws: RestApiValidationError
  1187	 * // 'PATCH request "data" must have at least one of "attributes" or "relationships"'
  1188	 *
  1189	 * @description
  1190	 * Used by:
  1191	 * - rest-api-plugin.patch() method for partial updates
  1192	 * - Enforces PATCH semantics vs PUT
  1193	 *
  1194	 * Purpose:
  1195	 * - Enables efficient partial updates (send only changes)
  1196	 * - Requires at least one change (attributes or relationships)
  1197	 * - Prevents included resources (no side effects)
  1198	 * - Reduces bandwidth for large resources
  1199	 * - Prevents race conditions in concurrent updates
  1200	 *
  1201	 * Data flow:
  1202	 * 1. Validates document structure with data property
  1203	 * 2. Ensures no included array present
  1204	 * 3. Validates resource type exists in scopes
  1205	 * 4. Requires ID property for targeting
  1206	 * 5. Requires at least attributes or relationships
  1207	 * 6. Validates each if present
  1208	 * 7. Returns true or throws descriptive error
  1209	 */
  1210	export function validatePatchPayload (inputRecord, scopes = null) {
  1211	  if (!inputRecord || typeof inputRecord !== 'object') {
  1212	    throw new RestApiPayloadError(
  1213	      'PATCH request body must be a JSON:API document object',
  1214	      { path: 'body', expected: 'object', received: typeof inputRecord }
  1215	    )
  1216	  }
  1217	
  1218	  // Validate required 'data' property
  1219	  if (!('data' in inputRecord)) {
  1220	    throw new RestApiPayloadError(
  1221	      'PATCH request body must have a "data" property',
  1222	      { path: 'data', expected: 'property exists', received: 'missing' }
  1223	    )
  1224	  }
  1225	
  1226	  const { data, included } = inputRecord
  1227	
  1228	  // PATCH cannot have included array
  1229	  if (included !== undefined) {
  1230	    throw new RestApiPayloadError(
  1231	      'PATCH requests cannot include an "included" array for creating new resources',
  1232	      { path: 'included', expected: 'undefined', received: 'array' }
  1233	    )
  1234	  }
  1235	
  1236	  // Validate primary data
  1237	  if (!data || typeof data !== 'object' || Array.isArray(data)) {
  1238	    throw new RestApiPayloadError(
  1239	      'PATCH request "data" must be a single resource object',
  1240	      { path: 'data', expected: 'object', received: Array.isArray(data) ? 'array' : typeof data }
  1241	    )
  1242	  }
  1243	
  1244	  if (typeof data.type !== 'string' || !data.type) {
  1245	    throw new RestApiPayloadError(
  1246	      'PATCH request "data" must have a non-empty "type" string',
  1247	      { path: 'data.type', expected: 'non-empty string', received: data.type || 'empty' }
  1248	    )
  1249	  }
  1250	
  1251	  // Check if resource type is valid
  1252	  if (scopes && !scopes[data.type]) {
  1253	    throw new RestApiValidationError(
  1254	      `PATCH request "data.type" '${data.type}' is not a valid resource type. No scope with this name exists.`,
  1255	      { fields: ['data.type'], violations: [{ field: 'data.type', rule: 'valid_resource_type', message: `Resource type '${data.type}' does not exist` }] }
  1256	    )
  1257	  }
  1258	
  1259	  // For PATCH, id is required
  1260	  if (!('id' in data)) {
  1261	    throw new RestApiPayloadError(
  1262	      'PATCH request "data" must have an "id" property',
  1263	      { path: 'data.id', expected: 'property exists', received: 'missing' }
  1264	    )
  1265	  }
  1266	
  1267	  if (data.id === null || data.id === undefined || data.id === '') {
  1268	    throw new RestApiValidationError(
  1269	      'PATCH request "data.id" cannot be null, undefined, or empty',
  1270	      { fields: ['data.id'], violations: [{ field: 'data.id', rule: 'not_empty', message: 'ID cannot be null, undefined, or empty' }] }
  1271	    )
  1272	  }
  1273	
  1274	  if (typeof data.id !== 'string' && typeof data.id !== 'number') {
  1275	    throw new RestApiPayloadError(
  1276	      'PATCH request "data.id" must be a string or number',
  1277	      { path: 'data.id', expected: 'string or number', received: typeof data.id }
  1278	    )
  1279	  }
  1280	
  1281	  // For PATCH, at least one of attributes or relationships should be present
  1282	  if (!('attributes' in data) && !('relationships' in data)) {
  1283	    throw new RestApiValidationError(
  1284	      'PATCH request "data" must have at least one of "attributes" or "relationships"',
  1285	      { fields: ['data'], violations: [{ field: 'data', rule: 'partial_update', message: 'Must include at least one of attributes or relationships' }] }
  1286	    )
  1287	  }
  1288	
  1289	  // Validate attributes if present
  1290	  if ('attributes' in data && data.attributes !== undefined) {
  1291	    if (typeof data.attributes !== 'object' || data.attributes === null || Array.isArray(data.attributes)) {
  1292	      throw new RestApiPayloadError(
  1293	        'PATCH request "data.attributes" must be an object',
  1294	        { path: 'data.attributes', expected: 'object', received: Array.isArray(data.attributes) ? 'array' : typeof data.attributes }
  1295	      )
  1296	    }
  1297	  }
  1298	
  1299	  // Validate relationships if present
  1300	  if ('relationships' in data && data.relationships !== undefined) {
  1301	    if (typeof data.relationships !== 'object' || data.relationships === null || Array.isArray(data.relationships)) {
  1302	      throw new RestApiPayloadError(
  1303	        'PATCH request "data.relationships" must be an object',
  1304	        { path: 'data.relationships', expected: 'object', received: Array.isArray(data.relationships) ? 'array' : typeof data.relationships }
  1305	      )
  1306	    }
  1307	
  1308	    Object.entries(data.relationships).forEach(([relName, relationship]) => {
  1309	      validateRelationship(relationship, relName, scopes)
  1310	    })
  1311	  }
  1312	
  1313	  return true
  1314	}
