mirror of
https://github.com/Part-DB/Part-DB-server.git
synced 2026-07-27 11:41:36 +00:00
Added MCP tools to list footprints, storelocations, etc. via MCP
This commit is contained in:
parent
3056809afd
commit
f14418d889
14 changed files with 770 additions and 0 deletions
|
|
@ -33,6 +33,8 @@ use ApiPlatform\Metadata\Delete;
|
|||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Link;
|
||||
use ApiPlatform\Metadata\McpTool;
|
||||
use ApiPlatform\Metadata\McpToolCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use ApiPlatform\OpenApi\Model\Operation;
|
||||
|
|
@ -40,7 +42,12 @@ use ApiPlatform\Serializer\Filter\PropertyFilter;
|
|||
use App\ApiPlatform\Filter\LikeFilter;
|
||||
use App\Entity\Attachments\Attachment;
|
||||
use App\Entity\EDA\EDACategoryInfo;
|
||||
use App\Mcp\DTO\ElementByIdInput;
|
||||
use App\Mcp\DTO\StructuralElementOverview;
|
||||
use App\Mcp\DTO\StructuralElementSearchInput;
|
||||
use App\Repository\Parts\CategoryRepository;
|
||||
use App\State\Mcp\GetStructuralElementDetailsProcessor;
|
||||
use App\State\Mcp\ListStructuralElementsProcessor;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use App\Entity\Attachments\CategoryAttachment;
|
||||
|
|
@ -77,6 +84,28 @@ use Symfony\Component\Validator\Constraints as Assert;
|
|||
],
|
||||
normalizationContext: ['groups' => ['category:read', 'api:basic:read'], 'openapi_definition_name' => 'Read'],
|
||||
denormalizationContext: ['groups' => ['category:write', 'api:basic:write', 'attachment:write', 'parameter:write'], 'openapi_definition_name' => 'Write'],
|
||||
mcp: [
|
||||
'list_categories' => new McpToolCollection(
|
||||
title: 'List/search categories',
|
||||
description: 'List all part categories, optionally filtered by a keyword matched against the name and comment. Categories are used to group parts by their function.',
|
||||
annotations: ['readOnlyHint' => true, 'destructiveHint' => false, 'idempotentHint' => true, 'openWorldHint' => false],
|
||||
output: StructuralElementOverview::class,
|
||||
normalizationContext: ['groups' => ['mcp_structural_overview:read']],
|
||||
input: StructuralElementSearchInput::class,
|
||||
security: 'is_granted("@categories.read")',
|
||||
processor: ListStructuralElementsProcessor::class,
|
||||
),
|
||||
'get_category_details' => new McpTool(
|
||||
title: 'Get category details by ID',
|
||||
description: 'Get detailed information about a specific part category by its database ID.',
|
||||
annotations: ['readOnlyHint' => true, 'destructiveHint' => false, 'idempotentHint' => true, 'openWorldHint' => false],
|
||||
normalizationContext: ['groups' => ['category:read', 'api:basic:read']],
|
||||
input: ElementByIdInput::class,
|
||||
security: 'is_granted("@categories.read")',
|
||||
validate: true,
|
||||
processor: GetStructuralElementDetailsProcessor::class,
|
||||
),
|
||||
],
|
||||
)]
|
||||
#[ApiFilter(PropertyFilter::class)]
|
||||
#[ApiFilter(LikeFilter::class, properties: ["name", "comment"])]
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@ use ApiPlatform\Metadata\Delete;
|
|||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Link;
|
||||
use ApiPlatform\Metadata\McpTool;
|
||||
use ApiPlatform\Metadata\McpToolCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use ApiPlatform\OpenApi\Model\Operation;
|
||||
|
|
@ -40,7 +42,12 @@ use ApiPlatform\Serializer\Filter\PropertyFilter;
|
|||
use App\ApiPlatform\Filter\LikeFilter;
|
||||
use App\Entity\Attachments\Attachment;
|
||||
use App\Entity\EDA\EDAFootprintInfo;
|
||||
use App\Mcp\DTO\ElementByIdInput;
|
||||
use App\Mcp\DTO\StructuralElementOverview;
|
||||
use App\Mcp\DTO\StructuralElementSearchInput;
|
||||
use App\Repository\Parts\FootprintRepository;
|
||||
use App\State\Mcp\GetStructuralElementDetailsProcessor;
|
||||
use App\State\Mcp\ListStructuralElementsProcessor;
|
||||
use App\Entity\Base\AbstractStructuralDBElement;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use App\Entity\Attachments\FootprintAttachment;
|
||||
|
|
@ -76,6 +83,28 @@ use Symfony\Component\Validator\Constraints as Assert;
|
|||
],
|
||||
normalizationContext: ['groups' => ['footprint:read', 'api:basic:read'], 'openapi_definition_name' => 'Read'],
|
||||
denormalizationContext: ['groups' => ['footprint:write', 'api:basic:write', 'attachment:write', 'parameter:write'], 'openapi_definition_name' => 'Write'],
|
||||
mcp: [
|
||||
'list_footprints' => new McpToolCollection(
|
||||
title: 'List/search footprints',
|
||||
description: 'List all footprints, optionally filtered by a keyword matched against the name and comment. Footprints describe the physical package/shape of a part.',
|
||||
annotations: ['readOnlyHint' => true, 'destructiveHint' => false, 'idempotentHint' => true, 'openWorldHint' => false],
|
||||
output: StructuralElementOverview::class,
|
||||
normalizationContext: ['groups' => ['mcp_structural_overview:read']],
|
||||
input: StructuralElementSearchInput::class,
|
||||
security: 'is_granted("@footprints.read")',
|
||||
processor: ListStructuralElementsProcessor::class,
|
||||
),
|
||||
'get_footprint_details' => new McpTool(
|
||||
title: 'Get footprint details by ID',
|
||||
description: 'Get detailed information about a specific footprint by its database ID.',
|
||||
annotations: ['readOnlyHint' => true, 'destructiveHint' => false, 'idempotentHint' => true, 'openWorldHint' => false],
|
||||
normalizationContext: ['groups' => ['footprint:read', 'api:basic:read']],
|
||||
input: ElementByIdInput::class,
|
||||
security: 'is_granted("@footprints.read")',
|
||||
validate: true,
|
||||
processor: GetStructuralElementDetailsProcessor::class,
|
||||
),
|
||||
],
|
||||
)]
|
||||
#[ApiFilter(PropertyFilter::class)]
|
||||
#[ApiFilter(LikeFilter::class, properties: ["name", "comment"])]
|
||||
|
|
|
|||
|
|
@ -33,13 +33,20 @@ use ApiPlatform\Metadata\Delete;
|
|||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Link;
|
||||
use ApiPlatform\Metadata\McpTool;
|
||||
use ApiPlatform\Metadata\McpToolCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use ApiPlatform\OpenApi\Model\Operation;
|
||||
use ApiPlatform\Serializer\Filter\PropertyFilter;
|
||||
use App\ApiPlatform\Filter\LikeFilter;
|
||||
use App\Entity\Attachments\Attachment;
|
||||
use App\Mcp\DTO\ElementByIdInput;
|
||||
use App\Mcp\DTO\StructuralElementOverview;
|
||||
use App\Mcp\DTO\StructuralElementSearchInput;
|
||||
use App\Repository\Parts\ManufacturerRepository;
|
||||
use App\State\Mcp\GetStructuralElementDetailsProcessor;
|
||||
use App\State\Mcp\ListStructuralElementsProcessor;
|
||||
use App\Entity\Base\AbstractStructuralDBElement;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use App\Entity\Attachments\ManufacturerAttachment;
|
||||
|
|
@ -75,6 +82,28 @@ use Symfony\Component\Validator\Constraints as Assert;
|
|||
],
|
||||
normalizationContext: ['groups' => ['manufacturer:read', 'company:read', 'api:basic:read'], 'openapi_definition_name' => 'Read'],
|
||||
denormalizationContext: ['groups' => ['manufacturer:write', 'company:write', 'api:basic:write', 'attachment:write', 'parameter:write'], 'openapi_definition_name' => 'Write'],
|
||||
mcp: [
|
||||
'list_manufacturers' => new McpToolCollection(
|
||||
title: 'List/search manufacturers',
|
||||
description: 'List all manufacturers, optionally filtered by a keyword matched against the name and comment.',
|
||||
annotations: ['readOnlyHint' => true, 'destructiveHint' => false, 'idempotentHint' => true, 'openWorldHint' => false],
|
||||
output: StructuralElementOverview::class,
|
||||
normalizationContext: ['groups' => ['mcp_structural_overview:read']],
|
||||
input: StructuralElementSearchInput::class,
|
||||
security: 'is_granted("@manufacturers.read")',
|
||||
processor: ListStructuralElementsProcessor::class,
|
||||
),
|
||||
'get_manufacturer_details' => new McpTool(
|
||||
title: 'Get manufacturer details by ID',
|
||||
description: 'Get detailed information about a specific manufacturer by its database ID.',
|
||||
annotations: ['readOnlyHint' => true, 'destructiveHint' => false, 'idempotentHint' => true, 'openWorldHint' => false],
|
||||
normalizationContext: ['groups' => ['manufacturer:read', 'company:read', 'api:basic:read']],
|
||||
input: ElementByIdInput::class,
|
||||
security: 'is_granted("@manufacturers.read")',
|
||||
validate: true,
|
||||
processor: GetStructuralElementDetailsProcessor::class,
|
||||
),
|
||||
],
|
||||
)]
|
||||
#[ApiFilter(PropertyFilter::class)]
|
||||
#[ApiFilter(LikeFilter::class, properties: ["name", "comment"])]
|
||||
|
|
|
|||
|
|
@ -33,13 +33,20 @@ use ApiPlatform\Metadata\Delete;
|
|||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Link;
|
||||
use ApiPlatform\Metadata\McpTool;
|
||||
use ApiPlatform\Metadata\McpToolCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use ApiPlatform\OpenApi\Model\Operation;
|
||||
use ApiPlatform\Serializer\Filter\PropertyFilter;
|
||||
use App\ApiPlatform\Filter\LikeFilter;
|
||||
use App\Entity\Attachments\Attachment;
|
||||
use App\Mcp\DTO\ElementByIdInput;
|
||||
use App\Mcp\DTO\StructuralElementOverview;
|
||||
use App\Mcp\DTO\StructuralElementSearchInput;
|
||||
use App\Repository\Parts\MeasurementUnitRepository;
|
||||
use App\State\Mcp\GetStructuralElementDetailsProcessor;
|
||||
use App\State\Mcp\ListStructuralElementsProcessor;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use App\Entity\Base\AbstractStructuralDBElement;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
|
|
@ -80,6 +87,28 @@ use Symfony\Component\Validator\Constraints\Length;
|
|||
],
|
||||
normalizationContext: ['groups' => ['measurement_unit:read', 'api:basic:read'], 'openapi_definition_name' => 'Read'],
|
||||
denormalizationContext: ['groups' => ['measurement_unit:write', 'api:basic:write', 'attachment:write', 'parameter:write'], 'openapi_definition_name' => 'Write'],
|
||||
mcp: [
|
||||
'list_measurement_units' => new McpToolCollection(
|
||||
title: 'List/search measurement units',
|
||||
description: 'List all measurement units, optionally filtered by a keyword matched against the name and comment. Measurement units describe how the amount of a part is measured (e.g. pieces, meters, grams).',
|
||||
annotations: ['readOnlyHint' => true, 'destructiveHint' => false, 'idempotentHint' => true, 'openWorldHint' => false],
|
||||
output: StructuralElementOverview::class,
|
||||
normalizationContext: ['groups' => ['mcp_structural_overview:read']],
|
||||
input: StructuralElementSearchInput::class,
|
||||
security: 'is_granted("@measurement_units.read")',
|
||||
processor: ListStructuralElementsProcessor::class,
|
||||
),
|
||||
'get_measurement_unit_details' => new McpTool(
|
||||
title: 'Get measurement unit details by ID',
|
||||
description: 'Get detailed information about a specific measurement unit by its database ID.',
|
||||
annotations: ['readOnlyHint' => true, 'destructiveHint' => false, 'idempotentHint' => true, 'openWorldHint' => false],
|
||||
normalizationContext: ['groups' => ['measurement_unit:read', 'api:basic:read']],
|
||||
input: ElementByIdInput::class,
|
||||
security: 'is_granted("@measurement_units.read")',
|
||||
validate: true,
|
||||
processor: GetStructuralElementDetailsProcessor::class,
|
||||
),
|
||||
],
|
||||
)]
|
||||
#[ApiFilter(PropertyFilter::class)]
|
||||
#[ApiFilter(LikeFilter::class, properties: ["name", "comment", "unit"])]
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@ use ApiPlatform\Metadata\ApiResource;
|
|||
use ApiPlatform\Metadata\Delete;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\McpTool;
|
||||
use ApiPlatform\Metadata\McpToolCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use ApiPlatform\Serializer\Filter\PropertyFilter;
|
||||
|
|
@ -40,7 +42,12 @@ use App\ApiPlatform\Filter\LikeFilter;
|
|||
use App\Entity\Base\AbstractPartsContainingDBElement;
|
||||
use App\Entity\Base\AbstractStructuralDBElement;
|
||||
use App\Entity\Parameters\PartCustomStateParameter;
|
||||
use App\Mcp\DTO\ElementByIdInput;
|
||||
use App\Mcp\DTO\StructuralElementOverview;
|
||||
use App\Mcp\DTO\StructuralElementSearchInput;
|
||||
use App\Repository\Parts\PartCustomStateRepository;
|
||||
use App\State\Mcp\GetStructuralElementDetailsProcessor;
|
||||
use App\State\Mcp\ListStructuralElementsProcessor;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\Common\Collections\Criteria;
|
||||
|
|
@ -67,6 +74,28 @@ use Symfony\Component\Validator\Constraints as Assert;
|
|||
],
|
||||
normalizationContext: ['groups' => ['part_custom_state:read', 'api:basic:read'], 'openapi_definition_name' => 'Read'],
|
||||
denormalizationContext: ['groups' => ['part_custom_state:write', 'api:basic:write'], 'openapi_definition_name' => 'Write'],
|
||||
mcp: [
|
||||
'list_part_custom_states' => new McpToolCollection(
|
||||
title: 'List/search part custom states',
|
||||
description: 'List all part custom states, optionally filtered by a keyword matched against the name and comment. Custom states are used to mark parts with a custom status (e.g. "obsolete", "needs review").',
|
||||
annotations: ['readOnlyHint' => true, 'destructiveHint' => false, 'idempotentHint' => true, 'openWorldHint' => false],
|
||||
output: StructuralElementOverview::class,
|
||||
normalizationContext: ['groups' => ['mcp_structural_overview:read']],
|
||||
input: StructuralElementSearchInput::class,
|
||||
security: 'is_granted("@part_custom_states.read")',
|
||||
processor: ListStructuralElementsProcessor::class,
|
||||
),
|
||||
'get_part_custom_state_details' => new McpTool(
|
||||
title: 'Get part custom state details by ID',
|
||||
description: 'Get detailed information about a specific part custom state by its database ID.',
|
||||
annotations: ['readOnlyHint' => true, 'destructiveHint' => false, 'idempotentHint' => true, 'openWorldHint' => false],
|
||||
normalizationContext: ['groups' => ['part_custom_state:read', 'api:basic:read']],
|
||||
input: ElementByIdInput::class,
|
||||
security: 'is_granted("@part_custom_states.read")',
|
||||
validate: true,
|
||||
processor: GetStructuralElementDetailsProcessor::class,
|
||||
),
|
||||
],
|
||||
)]
|
||||
#[ApiFilter(PropertyFilter::class)]
|
||||
#[ApiFilter(LikeFilter::class, properties: ["name"])]
|
||||
|
|
|
|||
|
|
@ -33,13 +33,20 @@ use ApiPlatform\Metadata\Delete;
|
|||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Link;
|
||||
use ApiPlatform\Metadata\McpTool;
|
||||
use ApiPlatform\Metadata\McpToolCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use ApiPlatform\OpenApi\Model\Operation;
|
||||
use ApiPlatform\Serializer\Filter\PropertyFilter;
|
||||
use App\ApiPlatform\Filter\LikeFilter;
|
||||
use App\Entity\Attachments\Attachment;
|
||||
use App\Mcp\DTO\ElementByIdInput;
|
||||
use App\Mcp\DTO\StructuralElementOverview;
|
||||
use App\Mcp\DTO\StructuralElementSearchInput;
|
||||
use App\Repository\Parts\StorelocationRepository;
|
||||
use App\State\Mcp\GetStructuralElementDetailsProcessor;
|
||||
use App\State\Mcp\ListStructuralElementsProcessor;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use App\Entity\Attachments\StorageLocationAttachment;
|
||||
|
|
@ -76,6 +83,28 @@ use Symfony\Component\Validator\Constraints as Assert;
|
|||
],
|
||||
normalizationContext: ['groups' => ['location:read', 'api:basic:read'], 'openapi_definition_name' => 'Read'],
|
||||
denormalizationContext: ['groups' => ['location:write', 'api:basic:write', 'attachment:write', 'parameter:write'], 'openapi_definition_name' => 'Write'],
|
||||
mcp: [
|
||||
'list_storage_locations' => new McpToolCollection(
|
||||
title: 'List/search storage locations',
|
||||
description: 'List all storage locations, optionally filtered by a keyword matched against the name and comment. Storage locations describe where parts are physically stored.',
|
||||
annotations: ['readOnlyHint' => true, 'destructiveHint' => false, 'idempotentHint' => true, 'openWorldHint' => false],
|
||||
output: StructuralElementOverview::class,
|
||||
normalizationContext: ['groups' => ['mcp_structural_overview:read']],
|
||||
input: StructuralElementSearchInput::class,
|
||||
security: 'is_granted("@storelocations.read")',
|
||||
processor: ListStructuralElementsProcessor::class,
|
||||
),
|
||||
'get_storage_location_details' => new McpTool(
|
||||
title: 'Get storage location details by ID',
|
||||
description: 'Get detailed information about a specific storage location by its database ID.',
|
||||
annotations: ['readOnlyHint' => true, 'destructiveHint' => false, 'idempotentHint' => true, 'openWorldHint' => false],
|
||||
normalizationContext: ['groups' => ['location:read', 'api:basic:read']],
|
||||
input: ElementByIdInput::class,
|
||||
security: 'is_granted("@storelocations.read")',
|
||||
validate: true,
|
||||
processor: GetStructuralElementDetailsProcessor::class,
|
||||
),
|
||||
],
|
||||
)]
|
||||
#[ApiFilter(PropertyFilter::class)]
|
||||
#[ApiFilter(LikeFilter::class, properties: ["name", "comment"])]
|
||||
|
|
|
|||
|
|
@ -33,13 +33,20 @@ use ApiPlatform\Metadata\Delete;
|
|||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Link;
|
||||
use ApiPlatform\Metadata\McpTool;
|
||||
use ApiPlatform\Metadata\McpToolCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use ApiPlatform\OpenApi\Model\Operation;
|
||||
use ApiPlatform\Serializer\Filter\PropertyFilter;
|
||||
use App\ApiPlatform\Filter\LikeFilter;
|
||||
use App\Entity\Attachments\Attachment;
|
||||
use App\Mcp\DTO\ElementByIdInput;
|
||||
use App\Mcp\DTO\StructuralElementOverview;
|
||||
use App\Mcp\DTO\StructuralElementSearchInput;
|
||||
use App\Repository\Parts\SupplierRepository;
|
||||
use App\State\Mcp\GetStructuralElementDetailsProcessor;
|
||||
use App\State\Mcp\ListStructuralElementsProcessor;
|
||||
use App\Entity\PriceInformations\Orderdetail;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use App\Entity\Attachments\SupplierAttachment;
|
||||
|
|
@ -80,6 +87,28 @@ use Symfony\Component\Validator\Constraints as Assert;
|
|||
],
|
||||
normalizationContext: ['groups' => ['supplier:read', 'company:read', 'api:basic:read'], 'openapi_definition_name' => 'Read'],
|
||||
denormalizationContext: ['groups' => ['supplier:write', 'company:write', 'api:basic:write', 'attachment:write', 'parameter:write'], 'openapi_definition_name' => 'Write'],
|
||||
mcp: [
|
||||
'list_suppliers' => new McpToolCollection(
|
||||
title: 'List/search suppliers',
|
||||
description: 'List all suppliers, optionally filtered by a keyword matched against the name and comment.',
|
||||
annotations: ['readOnlyHint' => true, 'destructiveHint' => false, 'idempotentHint' => true, 'openWorldHint' => false],
|
||||
output: StructuralElementOverview::class,
|
||||
normalizationContext: ['groups' => ['mcp_structural_overview:read']],
|
||||
input: StructuralElementSearchInput::class,
|
||||
security: 'is_granted("@suppliers.read")',
|
||||
processor: ListStructuralElementsProcessor::class,
|
||||
),
|
||||
'get_supplier_details' => new McpTool(
|
||||
title: 'Get supplier details by ID',
|
||||
description: 'Get detailed information about a specific supplier by its database ID.',
|
||||
annotations: ['readOnlyHint' => true, 'destructiveHint' => false, 'idempotentHint' => true, 'openWorldHint' => false],
|
||||
normalizationContext: ['groups' => ['supplier:read', 'company:read', 'api:basic:read']],
|
||||
input: ElementByIdInput::class,
|
||||
security: 'is_granted("@suppliers.read")',
|
||||
validate: true,
|
||||
processor: GetStructuralElementDetailsProcessor::class,
|
||||
),
|
||||
],
|
||||
)]
|
||||
#[ApiFilter(PropertyFilter::class)]
|
||||
#[ApiFilter(LikeFilter::class, properties: ["name", "comment"])]
|
||||
|
|
|
|||
47
src/Mcp/DTO/StructuralElementOverview.php
Normal file
47
src/Mcp/DTO/StructuralElementOverview.php
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\DTO;
|
||||
|
||||
use Symfony\Component\Serializer\Annotation\Groups;
|
||||
|
||||
/**
|
||||
* A lean overview projection of a structural "master data" element (category, footprint, manufacturer, ...),
|
||||
* used by the list_X MCP tools. Use the corresponding get_X_details tool with the id to fetch full details.
|
||||
*
|
||||
* The properties are tagged with the 'mcp_structural_overview:read' group (and the list_X mcp tools set
|
||||
* this as their normalizationContext), because otherwise the operation would silently fall back to the
|
||||
* backing entity's own (unrelated) normalization groups, which don't match these plain properties and
|
||||
* would filter them out entirely.
|
||||
*/
|
||||
readonly class StructuralElementOverview
|
||||
{
|
||||
public function __construct(
|
||||
/** @var int The database ID of the element, to be used with the corresponding get_X_details tool */
|
||||
#[Groups(['mcp_structural_overview:read'])]
|
||||
public int $id,
|
||||
/** @var string The name of the element */
|
||||
#[Groups(['mcp_structural_overview:read'])]
|
||||
public string $name,
|
||||
) {
|
||||
}
|
||||
}
|
||||
36
src/Mcp/DTO/StructuralElementSearchInput.php
Normal file
36
src/Mcp/DTO/StructuralElementSearchInput.php
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\DTO;
|
||||
|
||||
/**
|
||||
* Generic input for listing/searching one of the structural "master data" entities (categories, footprints,
|
||||
* manufacturers, storage locations, suppliers, measurement units, part custom states).
|
||||
*/
|
||||
readonly class StructuralElementSearchInput
|
||||
{
|
||||
public function __construct(
|
||||
/** @var string|null Optional keyword to filter by name or comment (case-insensitive substring match). If omitted, all elements are returned. */
|
||||
public ?string $keyword = null,
|
||||
) {
|
||||
}
|
||||
}
|
||||
68
src/Serializer/StructuralElementOverviewNormalizer.php
Normal file
68
src/Serializer/StructuralElementOverviewNormalizer.php
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
namespace App\Serializer;
|
||||
|
||||
use App\Mcp\DTO\StructuralElementOverview;
|
||||
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
|
||||
|
||||
/**
|
||||
* Normalizes StructuralElementOverview as a plain {id, name} object, without the JSON-LD "@id"/"@type"
|
||||
* metadata that ApiPlatform\JsonLd\Serializer\ItemNormalizer would otherwise add (as a blank/genid node,
|
||||
* since this DTO is intentionally not an API resource). That metadata is useless clutter for the list_X
|
||||
* MCP tools, whose whole point is a lean id+name overview.
|
||||
*
|
||||
* @see \App\Tests\Serializer\StructuralElementOverviewNormalizerTest
|
||||
*/
|
||||
class StructuralElementOverviewNormalizer implements NormalizerInterface
|
||||
{
|
||||
public function supportsNormalization($data, ?string $format = null, array $context = []): bool
|
||||
{
|
||||
return $data instanceof StructuralElementOverview;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{id: int, name: string}
|
||||
*/
|
||||
public function normalize($object, ?string $format = null, array $context = []): array
|
||||
{
|
||||
if (!$object instanceof StructuralElementOverview) {
|
||||
throw new \InvalidArgumentException('This normalizer only supports StructuralElementOverview objects!');
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $object->id,
|
||||
'name' => $object->name,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return bool[]
|
||||
*/
|
||||
public function getSupportedTypes(?string $format): array
|
||||
{
|
||||
return [
|
||||
StructuralElementOverview::class => true,
|
||||
];
|
||||
}
|
||||
}
|
||||
70
src/State/Mcp/GetStructuralElementDetailsProcessor.php
Normal file
70
src/State/Mcp/GetStructuralElementDetailsProcessor.php
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\State\Mcp;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Entity\Base\AbstractStructuralDBElement;
|
||||
use App\Mcp\DTO\ElementByIdInput;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
/**
|
||||
* Generic get-by-id processor shared by all structural "master data" entities (categories, footprints,
|
||||
* manufacturers, storage locations, suppliers, measurement units, part custom states). The concrete entity
|
||||
* class is determined from the operation, so this single processor can be reused for all of them.
|
||||
*/
|
||||
class GetStructuralElementDetailsProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly AuthorizationCheckerInterface $authorizationChecker,
|
||||
) {
|
||||
}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): AbstractStructuralDBElement
|
||||
{
|
||||
if (!$data instanceof ElementByIdInput) {
|
||||
throw new \InvalidArgumentException('Expected ElementByIdInput');
|
||||
}
|
||||
|
||||
$class = $operation->getClass();
|
||||
if (!is_a($class, AbstractStructuralDBElement::class, true)) {
|
||||
throw new \LogicException(sprintf('%s can only be used for resources extending %s', self::class, AbstractStructuralDBElement::class));
|
||||
}
|
||||
|
||||
$element = $this->entityManager->find($class, $data->id);
|
||||
|
||||
if (!$element instanceof AbstractStructuralDBElement) {
|
||||
throw new NotFoundHttpException(sprintf('%s with id %d not found', (new \ReflectionClass($class))->getShortName(), $data->id));
|
||||
}
|
||||
|
||||
if (!$this->authorizationChecker->isGranted('read', $element)) {
|
||||
throw new AccessDeniedException(sprintf('Access denied to %s with id %d', (new \ReflectionClass($class))->getShortName(), $data->id));
|
||||
}
|
||||
|
||||
return $element;
|
||||
}
|
||||
}
|
||||
78
src/State/Mcp/ListStructuralElementsProcessor.php
Normal file
78
src/State/Mcp/ListStructuralElementsProcessor.php
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\State\Mcp;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Entity\Base\AbstractStructuralDBElement;
|
||||
use App\Mcp\DTO\StructuralElementOverview;
|
||||
use App\Mcp\DTO\StructuralElementSearchInput;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
/**
|
||||
* Generic list/search processor shared by all structural "master data" entities (categories, footprints,
|
||||
* manufacturers, storage locations, suppliers, measurement units, part custom states). The concrete entity
|
||||
* class is determined from the operation, so this single processor can be reused for all of them.
|
||||
* Returns a lean id+name overview only; use the corresponding get_X_details tool for full details.
|
||||
*/
|
||||
class ListStructuralElementsProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return StructuralElementOverview[]
|
||||
*/
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): array
|
||||
{
|
||||
if (!$data instanceof StructuralElementSearchInput) {
|
||||
throw new \InvalidArgumentException('Expected StructuralElementSearchInput');
|
||||
}
|
||||
|
||||
$class = $operation->getClass();
|
||||
if (!is_a($class, AbstractStructuralDBElement::class, true)) {
|
||||
throw new \LogicException(sprintf('%s can only be used for resources extending %s', self::class, AbstractStructuralDBElement::class));
|
||||
}
|
||||
|
||||
$qb = $this->entityManager->getRepository($class)->createQueryBuilder('element');
|
||||
$qb->select('element.id AS id', 'element.name AS name');
|
||||
|
||||
if ($data->keyword !== null && $data->keyword !== '') {
|
||||
//Escape % and _ characters in the keyword, like PartSearchFilter does
|
||||
$keyword = str_replace(['%', '_'], ['\%', '\_'], $data->keyword);
|
||||
$qb->andWhere('ILIKE(element.name, :keyword) = TRUE OR ILIKE(element.comment, :keyword) = TRUE')
|
||||
->setParameter('keyword', '%'.$keyword.'%');
|
||||
}
|
||||
|
||||
$qb->orderBy('element.name', 'ASC');
|
||||
|
||||
$rows = $qb->getQuery()->getArrayResult();
|
||||
|
||||
return array_map(
|
||||
static fn (array $row): StructuralElementOverview => new StructuralElementOverview(id: $row['id'], name: $row['name']),
|
||||
$rows
|
||||
);
|
||||
}
|
||||
}
|
||||
52
tests/Serializer/StructuralElementOverviewNormalizerTest.php
Normal file
52
tests/Serializer/StructuralElementOverviewNormalizerTest.php
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
namespace App\Tests\Serializer;
|
||||
|
||||
use App\Mcp\DTO\StructuralElementOverview;
|
||||
use App\Serializer\StructuralElementOverviewNormalizer;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class StructuralElementOverviewNormalizerTest extends TestCase
|
||||
{
|
||||
private StructuralElementOverviewNormalizer $service;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->service = new StructuralElementOverviewNormalizer();
|
||||
}
|
||||
|
||||
public function testNormalize(): void
|
||||
{
|
||||
$overview = new StructuralElementOverview(id: 42, name: 'Test Node');
|
||||
|
||||
//Must be a plain array with exactly id+name, no JSON-LD "@id"/"@type" clutter
|
||||
$this->assertSame(['id' => 42, 'name' => 'Test Node'], $this->service->normalize($overview));
|
||||
}
|
||||
|
||||
public function testSupportsNormalization(): void
|
||||
{
|
||||
$this->assertFalse($this->service->supportsNormalization(new \stdClass()));
|
||||
$this->assertTrue($this->service->supportsNormalization(new StructuralElementOverview(id: 1, name: 'X')));
|
||||
}
|
||||
}
|
||||
216
tests/State/Mcp/StructuralElementProcessorsTest.php
Normal file
216
tests/State/Mcp/StructuralElementProcessorsTest.php
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
namespace App\Tests\State\Mcp;
|
||||
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\McpTool;
|
||||
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
|
||||
use App\Entity\Parts\Category;
|
||||
use App\Entity\Parts\Footprint;
|
||||
use App\Entity\Parts\Manufacturer;
|
||||
use App\Entity\Parts\MeasurementUnit;
|
||||
use App\Entity\Parts\Part;
|
||||
use App\Entity\Parts\PartCustomState;
|
||||
use App\Entity\Parts\StorageLocation;
|
||||
use App\Entity\Parts\Supplier;
|
||||
use App\Entity\UserSystem\User;
|
||||
use App\Mcp\DTO\ElementByIdInput;
|
||||
use App\Mcp\DTO\StructuralElementOverview;
|
||||
use App\Mcp\DTO\StructuralElementSearchInput;
|
||||
use App\State\Mcp\GetStructuralElementDetailsProcessor;
|
||||
use App\State\Mcp\ListStructuralElementsProcessor;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
use Symfony\Component\Serializer\SerializerInterface;
|
||||
|
||||
class StructuralElementProcessorsTest extends WebTestCase
|
||||
{
|
||||
private KernelBrowser $client;
|
||||
private EntityManagerInterface $em;
|
||||
private ListStructuralElementsProcessor $listProcessor;
|
||||
private GetStructuralElementDetailsProcessor $getProcessor;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->client = static::createClient();
|
||||
|
||||
$userRepository = self::getContainer()->get('doctrine')->getRepository(User::class);
|
||||
$admin = $userRepository->findOneBy(['name' => 'admin']);
|
||||
$this->client->loginUser($admin);
|
||||
|
||||
$this->em = self::getContainer()->get(EntityManagerInterface::class);
|
||||
$this->listProcessor = self::getContainer()->get(ListStructuralElementsProcessor::class);
|
||||
$this->getProcessor = self::getContainer()->get(GetStructuralElementDetailsProcessor::class);
|
||||
}
|
||||
|
||||
private function getOperationForClass(string $class): Get
|
||||
{
|
||||
return (new Get())->withClass($class);
|
||||
}
|
||||
|
||||
public function testListReturnsAllElementsWithoutKeyword(): void
|
||||
{
|
||||
$result = $this->listProcessor->process(new StructuralElementSearchInput(), $this->getOperationForClass(Category::class));
|
||||
|
||||
//DataStructureFixtures creates 7 category nodes ("Node 1" .. "Node 1.1.1")
|
||||
self::assertGreaterThanOrEqual(7, count($result));
|
||||
foreach ($result as $overview) {
|
||||
self::assertInstanceOf(StructuralElementOverview::class, $overview);
|
||||
//The list tool is a lean id+name overview only
|
||||
self::assertIsInt($overview->id);
|
||||
self::assertIsString($overview->name);
|
||||
}
|
||||
}
|
||||
|
||||
public function testListFiltersByKeyword(): void
|
||||
{
|
||||
$result = $this->listProcessor->process(
|
||||
new StructuralElementSearchInput(keyword: 'Node 3'),
|
||||
$this->getOperationForClass(Category::class)
|
||||
);
|
||||
|
||||
self::assertCount(1, $result);
|
||||
self::assertSame('Node 3', $result[0]->name);
|
||||
|
||||
$node3 = $this->em->getRepository(Category::class)->findOneBy(['name' => 'Node 3']);
|
||||
self::assertSame($node3->getID(), $result[0]->id);
|
||||
}
|
||||
|
||||
public function testListIsGenericAcrossEntityClasses(): void
|
||||
{
|
||||
$result = $this->listProcessor->process(
|
||||
new StructuralElementSearchInput(keyword: 'Node 3'),
|
||||
$this->getOperationForClass(Footprint::class)
|
||||
);
|
||||
|
||||
self::assertCount(1, $result);
|
||||
self::assertInstanceOf(StructuralElementOverview::class, $result[0]);
|
||||
self::assertSame('Node 3', $result[0]->name);
|
||||
}
|
||||
|
||||
public function testListRejectsNonStructuralResourceClass(): void
|
||||
{
|
||||
$this->expectException(\LogicException::class);
|
||||
$this->listProcessor->process(new StructuralElementSearchInput(), $this->getOperationForClass(Part::class));
|
||||
}
|
||||
|
||||
public function testGetDetailsReturnsElement(): void
|
||||
{
|
||||
$node1 = $this->em->getRepository(Category::class)->findOneBy(['name' => 'Node 1']);
|
||||
self::assertNotNull($node1);
|
||||
|
||||
$result = $this->getProcessor->process(new ElementByIdInput($node1->getID()), $this->getOperationForClass(Category::class));
|
||||
|
||||
self::assertSame($node1->getID(), $result->getID());
|
||||
self::assertSame('Node 1', $result->getName());
|
||||
}
|
||||
|
||||
public function testGetDetailsThrowsNotFoundForUnknownId(): void
|
||||
{
|
||||
$this->expectException(NotFoundHttpException::class);
|
||||
$this->getProcessor->process(new ElementByIdInput(999999999), $this->getOperationForClass(Category::class));
|
||||
}
|
||||
|
||||
public function testGetDetailsRejectsNonStructuralResourceClass(): void
|
||||
{
|
||||
$part = $this->em->getRepository(Part::class)->findOneBy([]);
|
||||
self::assertNotNull($part);
|
||||
|
||||
$this->expectException(\LogicException::class);
|
||||
$this->getProcessor->process(new ElementByIdInput($part->getID()), $this->getOperationForClass(Part::class));
|
||||
}
|
||||
|
||||
public function testGetDetailsDeniesAccessForUserWithoutPermission(): void
|
||||
{
|
||||
$userRepository = self::getContainer()->get('doctrine')->getRepository(User::class);
|
||||
$noread = $userRepository->findOneBy(['name' => 'noread']);
|
||||
$this->client->loginUser($noread);
|
||||
|
||||
$node1 = $this->em->getRepository(Category::class)->findOneBy(['name' => 'Node 1']);
|
||||
|
||||
$this->expectException(AccessDeniedException::class);
|
||||
$this->getProcessor->process(new ElementByIdInput($node1->getID()), $this->getOperationForClass(Category::class));
|
||||
}
|
||||
|
||||
/**
|
||||
* Regression test: a list_X tool's output must actually serialize id+name through the real
|
||||
* pipeline (JSON-LD normalizer with the operation's own normalizationContext), not just when the
|
||||
* processor is called directly. Previously, list_X operations had no normalizationContext of their
|
||||
* own, so they silently inherited the backing entity's REST "Read" groups (e.g. "category:read"),
|
||||
* which don't match StructuralElementOverview's plain properties, and every field was serialized
|
||||
* away, leaving an empty object.
|
||||
*
|
||||
*/
|
||||
#[DataProvider('provideListToolClasses')]
|
||||
public function testListToolOutputActuallySerializesIdAndName(string $entityClass, string $toolName): void
|
||||
{
|
||||
$resourceMetadataCollectionFactory = self::getContainer()->get(ResourceMetadataCollectionFactoryInterface::class);
|
||||
$metadata = $resourceMetadataCollectionFactory->create($entityClass);
|
||||
|
||||
$operation = null;
|
||||
foreach ($metadata as $resource) {
|
||||
foreach ($resource->getMcp() ?? [] as $mcp) {
|
||||
if ($mcp instanceof McpTool && $mcp->getName() === $toolName) {
|
||||
$operation = $mcp;
|
||||
}
|
||||
}
|
||||
}
|
||||
self::assertNotNull($operation, sprintf('mcp tool "%s" not found on %s', $toolName, $entityClass));
|
||||
|
||||
$serializer = self::getContainer()->get(SerializerInterface::class);
|
||||
$overview = new StructuralElementOverview(id: 123, name: 'Regression test node');
|
||||
|
||||
$result = $serializer->serialize([$overview], 'jsonld', [
|
||||
'groups' => $operation->getNormalizationContext()['groups'] ?? null,
|
||||
'resource_class' => $entityClass,
|
||||
'operation' => $operation,
|
||||
]);
|
||||
|
||||
$decoded = json_decode($result, true, flags: \JSON_THROW_ON_ERROR);
|
||||
self::assertArrayHasKey('hydra:member', $decoded);
|
||||
self::assertSame(123, $decoded['hydra:member'][0]['id'] ?? null);
|
||||
self::assertSame('Regression test node', $decoded['hydra:member'][0]['name'] ?? null);
|
||||
//The list_X tools are a lean id+name overview - no JSON-LD "@id"/"@type" clutter per item
|
||||
self::assertArrayNotHasKey('@id', $decoded['hydra:member'][0]);
|
||||
self::assertArrayNotHasKey('@type', $decoded['hydra:member'][0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{0: class-string, 1: string}>
|
||||
*/
|
||||
public static function provideListToolClasses(): iterable
|
||||
{
|
||||
yield 'categories' => [Category::class, 'list_categories'];
|
||||
yield 'footprints' => [Footprint::class, 'list_footprints'];
|
||||
yield 'manufacturers' => [Manufacturer::class, 'list_manufacturers'];
|
||||
yield 'storage_locations' => [StorageLocation::class, 'list_storage_locations'];
|
||||
yield 'suppliers' => [Supplier::class, 'list_suppliers'];
|
||||
yield 'measurement_units' => [MeasurementUnit::class, 'list_measurement_units'];
|
||||
yield 'part_custom_states' => [PartCustomState::class, 'list_part_custom_states'];
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue