FixedSerializerContextBuilder.php 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529
  1. <?php
  2. namespace Webkul\BagistoApi\GraphQl\Serializer;
  3. use ApiPlatform\GraphQl\Serializer\SerializerContextBuilderInterface;
  4. use ApiPlatform\Metadata\GraphQl\Mutation;
  5. use ApiPlatform\Metadata\GraphQl\Operation;
  6. use GraphQL\Type\Definition\ResolveInfo;
  7. use Illuminate\Database\Eloquent\Model;
  8. use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
  9. use Webkul\BagistoApi\Models\CustomerOrderItem;
  10. use Webkul\BagistoApi\Models\CustomerOrder;
  11. use Webkul\BagistoApi\Models\CustomerInvoiceItem;
  12. use Webkul\BagistoApi\Models\CustomerInvoice;
  13. use Webkul\BagistoApi\Models\CustomerInvoiceAddress;
  14. use Webkul\BagistoApi\Models\CustomerOrderShipment;
  15. use Webkul\BagistoApi\Models\CustomerOrderShipmentItem;
  16. /**
  17. * Decorates the GraphQL SerializerContextBuilder to fix attribute resolution
  18. * for multi-word resource names (e.g., CompareItem).
  19. *
  20. * API Platform's default implementation uses the name converter to denormalize
  21. * field names in the selection set (e.g., compareItem → compare_item), but then
  22. * looks up the wrap field name without denormalization (using lcfirst(shortName)).
  23. * This mismatch causes empty attributes for multi-word resource names.
  24. *
  25. * Also ensures that nested relationships (like CustomerOrder.items) properly
  26. * include all requested fields in the serialization context.
  27. */
  28. class FixedSerializerContextBuilder implements SerializerContextBuilderInterface
  29. {
  30. public function __construct(
  31. private readonly SerializerContextBuilderInterface $decorated,
  32. private readonly ?NameConverterInterface $nameConverter = null,
  33. ) {}
  34. public function create(?string $resourceClass, Operation $operation, array $resolverContext, bool $normalization): array
  35. {
  36. $context = $this->decorated->create($resourceClass, $operation, $resolverContext, $normalization);
  37. // Ensure nested CustomerOrderItem fields are always included
  38. // This handles the case where nested items don't trigger their own context builder call
  39. if ($normalization && $resourceClass === CustomerOrder::class) {
  40. // When processing the parent order, also ensure nested items have proper attributes
  41. $attributes = $context['attributes'] ?? [];
  42. // Check if items field is being requested
  43. if (isset($attributes['items']) && is_array($attributes['items'])) {
  44. $this->ensureNestedItemsAttributes($attributes['items']);
  45. }
  46. // Check if addresses field is being requested
  47. if (isset($attributes['addresses']) && is_array($attributes['addresses'])) {
  48. $this->ensureNestedAddressesAttributes($attributes['addresses']);
  49. }
  50. // Check if shipments field is being requested
  51. if (isset($attributes['shipments']) && is_array($attributes['shipments'])) {
  52. $this->ensureNestedShipmentsAttributes($attributes['shipments']);
  53. }
  54. }
  55. // Ensure nested CustomerInvoiceItem fields are always included
  56. if ($normalization && $resourceClass === CustomerInvoice::class) {
  57. $attributes = $context['attributes'] ?? [];
  58. // Check if items field is being requested
  59. if (isset($attributes['items']) && is_array($attributes['items'])) {
  60. $this->ensureNestedInvoiceItemsAttributes($attributes['items']);
  61. }
  62. // Check if addresses field is being requested
  63. if (isset($attributes['addresses']) && is_array($attributes['addresses'])) {
  64. $this->ensureNestedAddressesAttributes($attributes['addresses']);
  65. }
  66. }
  67. // Handle direct nested CustomerOrderItem serialization
  68. if ($normalization && $resourceClass === CustomerOrderItem::class) {
  69. $context = $this->ensureCustomerOrderItemAttributes($context, $resolverContext);
  70. }
  71. // Handle direct nested CustomerInvoiceItem serialization
  72. if ($normalization && $resourceClass === CustomerInvoiceItem::class) {
  73. $context = $this->ensureCustomerInvoiceItemAttributes($context, $resolverContext);
  74. }
  75. // Handle direct nested CustomerInvoiceAddress serialization
  76. if ($normalization && $resourceClass === CustomerInvoiceAddress::class) {
  77. $context = $this->ensureAddressAttributes($context, $resolverContext);
  78. }
  79. // Handle direct nested CustomerOrderShipment serialization
  80. if ($normalization && $resourceClass === CustomerOrderShipment::class) {
  81. $context = $this->ensureShipmentAttributes($context, $resolverContext);
  82. }
  83. // Handle direct nested CustomerOrderShipmentItem serialization
  84. if ($normalization && $resourceClass === CustomerOrderShipmentItem::class) {
  85. $context = $this->ensureShipmentItemAttributes($context, $resolverContext);
  86. }
  87. if (! $normalization || ! ($operation instanceof Mutation)) {
  88. return $context;
  89. }
  90. if (! empty($context['attributes'] ?? null)) {
  91. return $context;
  92. }
  93. $wrapFieldName = lcfirst($operation->getShortName());
  94. if ($this->nameConverter) {
  95. $denormalizedName = $this->nameConverter->denormalize($wrapFieldName, $resourceClass);
  96. if ($denormalizedName !== $wrapFieldName) {
  97. $context['attributes'] = $this->rebuildMutationAttributes(
  98. $resourceClass,
  99. $operation,
  100. $resolverContext,
  101. $denormalizedName
  102. );
  103. }
  104. }
  105. return $context;
  106. }
  107. /**
  108. * Ensure nested items' attributes include qty fields
  109. */
  110. private function ensureNestedItemsAttributes(array $itemsAttributes): void
  111. {
  112. // The itemsAttributes should contain edges > node structure for paginated results
  113. // Recursively ensure qty fields are in node attributes
  114. if (isset($itemsAttributes['edges']) && is_array($itemsAttributes['edges'])) {
  115. if (isset($itemsAttributes['edges']['node']) && is_array($itemsAttributes['edges']['node'])) {
  116. $this->addQtyFieldsToAttributes($itemsAttributes['edges']['node']);
  117. // Also add database column names in case they're being used
  118. $this->addQtyFieldsToAttributes($itemsAttributes['edges']);
  119. }
  120. }
  121. }
  122. /**
  123. * Add qty fields to an attributes array
  124. */
  125. private function addQtyFieldsToAttributes(array &$attributes): void
  126. {
  127. $qtyFields = ['qty_ordered', 'qty_shipped', 'qty_invoiced', 'qty_canceled', 'qty_refunded', 'baseImage'];
  128. foreach ($qtyFields as $field) {
  129. $attributes[$field] = true;
  130. }
  131. }
  132. /**
  133. * Ensure nested invoice items' attributes include qty field
  134. */
  135. private function ensureNestedInvoiceItemsAttributes(array &$itemsAttributes): void
  136. {
  137. // The itemsAttributes should contain edges > node structure for paginated results
  138. if (isset($itemsAttributes['edges']) && is_array($itemsAttributes['edges'])) {
  139. if (isset($itemsAttributes['edges']['node']) && is_array($itemsAttributes['edges']['node'])) {
  140. $this->addInvoiceItemFieldsToAttributes($itemsAttributes['edges']['node']);
  141. $this->addInvoiceItemFieldsToAttributes($itemsAttributes['edges']);
  142. }
  143. }
  144. }
  145. /**
  146. * Ensure nested addresses' attributes are properly set
  147. */
  148. private function ensureNestedAddressesAttributes(array &$addressAttributes): void
  149. {
  150. // The addressAttributes should contain edges > node structure for paginated results
  151. if (isset($addressAttributes['edges']) && is_array($addressAttributes['edges'])) {
  152. if (isset($addressAttributes['edges']['node']) && is_array($addressAttributes['edges']['node'])) {
  153. $this->addAddressFieldsToAttributes($addressAttributes['edges']['node']);
  154. $this->addAddressFieldsToAttributes($addressAttributes['edges']);
  155. }
  156. }
  157. }
  158. /**
  159. * Add invoice item fields to an attributes array
  160. */
  161. private function addInvoiceItemFieldsToAttributes(array &$attributes): void
  162. {
  163. $itemFields = ['id', 'qty', 'sku', 'name', 'price', 'base_price', 'total', 'base_total', 'tax_amount', 'discount_amount'];
  164. foreach ($itemFields as $field) {
  165. $attributes[$field] = true;
  166. }
  167. }
  168. /**
  169. * Add address fields to an attributes array
  170. */
  171. private function addAddressFieldsToAttributes(array &$attributes): void
  172. {
  173. // Include both snake_case and camelCase versions
  174. $addressFields = [
  175. 'id',
  176. 'name',
  177. 'address',
  178. 'city',
  179. 'state',
  180. 'postcode',
  181. 'country_id',
  182. 'countryId',
  183. 'phone',
  184. 'address_type',
  185. 'addressType',
  186. 'first_name',
  187. 'firstName',
  188. 'last_name',
  189. 'lastName',
  190. 'country',
  191. ];
  192. foreach ($addressFields as $field) {
  193. $attributes[$field] = true;
  194. }
  195. }
  196. /**
  197. * Ensure CustomerOrderItem attributes always include qty fields
  198. */
  199. private function ensureCustomerOrderItemAttributes(array $context, array $resolverContext): array
  200. {
  201. // Always ensure qty fields are in the attributes for item serialization
  202. $attributes = $context['attributes'] ?? [];
  203. // Use snake_case field names to match the denormalization used by the serializer
  204. $qtyFields = ['id', 'qty_ordered', 'qty_shipped', 'qty_invoiced', 'qty_canceled', 'qty_refunded', 'sku', 'name', 'price', 'base_price', 'total', 'base_total', 'type', 'baseImage'];
  205. // Ensure attributes includes all qty fields
  206. foreach ($qtyFields as $field) {
  207. if (is_array($attributes)) {
  208. if (array_key_exists('qty_ordered', $attributes) || !empty($attributes)) {
  209. $attributes[$field] = true;
  210. } else {
  211. $attributes[] = $field;
  212. }
  213. } else {
  214. if (!in_array($field, (array)$attributes)) {
  215. $attributes[] = $field;
  216. }
  217. }
  218. }
  219. $context['attributes'] = $attributes;
  220. return $context;
  221. }
  222. /**
  223. * Ensure CustomerInvoiceItem attributes include qty field
  224. */
  225. private function ensureCustomerInvoiceItemAttributes(array $context, array $resolverContext): array
  226. {
  227. $attributes = $context['attributes'] ?? [];
  228. $fields = ['id', 'qty', 'sku', 'name', 'price', 'base_price', 'total', 'base_total', 'tax_amount', 'discount_amount'];
  229. foreach ($fields as $field) {
  230. if (is_array($attributes)) {
  231. $attributes[$field] = true;
  232. }
  233. }
  234. $context['attributes'] = $attributes;
  235. return $context;
  236. }
  237. /**
  238. * Ensure address attributes are properly set
  239. */
  240. private function ensureAddressAttributes(array $context, array $resolverContext): array
  241. {
  242. $attributes = $context['attributes'] ?? [];
  243. $fields = ['id', 'name', 'address', 'city', 'state', 'postcode', 'country_id', 'phone', 'address_type'];
  244. foreach ($fields as $field) {
  245. if (is_array($attributes)) {
  246. $attributes[$field] = true;
  247. }
  248. }
  249. $context['attributes'] = $attributes;
  250. return $context;
  251. }
  252. /**
  253. * Ensure nested shipments' attributes include items and addresses
  254. */
  255. private function ensureNestedShipmentsAttributes(array &$shipmentsAttributes): void
  256. {
  257. // The shipmentsAttributes should contain edges > node structure for paginated results
  258. if (isset($shipmentsAttributes['edges']) && is_array($shipmentsAttributes['edges'])) {
  259. if (isset($shipmentsAttributes['edges']['node']) && is_array($shipmentsAttributes['edges']['node'])) {
  260. $this->addShipmentFieldsToAttributes($shipmentsAttributes['edges']['node']);
  261. // Also ensure nested fields within the node are properly handled
  262. if (isset($shipmentsAttributes['edges']['node']['shippingAddress']) && is_array($shipmentsAttributes['edges']['node']['shippingAddress'])) {
  263. $this->addAddressFieldsToAttributes($shipmentsAttributes['edges']['node']['shippingAddress']);
  264. }
  265. if (isset($shipmentsAttributes['edges']['node']['items']) && is_array($shipmentsAttributes['edges']['node']['items'])) {
  266. if (isset($shipmentsAttributes['edges']['node']['items']['edges']) && is_array($shipmentsAttributes['edges']['node']['items']['edges'])) {
  267. if (isset($shipmentsAttributes['edges']['node']['items']['edges']['node']) && is_array($shipmentsAttributes['edges']['node']['items']['edges']['node'])) {
  268. $this->addShipmentItemFieldsToAttributes($shipmentsAttributes['edges']['node']['items']['edges']['node']);
  269. }
  270. }
  271. }
  272. $this->addShipmentFieldsToAttributes($shipmentsAttributes['edges']);
  273. }
  274. }
  275. }
  276. /**
  277. * Add shipment fields to an attributes array
  278. */
  279. private function addShipmentFieldsToAttributes(array &$attributes): void
  280. {
  281. // Include both snake_case and camelCase versions to handle serializer name conversion
  282. $shipmentFields = [
  283. 'id',
  284. 'status',
  285. 'total_qty',
  286. 'totalQty',
  287. 'total_weight',
  288. 'totalWeight',
  289. 'carrier_code',
  290. 'carrierCode',
  291. 'carrier_title',
  292. 'carrierTitle',
  293. 'track_number',
  294. 'trackNumber',
  295. 'email_sent',
  296. 'emailSent',
  297. 'shipping_number',
  298. 'shippingNumber',
  299. 'payment_method_title',
  300. 'paymentMethodTitle',
  301. 'shipping_method_title',
  302. 'shippingMethodTitle',
  303. 'created_at',
  304. 'createdAt',
  305. 'items',
  306. 'shipping_address',
  307. 'shippingAddress',
  308. 'billing_address',
  309. 'billingAddress',
  310. ];
  311. foreach ($shipmentFields as $field) {
  312. $attributes[$field] = true;
  313. }
  314. }
  315. /**
  316. * Add shipment item fields to an attributes array
  317. */
  318. private function addShipmentItemFieldsToAttributes(array &$attributes): void
  319. {
  320. $itemFields = ['id', 'sku', 'name', 'qty', 'weight', 'description', 'order_item_id'];
  321. foreach ($itemFields as $field) {
  322. $attributes[$field] = true;
  323. }
  324. }
  325. /**
  326. * Ensure CustomerOrderShipment attributes are properly set
  327. */
  328. private function ensureShipmentAttributes(array $context, array $resolverContext): array
  329. {
  330. $attributes = $context['attributes'] ?? [];
  331. // Include both snake_case and camelCase versions
  332. $fields = [
  333. 'id',
  334. 'status',
  335. 'total_qty',
  336. 'totalQty',
  337. 'total_weight',
  338. 'totalWeight',
  339. 'carrier_code',
  340. 'carrierCode',
  341. 'carrier_title',
  342. 'carrierTitle',
  343. 'track_number',
  344. 'trackNumber',
  345. 'email_sent',
  346. 'emailSent',
  347. 'shipping_number',
  348. 'shippingNumber',
  349. 'payment_method_title',
  350. 'paymentMethodTitle',
  351. 'shipping_method_title',
  352. 'shippingMethodTitle',
  353. 'created_at',
  354. 'createdAt',
  355. 'items',
  356. 'shipping_address',
  357. 'shippingAddress',
  358. 'billing_address',
  359. 'billingAddress',
  360. ];
  361. foreach ($fields as $field) {
  362. if (is_array($attributes)) {
  363. $attributes[$field] = true;
  364. }
  365. }
  366. $context['attributes'] = $attributes;
  367. return $context;
  368. }
  369. /**
  370. * Ensure CustomerOrderShipmentItem attributes are properly set
  371. */
  372. private function ensureShipmentItemAttributes(array $context, array $resolverContext): array
  373. {
  374. $attributes = $context['attributes'] ?? [];
  375. $fields = ['id', 'sku', 'name', 'qty', 'weight', 'description'];
  376. foreach ($fields as $field) {
  377. if (is_array($attributes)) {
  378. $attributes[$field] = true;
  379. }
  380. }
  381. $context['attributes'] = $attributes;
  382. return $context;
  383. }
  384. /**
  385. * Rebuild mutation attributes using the denormalized wrap field name
  386. *
  387. * For Eloquent models, inner field names are denormalized (camelCase → snake_case)
  388. * since Eloquent properties use snake_case. For non-Eloquent response models
  389. * (DTOs, action responses), inner field names are kept as-is since PHP properties
  390. * use camelCase.
  391. */
  392. private function rebuildMutationAttributes(
  393. ?string $resourceClass,
  394. Operation $operation,
  395. array $resolverContext,
  396. string $denormalizedWrapFieldName,
  397. ): array {
  398. if (isset($resolverContext['fields'])) {
  399. $fields = $resolverContext['fields'];
  400. } elseif (isset($resolverContext['info']) && $resolverContext['info'] instanceof ResolveInfo) {
  401. $fields = $resolverContext['info']->getFieldSelection(\PHP_INT_MAX);
  402. } else {
  403. return [];
  404. }
  405. // Eloquent models AND admin output resources expose non-camelCase
  406. // properties (admin DTOs declare snake_case props via AcceptsCamelCaseWrites),
  407. // so their inner mutation field names must be denormalized
  408. // (incrementId → increment_id) for the serializer to read them. Shop
  409. // POPOs with camelCase props keep their field names as-is.
  410. $isEloquent = $resourceClass && (
  411. is_subclass_of($resourceClass, Model::class)
  412. || str_starts_with($resourceClass, 'Webkul\\BagistoApi\\Admin\\')
  413. );
  414. /** Denormalize top-level keys to locate the wrap field */
  415. $topLevel = [];
  416. foreach ($fields as $key => $value) {
  417. $denormalizedKey = $this->nameConverter
  418. ? $this->nameConverter->denormalize((string) $key, $resourceClass)
  419. : $key;
  420. $topLevel[$denormalizedKey] = $value;
  421. }
  422. $innerFields = $topLevel[$denormalizedWrapFieldName] ?? [];
  423. if (! \is_array($innerFields)) {
  424. return [];
  425. }
  426. /**
  427. * For Eloquent models, denormalize inner field names (e.g., productId → product_id).
  428. * For non-Eloquent models, keep camelCase field names as-is (they match PHP properties).
  429. */
  430. return $this->replaceIdKeys($innerFields, $resourceClass, $isEloquent);
  431. }
  432. /**
  433. * Replace _id keys with id and optionally denormalize field names
  434. */
  435. private function replaceIdKeys(array $fields, ?string $resourceClass, bool $denormalizeKeys = true): array
  436. {
  437. $denormalizedFields = [];
  438. foreach ($fields as $key => $value) {
  439. if ('_id' === $key) {
  440. $denormalizedFields['id'] = $fields['_id'];
  441. continue;
  442. }
  443. $denormalizedKey = ($denormalizeKeys && $this->nameConverter)
  444. ? $this->nameConverter->denormalize((string) $key, $resourceClass)
  445. : $key;
  446. $denormalizedFields[$denormalizedKey] = \is_array($value)
  447. ? $this->replaceIdKeys($value, $resourceClass, $denormalizeKeys)
  448. : $value;
  449. }
  450. return $denormalizedFields;
  451. }
  452. }