Skip to content

Commit 31efb2e

Browse files
committed
wip: Add related_items output
#150
1 parent f2862ac commit 31efb2e

File tree

7 files changed

+369
-3
lines changed

7 files changed

+369
-3
lines changed

app/Http/Controllers/Api/V2/AbstractApiV2Controller.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ enum: [
8484
'shops',
8585
'shops.items',
8686
'variants',
87+
'related_items',
8788
]
8889
),
8990
),

app/Http/Controllers/Api/V2/SC/ItemController.php

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
use App\Http\Resources\SC\Item\ItemLinkResource;
1111
use App\Http\Resources\SC\Item\ItemResource;
1212
use App\Models\SC\Item\Item;
13+
use App\Support\QueryBuilder\Includes\IncludedPassthrough;
1314
use Illuminate\Database\Eloquent\Builder;
1415
use Illuminate\Database\Eloquent\ModelNotFoundException;
1516
use Illuminate\Http\Request;
@@ -18,6 +19,7 @@
1819
use Illuminate\Support\Facades\Validator;
1920
use OpenApi\Attributes as OA;
2021
use Spatie\QueryBuilder\AllowedFilter;
22+
use Spatie\QueryBuilder\AllowedInclude;
2123
use Spatie\QueryBuilder\QueryBuilder;
2224
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
2325

@@ -55,7 +57,10 @@ public function index(Request $request): AnonymousResourceCollection
5557
AllowedFilter::exact('manufacturer', 'manufacturer.name'),
5658
AllowedFilter::custom('variants', new ItemVariantsFilter),
5759
])
58-
->allowedIncludes(ItemResource::validIncludes())
60+
->allowedIncludes(array_merge(
61+
ItemResource::validIncludes(),
62+
[AllowedInclude::custom('related_items', new IncludedPassthrough)]
63+
))
5964
->paginate($this->limit)
6065
->appends(request()->query());
6166

@@ -105,7 +110,10 @@ public function show(Request $request)
105110
$query->where('uuid', $identifier)
106111
->orWhere('name', $identifier);
107112
})
108-
->allowedIncludes(ItemResource::validIncludes())
113+
->allowedIncludes(array_merge(
114+
ItemResource::validIncludes(),
115+
[AllowedInclude::custom('related_items', new IncludedPassthrough)]
116+
))
109117
->with([
110118
'dimensions',
111119
'manufacturer',
@@ -187,7 +195,10 @@ public function search(ItemSearchRequest $request): JsonResource
187195
AllowedFilter::exact('manufacturer', 'manufacturer.name'),
188196
AllowedFilter::custom('variants', new ItemVariantsFilter),
189197
])
190-
->allowedIncludes(['shops.items']);
198+
->allowedIncludes(array_merge(
199+
['shops.items'],
200+
[AllowedInclude::custom('related_items', new IncludedPassthrough)]
201+
));
191202

192203
if ($request->has('shop') && $request->get('shop') !== null) {
193204
$items->whereRelation('shops', 'uuid', $request->get('shop'));

app/Http/Resources/SC/Item/ItemResource.php

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
use App\Http\Resources\SC\Manufacturer\ManufacturerLinkResource;
3636
use App\Http\Resources\SC\Shop\ShopResource;
3737
use App\Http\Resources\SC\Vehicle\Weapon\VehicleWeaponResource;
38+
use App\Support\Items\RelatedItemsBuilder;
3839
use Illuminate\Http\Request;
3940
use OpenApi\Attributes as OA;
4041

@@ -138,6 +139,58 @@
138139
],
139140
)]
140141

142+
#[OA\Schema(
143+
schema: 'item_related_link_v2',
144+
title: 'Related Item Link',
145+
description: 'Minimal link information for a related item',
146+
properties: [
147+
new OA\Property(property: 'uuid', type: 'string'),
148+
new OA\Property(property: 'name', type: 'string'),
149+
new OA\Property(property: 'variant_name', type: 'string', nullable: true),
150+
new OA\Property(property: 'link', type: 'string'),
151+
],
152+
type: 'object'
153+
)]
154+
155+
#[OA\Schema(
156+
schema: 'item_related_link_ext_v2',
157+
title: 'Related Item Link (Extended)',
158+
description: 'Related item link with basic classification',
159+
allOf: [
160+
new OA\Schema(ref: '#/components/schemas/item_related_link_v2'),
161+
new OA\Schema(
162+
properties: [
163+
new OA\Property(property: 'type', type: 'string', nullable: true),
164+
new OA\Property(property: 'sub_type', type: 'string', nullable: true),
165+
],
166+
type: 'object'
167+
),
168+
],
169+
)]
170+
171+
#[OA\Schema(
172+
schema: 'item_related_items_v2',
173+
title: 'Related Items',
174+
description: 'Aggregated related information for base/variants and set items',
175+
properties: [
176+
new OA\Property(property: 'set_name', type: 'string', nullable: true),
177+
new OA\Property(property: 'base_item', ref: '#/components/schemas/item_related_link_v2', nullable: true),
178+
new OA\Property(
179+
property: 'variant_items',
180+
type: 'array',
181+
items: new OA\Items(ref: '#/components/schemas/item_related_link_v2'),
182+
nullable: true,
183+
),
184+
new OA\Property(
185+
property: 'set_items',
186+
type: 'array',
187+
items: new OA\Items(ref: '#/components/schemas/item_related_link_ext_v2'),
188+
nullable: true,
189+
),
190+
],
191+
type: 'object'
192+
)]
193+
141194
#[OA\Schema(
142195
schema: 'item_v2',
143196
title: 'Item',
@@ -188,6 +241,12 @@
188241
),
189242
]
190243
),
244+
new OA\Schema(
245+
properties: [
246+
new OA\Property(property: 'related_items', ref: '#/components/schemas/item_related_items_v2', nullable: true),
247+
],
248+
type: 'object'
249+
),
191250
]
192251
)]
193252
class ItemResource extends AbstractTranslationResource
@@ -208,6 +267,16 @@ public function toArray(Request $request): array
208267

209268
$vehicleItem = $this->vehicleItem;
210269

270+
// Determine if 'related_items' has been requested via include
271+
$includeParam = $request->query('include');
272+
$includeValues = [];
273+
if (is_string($includeParam)) {
274+
$includeValues = array_map('trim', explode(',', $includeParam));
275+
} elseif (is_array($includeParam)) {
276+
$includeValues = $includeParam;
277+
}
278+
$includeRelated = in_array('related_items', $includeValues, true);
279+
211280
return [
212281
'uuid' => $this->uuid,
213282
'name' => $this->name,
@@ -257,6 +326,9 @@ public function toArray(Request $request): array
257326
'base_variant' => new ItemLinkResource($this->baseVariant),
258327
]),
259328
'variants' => ItemLinkResource::collection($this->whenLoaded('variants')),
329+
$this->mergeWhen($includeRelated, [
330+
'related_items' => (new RelatedItemsBuilder)->build($this->resource),
331+
]),
260332
'updated_at' => $this->updated_at,
261333
'version' => $this->version,
262334
];
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Support\Items;
6+
7+
use App\Models\SC\Item\Item;
8+
9+
class RelatedItemsBuilder
10+
{
11+
/**
12+
* Build the related_items payload for a given Item.
13+
*
14+
* @return array{set_name:?string,base_item:?array,variant_items:array<int,array>,set_items:array<int,array>}
15+
*/
16+
public function build(Item $item): array
17+
{
18+
[$baseItem, $groupItems] = $this->gatherVariantGroup($item);
19+
20+
$names = collect($groupItems)->pluck('name')->all();
21+
if ($baseItem !== null) {
22+
array_unshift($names, $baseItem->name);
23+
}
24+
25+
[$setName, $variantNames] = $this->computeSetNameAndVariantNames($names, $baseItem, $groupItems);
26+
27+
$base = $baseItem !== null ? $this->toBaseLink($baseItem, $setName, true) : null;
28+
$variants = collect($groupItems)
29+
->filter(fn (Item $i) => $i->uuid !== $item->uuid) // exclude current item
30+
->map(function (Item $it) use ($variantNames) {
31+
$link = $this->toBaseLink($it, null, false);
32+
$link['variant_name'] = $variantNames[$it->uuid] ?? null;
33+
34+
return $link;
35+
})
36+
->values()
37+
->all();
38+
39+
$setItems = $this->findSetItems($item);
40+
41+
return [
42+
'set_name' => $setName,
43+
'base_item' => $base,
44+
'variant_items' => $variants,
45+
'set_items' => $setItems,
46+
];
47+
}
48+
49+
/**
50+
* Determine base item and full variant group for an item.
51+
*
52+
* @return array{0:?Item,1:array<int,Item>}
53+
*/
54+
public function gatherVariantGroup(Item $item): array
55+
{
56+
if ($item->base_id === null) {
57+
$base = $item;
58+
$siblings = $item->variants()->get()->all();
59+
} else {
60+
$base = $item->baseVariant()->first();
61+
$siblings = $base?->variants()->get()->all() ?? [];
62+
}
63+
64+
return [$base, $siblings];
65+
}
66+
67+
/**
68+
* Compute set name and per-item variant names.
69+
* - set name: longest common prefix among names
70+
* - variant name: item name with the set name prefix removed (trimmed); if empty, "Base".
71+
*
72+
* @param array<int,string> $names
73+
* @param array<int,Item> $group
74+
* @return array{0:?string,1:array<string,string>} [setName, map(uuid=>variantName)]
75+
*/
76+
public function computeSetNameAndVariantNames(array $names, ?Item $base, array $group): array
77+
{
78+
$rawPrefix = $this->longestCommonPrefix($names);
79+
if ($rawPrefix !== null) {
80+
$endsWithSpace = str_ends_with($rawPrefix, ' ');
81+
$setName = rtrim($rawPrefix);
82+
if (! $endsWithSpace) {
83+
$lastSpace = strrpos($setName, ' ');
84+
if ($lastSpace !== false) {
85+
$setName = substr($setName, 0, $lastSpace);
86+
}
87+
}
88+
$setName = $setName === '' ? null : $setName;
89+
} else {
90+
$setName = null;
91+
}
92+
93+
$map = [];
94+
if ($base !== null) {
95+
$map[$base->uuid] = 'Base';
96+
}
97+
98+
foreach ($group as $it) {
99+
$variant = $this->stripPrefix($it->name, (string) $setName);
100+
$map[$it->uuid] = $variant === '' ? 'Base' : $variant;
101+
}
102+
103+
return [$setName, $map];
104+
}
105+
106+
/**
107+
* Find set items for armor/clothing by replacing the part token in class_name.
108+
*
109+
* @return array<int,array{uuid:string,name:string,type:?string,sub_type:?string,link:string}>
110+
*/
111+
public function findSetItems(Item $item): array
112+
{
113+
$className = $item->class_name ?? '';
114+
if ($className === '') {
115+
return [];
116+
}
117+
118+
$parts = config('item_sets.parts', ['helmet', 'core', 'arms', 'legs']);
119+
$currentPart = null;
120+
foreach ($parts as $part) {
121+
if (str_contains($className, '_'.$part.'_')) {
122+
$currentPart = $part;
123+
break;
124+
}
125+
}
126+
if ($currentPart === null) {
127+
return [];
128+
}
129+
130+
$set = [];
131+
foreach ($parts as $part) {
132+
if ($part === $currentPart) {
133+
continue;
134+
}
135+
$candidate = $this->replaceFirst('_'.$currentPart.'_', '_'.$part.'_', $className);
136+
$found = Item::query()->where('class_name', $candidate)->first();
137+
if ($found !== null && $found->uuid !== $item->uuid) {
138+
$set[] = [
139+
'uuid' => $found->uuid,
140+
'name' => $found->name,
141+
'type' => $found->type,
142+
'sub_type' => $found->sub_type,
143+
'link' => $this->makeLink($found->uuid),
144+
];
145+
}
146+
}
147+
148+
return $set;
149+
}
150+
151+
private function toBaseLink(Item $it, ?string $setName, bool $includeVariantName): array
152+
{
153+
$link = [
154+
'uuid' => $it->uuid,
155+
'name' => $it->name,
156+
'link' => $this->makeLink($it->uuid),
157+
];
158+
if ($includeVariantName && $setName !== null) {
159+
$variant = $this->stripPrefix($it->name, $setName);
160+
$link['variant_name'] = $variant === '' ? 'Base' : $variant;
161+
}
162+
163+
return $link;
164+
}
165+
166+
private function makeLink(string $uuid): string
167+
{
168+
$baseUrl = rtrim((string) config('app.url'), '/');
169+
170+
return $baseUrl.'/api/v2/items/'.$uuid;
171+
}
172+
173+
private function replaceFirst(string $search, string $replace, string $subject): string
174+
{
175+
$pos = strpos($subject, $search);
176+
if ($pos === false) {
177+
return $subject;
178+
}
179+
180+
return substr($subject, 0, $pos).$replace.substr($subject, $pos + strlen($search));
181+
}
182+
183+
private function longestCommonPrefix(array $strings): ?string
184+
{
185+
if (count($strings) === 0) {
186+
return null;
187+
}
188+
$prefix = $strings[0];
189+
foreach ($strings as $s) {
190+
$i = 0;
191+
$max = min(strlen($prefix), strlen($s));
192+
while ($i < $max && $prefix[$i] === $s[$i]) {
193+
$i++;
194+
}
195+
$prefix = substr($prefix, 0, $i);
196+
if ($prefix === '') {
197+
return null;
198+
}
199+
}
200+
201+
return $prefix;
202+
}
203+
204+
private function stripPrefix(string $name, string $prefix): string
205+
{
206+
if ($prefix === '') {
207+
return $name;
208+
}
209+
if (str_starts_with($name, $prefix)) {
210+
$rest = substr($name, strlen($prefix));
211+
212+
return ltrim($rest);
213+
}
214+
215+
return $name;
216+
}
217+
}

0 commit comments

Comments
 (0)