mirror of
https://github.com/Part-DB/Part-DB-server.git
synced 2026-07-28 20:21:41 +00:00
Merge branch 'mcp'
This commit is contained in:
commit
c9af3dc412
84 changed files with 3736 additions and 665 deletions
|
|
@ -15,7 +15,9 @@
|
|||
"amphp/http-client": "^5.1",
|
||||
"api-platform/doctrine-orm": "^4.1",
|
||||
"api-platform/json-api": "^4.0.0",
|
||||
"api-platform/mcp": "^v4.3.17",
|
||||
"api-platform/symfony": "^4.0.0",
|
||||
"api-platform/metadata": "^v4.3.17",
|
||||
"beberlei/doctrineextensions": "^1.2",
|
||||
"brick/math": "^0.17.0",
|
||||
"brick/schema": "^0.2.0",
|
||||
|
|
@ -40,6 +42,7 @@
|
|||
"league/html-to-markdown": "^5.0.1",
|
||||
"liip/imagine-bundle": "^2.2",
|
||||
"maennchen/zipstream-php": "2.1",
|
||||
"mcp/sdk": "v0.7.0 as v0.6.0",
|
||||
"nbgrp/onelogin-saml-bundle": "^v2.0.2",
|
||||
"nelexa/zip": "^4.0",
|
||||
"nelmio/cors-bundle": "^2.3",
|
||||
|
|
@ -57,10 +60,10 @@
|
|||
"scheb/2fa-trusted-device": "^v7.11.0",
|
||||
"shivas/versioning-bundle": "^4.0",
|
||||
"spatie/db-dumper": "^3.3.1",
|
||||
"symfony/ai-bundle": "^0.11.0",
|
||||
"symfony/ai-lm-studio-platform": "^v0.11.0",
|
||||
"symfony/ai-ollama-platform": "^0.11.0",
|
||||
"symfony/ai-open-router-platform": "^0.11.0",
|
||||
"symfony/ai-bundle": "^0.12.0",
|
||||
"symfony/ai-lm-studio-platform": "^v0.12.0",
|
||||
"symfony/ai-ollama-platform": "^0.12.0",
|
||||
"symfony/ai-open-router-platform": "^0.12.0",
|
||||
"symfony/apache-pack": "^1.0",
|
||||
"symfony/asset": "7.4.*",
|
||||
"symfony/console": "7.4.*",
|
||||
|
|
@ -75,6 +78,7 @@
|
|||
"symfony/http-client": "7.4.*",
|
||||
"symfony/http-kernel": "7.4.*",
|
||||
"symfony/mailer": "7.4.*",
|
||||
"symfony/mcp-bundle": "^v0.12.0",
|
||||
"symfony/monolog-bundle": "^4.0",
|
||||
"symfony/process": "7.4.*",
|
||||
"symfony/property-access": "7.4.*",
|
||||
|
|
|
|||
937
composer.lock
generated
937
composer.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -34,4 +34,5 @@ return [
|
|||
Jbtronics\TranslationEditorBundle\JbtronicsTranslationEditorBundle::class => ['dev' => true],
|
||||
ApiPlatform\Symfony\Bundle\ApiPlatformBundle::class => ['all' => true],
|
||||
Symfony\AI\AiBundle\AiBundle::class => ['all' => true],
|
||||
Symfony\AI\McpBundle\McpBundle::class => ['all' => true],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -38,3 +38,7 @@ api_platform:
|
|||
serializer:
|
||||
# Change this to false later, to remove the hydra prefix on the API
|
||||
hydra_prefix: true
|
||||
|
||||
mcp:
|
||||
enabled: true # default: true
|
||||
format: jsonld # default: 'jsonld'
|
||||
|
|
|
|||
18
config/packages/mcp.yaml
Normal file
18
config/packages/mcp.yaml
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
mcp:
|
||||
app: "Part-DB"
|
||||
version: "0.1.0"
|
||||
description: "Part-DB is a inventory management database for electronic parts."
|
||||
instructions: |
|
||||
This server provides inventory information for your current user. It is mostly used for electronic parts,
|
||||
but can be used for any kind of inventory. The stored objects are called parts.
|
||||
If you miss some information from an search endpoint, try to retrieve more details using the ID of an entity.
|
||||
|
||||
client_transports:
|
||||
http: true
|
||||
stdio: false
|
||||
http:
|
||||
path: "/mcp"
|
||||
session:
|
||||
store: "file"
|
||||
directory: "%kernel.cache_dir%/mcp"
|
||||
ttl: 3600
|
||||
|
|
@ -73,3 +73,6 @@ security:
|
|||
- { path: "^/api", allow_if: 'is_granted("@api.access_api") and is_authenticated()' }
|
||||
# Restrict access to KICAD to users, which has API access permission
|
||||
- { path: "^/kicad-api", allow_if: 'is_granted("@api.access_api") and is_authenticated()' }
|
||||
|
||||
# Restrict MCP access to users, which has the MCP access permission
|
||||
- { path: "^/mcp", allow_if: 'is_granted("@api.use_mcp") and is_authenticated()' }
|
||||
|
|
|
|||
|
|
@ -381,7 +381,12 @@ perms: # Here comes a list with all Permission names (they have a perm_[name] co
|
|||
access_api:
|
||||
label: "perm.api.access_api"
|
||||
apiTokenRole: ROLE_API_READ_ONLY
|
||||
use_mcp:
|
||||
label: "perm.api.use_mcp"
|
||||
alsoSet: [ 'access_api' ]
|
||||
apiTokenRole: ROLE_API_READ_ONLY
|
||||
manage_tokens:
|
||||
label: "perm.api.manage_tokens"
|
||||
alsoSet: ['access_api']
|
||||
apiTokenRole: ROLE_API_FULL
|
||||
|
||||
|
|
|
|||
|
|
@ -3199,6 +3199,37 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||
* store?: string|Param, // Service name of store // Default: "Symfony\\AI\\Store\\StoreInterface"
|
||||
* }>,
|
||||
* }
|
||||
* @psalm-type McpConfig = array{
|
||||
* app?: scalar|Param|null, // Default: "app"
|
||||
* version?: scalar|Param|null, // Default: "0.0.1"
|
||||
* description?: scalar|Param|null, // Default: null
|
||||
* icons?: list<array{ // Default: []
|
||||
* src?: scalar|Param|null,
|
||||
* mime_type?: scalar|Param|null, // Default: null
|
||||
* sizes?: list<scalar|Param|null>,
|
||||
* }>,
|
||||
* website_url?: scalar|Param|null, // Default: null
|
||||
* pagination_limit?: int|Param, // Default: 50
|
||||
* instructions?: scalar|Param|null, // Default: null
|
||||
* client_transports?: array{
|
||||
* stdio?: bool|Param, // Default: false
|
||||
* http?: bool|Param, // Default: false
|
||||
* },
|
||||
* apps?: array{ // MCP Apps support (interactive HTML UI resources). Apps are registered with the #[AsMcpApp] attribute.
|
||||
* enabled?: bool|Param|null, // Default: null
|
||||
* },
|
||||
* http?: array{
|
||||
* path?: scalar|Param|null, // Default: "/_mcp"
|
||||
* allowed_hosts?: mixed, // DNS rebinding protection hosts (without port). Leave unset to keep the SDK default (localhost only), set an array of hostnames to expose a public MCP server, or false to disable the protection entirely. // Default: null
|
||||
* session?: array{
|
||||
* store?: "file"|"memory"|"cache"|"framework"|Param, // Default: "file"
|
||||
* directory?: scalar|Param|null, // Default: "%kernel.cache_dir%/mcp-sessions"
|
||||
* cache_pool?: scalar|Param|null, // Default: "cache.mcp.sessions"
|
||||
* prefix?: scalar|Param|null, // Default: "mcp-"
|
||||
* ttl?: int|Param, // Default: 3600
|
||||
* },
|
||||
* },
|
||||
* }
|
||||
* @psalm-type ConfigType = array{
|
||||
* imports?: ImportsConfig,
|
||||
* parameters?: ParametersConfig,
|
||||
|
|
@ -3229,6 +3260,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||
* jbtronics_settings?: JbtronicsSettingsConfig,
|
||||
* api_platform?: ApiPlatformConfig,
|
||||
* ai?: AiConfig,
|
||||
* mcp?: McpConfig,
|
||||
* "when@dev"?: array{
|
||||
* imports?: ImportsConfig,
|
||||
* parameters?: ParametersConfig,
|
||||
|
|
@ -3263,6 +3295,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||
* jbtronics_translation_editor?: JbtronicsTranslationEditorConfig,
|
||||
* api_platform?: ApiPlatformConfig,
|
||||
* ai?: AiConfig,
|
||||
* mcp?: McpConfig,
|
||||
* },
|
||||
* "when@docker"?: array{
|
||||
* imports?: ImportsConfig,
|
||||
|
|
@ -3294,6 +3327,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||
* jbtronics_settings?: JbtronicsSettingsConfig,
|
||||
* api_platform?: ApiPlatformConfig,
|
||||
* ai?: AiConfig,
|
||||
* mcp?: McpConfig,
|
||||
* },
|
||||
* "when@prod"?: array{
|
||||
* imports?: ImportsConfig,
|
||||
|
|
@ -3325,6 +3359,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||
* jbtronics_settings?: JbtronicsSettingsConfig,
|
||||
* api_platform?: ApiPlatformConfig,
|
||||
* ai?: AiConfig,
|
||||
* mcp?: McpConfig,
|
||||
* },
|
||||
* "when@test"?: array{
|
||||
* imports?: ImportsConfig,
|
||||
|
|
@ -3359,6 +3394,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||
* jbtronics_settings?: JbtronicsSettingsConfig,
|
||||
* api_platform?: ApiPlatformConfig,
|
||||
* ai?: AiConfig,
|
||||
* mcp?: McpConfig,
|
||||
* },
|
||||
* ...<string, ExtensionType|array{ // extra keys must follow the when@%env% pattern or match an extension alias
|
||||
* imports?: ImportsConfig,
|
||||
|
|
|
|||
|
|
@ -18,6 +18,11 @@ csp_report:
|
|||
methods: [POST]
|
||||
defaults: { _controller: nelmio_security.csp_reporter_controller::indexAction }
|
||||
|
||||
mcp:
|
||||
resource: .
|
||||
type: mcp
|
||||
|
||||
|
||||
# Must be last as it matches everything
|
||||
redirector:
|
||||
path: /{url}
|
||||
|
|
@ -26,3 +31,4 @@ redirector:
|
|||
controller: App\Controller\RedirectController::addLocalePart
|
||||
# Dont match localized routes (no redirection loop, if no root with that name exists) or API prefixed routes
|
||||
condition: "not (request.getPathInfo() matches '/^\\\\/([a-z]{2}(_[A-Z]{2})?|api)\\\\//')"
|
||||
|
||||
|
|
|
|||
216
docs/api/mcp.md
Normal file
216
docs/api/mcp.md
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
---
|
||||
title: MCP Server
|
||||
layout: default
|
||||
parent: API
|
||||
nav_order: 3
|
||||
---
|
||||
|
||||
# MCP Server
|
||||
|
||||
{: .new }
|
||||
> This feature was added recently and might still change in future versions.
|
||||
|
||||
Part-DB ships a [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server, which allows AI assistants and
|
||||
agents (like Claude, ChatGPT, or AI-powered coding tools) to directly interact with your Part-DB inventory: they can
|
||||
search for parts, look up categories, footprints, manufacturers, storage locations, suppliers, and projects, and even
|
||||
query external info providers like Digikey, Mouser or LCSC, all using natural language, without you having to write
|
||||
any code against the [REST API]({% link api/intro.md %}).
|
||||
|
||||
MCP is a standardized, widely supported protocol, so once your Part-DB MCP endpoint is set up, you can connect it to
|
||||
many different AI clients and applications.
|
||||
|
||||
{: .warning }
|
||||
> The MCP integration is currently **read-only**: an AI assistant can look up data, but it can not create, change or
|
||||
> delete anything in your Part-DB instance via MCP.
|
||||
> Still, giving an AI assistant access to your inventory means it can read everything the connected user account is
|
||||
> allowed to see, so only connect trusted AI clients and keep your API token secret, just like you would for the
|
||||
> [REST API]({% link api/authentication.md %}).
|
||||
|
||||
## Enabling the MCP server
|
||||
|
||||
The MCP server is disabled by default and has to be enabled by an administrator first:
|
||||
|
||||
1. Open the system settings and go to the **AI** tab.
|
||||
2. In the **MCP (Model Context Protocol) Server** section, enable the **Enable MCP endpoint** checkbox.
|
||||
|
||||
This can also be controlled via the `MCP_ENABLED` environment variable.
|
||||
|
||||
Once enabled, the MCP server is reachable under the `/mcp` path of your Part-DB instance (e.g.
|
||||
`https://your-part-db.local/mcp`). Unlike most other Part-DB pages, this path is **not** locale-prefixed (so it is
|
||||
`/mcp`, not `/en/mcp`).
|
||||
|
||||
## Permissions
|
||||
|
||||
Users which should be allowed to use the MCP tools additionally need the **Use MCP tools (for AI agents)** permission
|
||||
(under the **API** permission group). Granting it automatically also grants the base **Access API** permission.
|
||||
|
||||
Like the REST API, authentication against the MCP endpoint is done using an [API token]({% link
|
||||
api/authentication.md %}). A token with the **Read-Only** scope is enough to use all currently available MCP tools,
|
||||
as they only read data.
|
||||
|
||||
## Connecting an AI client
|
||||
|
||||
To connect an AI client to Part-DB, you need two things:
|
||||
|
||||
* The **MCP endpoint URL**, e.g. `https://your-part-db.local/mcp`. Once you have the required permission, you can
|
||||
also find it on the **API** panel of your user settings page, under "MCP endpoint", together with a
|
||||
copy-to-clipboard button.
|
||||
* An **API token**. Create one on the same **API** panel of your user settings page (see
|
||||
[Authentication]({% link api/authentication.md %}) for details about tokens and scopes). A token with the
|
||||
**Read-Only** scope is sufficient.
|
||||
|
||||
The client has to send this token as a bearer token in the `Authorization` header of every request:
|
||||
`Authorization: Bearer tcp_<your-token>`. How exactly you configure this depends on the AI client you use; some
|
||||
examples for common clients are shown below.
|
||||
|
||||
{: .note }
|
||||
> Part-DB's MCP server only supports the **Streamable HTTP** transport (no stdio, no plain SSE). Most modern MCP
|
||||
> clients support this transport directly. Clients that only support local, stdio-based MCP servers can be bridged to
|
||||
> a remote HTTP server with a small proxy tool like [`mcp-remote`](https://www.npmjs.com/package/mcp-remote), as shown
|
||||
> in the Claude Desktop example below.
|
||||
|
||||
MCP client configuration formats change frequently, so if the examples below don't quite match what you see in your
|
||||
client, check the client's own documentation for how to add a remote MCP server with a custom `Authorization` header.
|
||||
|
||||
### Claude Code
|
||||
|
||||
Add the server with the Claude Code CLI:
|
||||
|
||||
```bash
|
||||
claude mcp add part-db https://your-part-db.local/mcp \
|
||||
--transport http \
|
||||
--header "Authorization: Bearer tcp_<your-token>"
|
||||
```
|
||||
|
||||
### Claude Desktop
|
||||
|
||||
Claude Desktop currently only launches local (stdio) MCP servers directly from its config file, so a remote server
|
||||
like Part-DB's has to be bridged with the [`mcp-remote`](https://www.npmjs.com/package/mcp-remote) proxy. Open
|
||||
Claude Desktop's configuration file (**Settings → Developer → Edit Config**) and add:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"part-db": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"mcp-remote",
|
||||
"https://your-part-db.local/mcp",
|
||||
"--header",
|
||||
"Authorization: Bearer tcp_<your-token>"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
### Google Antigravity
|
||||
|
||||
Open **Manage MCP Servers** and add the server via its JSON configuration:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"part-db": {
|
||||
"serverUrl": "https://your-part-db.local/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer tcp_<your-token>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Cursor
|
||||
|
||||
Add the following to your `.cursor/mcp.json` (project-specific) or global Cursor MCP settings:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"part-db": {
|
||||
"url": "https://your-part-db.local/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer tcp_<your-token>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### VS Code (MCP support / GitHub Copilot)
|
||||
|
||||
Add the following to your `.vscode/mcp.json` (or use the **MCP: Add Server** command from the command palette):
|
||||
|
||||
```json
|
||||
{
|
||||
"servers": {
|
||||
"part-db": {
|
||||
"type": "http",
|
||||
"url": "https://your-part-db.local/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer tcp_<your-token>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Other clients
|
||||
|
||||
Any MCP client that supports the Streamable HTTP transport with custom headers can connect to Part-DB, you generally
|
||||
just need to provide:
|
||||
|
||||
* **URL**: `https://your-part-db.local/mcp`
|
||||
* **Transport**: Streamable HTTP
|
||||
* **Header**: `Authorization: Bearer tcp_<your-token>`
|
||||
|
||||
## Available tools
|
||||
|
||||
The following MCP tools are currently available. All of them are read-only.
|
||||
|
||||
### Parts
|
||||
|
||||
* **search_parts** – Search for parts by a keyword, with toggles to control which fields are searched (name,
|
||||
description, comment, tags, storage location, supplier order number, MPN, IPN, supplier, manufacturer, footprint,
|
||||
category, database ID), and an optional regex mode.
|
||||
* **get_part_details** – Get full details about a specific part by its database ID, including stock, prices,
|
||||
order details, attachments, parameters and EDA info.
|
||||
|
||||
### Master data
|
||||
|
||||
Categories, footprints, manufacturers, storage locations, measurement units, suppliers and part custom states all
|
||||
expose the same pair of tools:
|
||||
|
||||
* **list_categories** / **get_category_details**
|
||||
* **list_footprints** / **get_footprint_details**
|
||||
* **list_manufacturers** / **get_manufacturer_details**
|
||||
* **list_storage_locations** / **get_storage_location_details**
|
||||
* **list_measurement_units** / **get_measurement_unit_details**
|
||||
* **list_suppliers** / **get_supplier_details**
|
||||
* **list_part_custom_states** / **get_part_custom_state_details**
|
||||
|
||||
Each `list_*` tool accepts an optional `keyword`, matched against the name and comment. Without a keyword, all
|
||||
elements are returned in hierarchical tree order; with a keyword, matching results are sorted by their full path, so
|
||||
parent/child relationships can still be derived from the flat list. Each `get_*_details` tool takes the element's
|
||||
database `id` and returns its full details.
|
||||
|
||||
### Projects
|
||||
|
||||
* **list_projects** / **get_project_details** – Same behavior as the master data tools above.
|
||||
`get_project_details` additionally returns the project's BOM entries, status, description and associated build
|
||||
part.
|
||||
|
||||
### Info Provider System
|
||||
|
||||
These tools query external part information providers (e.g. Digikey, Mouser, LCSC), see the
|
||||
[Information provider system]({% link usage/information_provider_system.md %}) page for background:
|
||||
|
||||
* **list_info_providers** – List the info providers that are currently active and can be used with the two tools
|
||||
below.
|
||||
* **search_info_providers** – Search one or more external info providers (or the configured default providers, if
|
||||
none are specified) for parts matching a keyword.
|
||||
* **get_info_provider_part_details** – Get full details (datasheets, images, parameters, prices, ...) for a specific
|
||||
search result, identified by the `provider_key` and `provider_id` returned by `search_info_providers`.
|
||||
|
|
@ -73,7 +73,7 @@ class BrowserPluginController extends AbstractController
|
|||
if (isset($activeProviders[$key])) {
|
||||
$urlProviders[] = [
|
||||
'id' => $key,
|
||||
'label' => $activeProviders[$key]->getProviderInfo()['name'],
|
||||
'label' => $activeProviders[$key]->getProviderInfo()->name,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ class InfoProviderController extends AbstractController
|
|||
$this->denyAccessUnlessGranted('@info_providers.create_parts');
|
||||
|
||||
$providerInstance = $this->providerRegistry->getProviderByKey($provider);
|
||||
$settingsClass = $providerInstance->getProviderInfo()['settings_class'] ?? throw new \LogicException('Provider ' . $provider . ' does not have a settings class defined');
|
||||
$settingsClass = $providerInstance->getProviderInfo()->settingsClass ?? throw new \LogicException('Provider ' . $provider . ' does not have a settings class defined');
|
||||
|
||||
//Create a clone of the settings object
|
||||
$settings = $this->settingsManager->createTemporaryCopy($settingsClass);
|
||||
|
|
|
|||
|
|
@ -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. Each entry includes its full hierarchical path, and results are sorted by that path so parents are immediately followed by their own children, making it easy to derive the tree structure from the flat list.',
|
||||
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. Each entry includes its full hierarchical path, and results are sorted by that path so parents are immediately followed by their own children, making it easy to derive the tree structure from the flat list.',
|
||||
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. Each entry includes its full hierarchical path, and results are sorted by that path so parents are immediately followed by their own children, making it easy to derive the tree structure from the flat list.',
|
||||
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). Each entry includes its full hierarchical path, and results are sorted by that path so parents are immediately followed by their own children, making it easy to derive the tree structure from the flat list.',
|
||||
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"])]
|
||||
|
|
|
|||
|
|
@ -32,6 +32,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;
|
||||
|
|
@ -39,6 +41,7 @@ use App\ApiPlatform\Filter\EntityFilter;
|
|||
use App\ApiPlatform\Filter\LikeFilter;
|
||||
use App\ApiPlatform\Filter\PartStoragelocationFilter;
|
||||
use App\ApiPlatform\Filter\TagFilter;
|
||||
use App\DataTables\Filters\PartSearchFilter;
|
||||
use App\Entity\Attachments\Attachment;
|
||||
use App\Entity\Attachments\AttachmentContainingDBElement;
|
||||
use App\Entity\Attachments\PartAttachment;
|
||||
|
|
@ -55,7 +58,10 @@ use App\Entity\Parts\PartTraits\ManufacturerTrait;
|
|||
use App\Entity\Parts\PartTraits\OrderTrait;
|
||||
use App\Entity\Parts\PartTraits\ProjectTrait;
|
||||
use App\EntityListeners\TreeCacheInvalidationListener;
|
||||
use App\Mcp\DTO\ElementByIdInput;
|
||||
use App\Repository\PartRepository;
|
||||
use App\State\Mcp\GetPartByIdProcessor;
|
||||
use App\State\Mcp\SearchPartsProcessor;
|
||||
use App\Validator\Constraints\UniqueObjectCollection;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
|
|
@ -80,7 +86,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
|||
#[ORM\Index(columns: ['datetime_added', 'name', 'last_modified', 'id', 'needs_review'], name: 'parts_idx_datet_name_last_id_needs')]
|
||||
#[ORM\Index(columns: ['name'], name: 'parts_idx_name')]
|
||||
#[ORM\Index(columns: ['ipn'], name: 'parts_idx_ipn')]
|
||||
#[ORM\Index(columns: ['gtin'], name: 'parts_idx_gtin')]
|
||||
#[ORM\Index(name: 'parts_idx_gtin', columns: ['gtin'])]
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Get(normalizationContext: [
|
||||
|
|
@ -104,6 +110,27 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
|||
],
|
||||
normalizationContext: ['groups' => ['part:read', 'provider_reference:read', 'api:basic:read', 'part_lot:read'], 'openapi_definition_name' => 'Read'],
|
||||
denormalizationContext: ['groups' => ['part:write', 'api:basic:write', 'eda_info:write', 'attachment:write', 'parameter:write'], 'openapi_definition_name' => 'Write'],
|
||||
mcp: [
|
||||
'search_parts' => new McpToolCollection(
|
||||
title: "Search parts by keyword",
|
||||
description: 'Search for parts',
|
||||
annotations: ['readOnlyHint' => true, 'destructiveHint' => false, 'idempotentHint' => true, 'openWorldHint' => false],
|
||||
input: PartSearchFilter::class,
|
||||
processor: SearchPartsProcessor::class,
|
||||
),
|
||||
'get_part_details' => new McpTool(
|
||||
title: 'Get part details by ID',
|
||||
description: 'Get detailed information about a specific part by its database ID',
|
||||
annotations: ['readOnlyHint' => true, 'destructiveHint' => false, 'idempotentHint' => true, 'openWorldHint' => false],
|
||||
normalizationContext: [
|
||||
'groups' => ['part:read', 'provider_reference:read', 'api:basic:read', 'part_lot:read', 'orderdetail:read', 'pricedetail:read', 'parameter:read', 'attachment:read', 'eda_info:read'],
|
||||
'item_uri_template' => '/api/parts/{id}',
|
||||
],
|
||||
input: ElementByIdInput::class,
|
||||
validate: true,
|
||||
processor: GetPartByIdProcessor::class
|
||||
),
|
||||
],
|
||||
)]
|
||||
#[ApiFilter(PropertyFilter::class)]
|
||||
#[ApiFilter(EntityFilter::class, properties: ["category", "footprint", "manufacturer", "partUnit", "partCustomState"])]
|
||||
|
|
|
|||
|
|
@ -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"). Each entry includes its full hierarchical path, and results are sorted by that path so parents are immediately followed by their own children, making it easy to derive the tree structure from the flat list.',
|
||||
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. Each entry includes its full hierarchical path, and results are sorted by that path so parents are immediately followed by their own children, making it easy to derive the tree structure from the flat list.',
|
||||
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. Each entry includes its full hierarchical path, and results are sorted by that path so parents are immediately followed by their own children, making it easy to derive the tree structure from the flat list.',
|
||||
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"])]
|
||||
|
|
|
|||
|
|
@ -31,13 +31,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\DeviceRepository;
|
||||
use App\State\Mcp\GetStructuralElementDetailsProcessor;
|
||||
use App\State\Mcp\ListStructuralElementsProcessor;
|
||||
use App\Validator\Constraints\UniqueObjectCollection;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use App\Entity\Attachments\ProjectAttachment;
|
||||
|
|
@ -75,6 +82,28 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
|||
],
|
||||
normalizationContext: ['groups' => ['project:read', 'api:basic:read'], 'openapi_definition_name' => 'Read'],
|
||||
denormalizationContext: ['groups' => ['project:write', 'api:basic:write', 'attachment:write', 'parameter:write'], 'openapi_definition_name' => 'Write'],
|
||||
mcp: [
|
||||
'list_projects' => new McpToolCollection(
|
||||
title: 'List/search projects',
|
||||
description: 'List all projects, optionally filtered by a keyword matched against the name and comment. Each entry includes its full hierarchical path, and results are sorted by that path so parents are immediately followed by their own children, making it easy to derive the tree structure from the flat list. Use get_project_details for a specific project to retrieve its BOM entries, status and other details.',
|
||||
annotations: ['readOnlyHint' => true, 'destructiveHint' => false, 'idempotentHint' => true, 'openWorldHint' => false],
|
||||
output: StructuralElementOverview::class,
|
||||
normalizationContext: ['groups' => ['mcp_structural_overview:read']],
|
||||
input: StructuralElementSearchInput::class,
|
||||
security: 'is_granted("@projects.read")',
|
||||
processor: ListStructuralElementsProcessor::class,
|
||||
),
|
||||
'get_project_details' => new McpTool(
|
||||
title: 'Get project details by ID',
|
||||
description: 'Get detailed information about a specific project by its database ID, including its BOM entries, status, description and associated build part.',
|
||||
annotations: ['readOnlyHint' => true, 'destructiveHint' => false, 'idempotentHint' => true, 'openWorldHint' => false],
|
||||
normalizationContext: ['groups' => ['project:read', 'api:basic:read', 'mcp_project_details:read']],
|
||||
input: ElementByIdInput::class,
|
||||
security: 'is_granted("@projects.read")',
|
||||
validate: true,
|
||||
processor: GetStructuralElementDetailsProcessor::class,
|
||||
),
|
||||
],
|
||||
)]
|
||||
#[ApiFilter(PropertyFilter::class)]
|
||||
#[ApiFilter(LikeFilter::class, properties: ["name", "comment"])]
|
||||
|
|
@ -98,7 +127,7 @@ class Project extends AbstractStructuralDBElement
|
|||
* @var Collection<int, ProjectBOMEntry>
|
||||
*/
|
||||
#[Assert\Valid]
|
||||
#[Groups(['extended', 'full', 'import'])]
|
||||
#[Groups(['extended', 'full', 'import', 'mcp_project_details:read'])]
|
||||
#[ORM\OneToMany(mappedBy: 'project', targetEntity: ProjectBOMEntry::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||
#[UniqueObjectCollection(message: 'project.bom_entry.part_already_in_bom', fields: ['part'])]
|
||||
#[UniqueObjectCollection(message: 'project.bom_entry.name_already_in_bom', fields: ['name'])]
|
||||
|
|
|
|||
|
|
@ -83,14 +83,14 @@ class ProjectBOMEntry extends AbstractDBElement implements UniqueValidatableInte
|
|||
|
||||
#[Assert\Positive]
|
||||
#[ORM\Column(name: 'quantity', type: Types::FLOAT)]
|
||||
#[Groups(['bom_entry:read', 'bom_entry:write', 'import', 'simple', 'extended', 'full'])]
|
||||
#[Groups(['bom_entry:read', 'bom_entry:write', 'import', 'simple', 'extended', 'full', 'mcp_project_details:read'])]
|
||||
protected float $quantity = 1.0;
|
||||
|
||||
/**
|
||||
* @var string A comma separated list of the names, where this parts should be placed
|
||||
*/
|
||||
#[ORM\Column(name: 'mountnames', type: Types::TEXT)]
|
||||
#[Groups(['bom_entry:read', 'bom_entry:write', 'import', 'simple', 'extended', 'full'])]
|
||||
#[Groups(['bom_entry:read', 'bom_entry:write', 'import', 'simple', 'extended', 'full', 'mcp_project_details:read'])]
|
||||
protected string $mountnames = '';
|
||||
|
||||
/**
|
||||
|
|
@ -98,14 +98,14 @@ class ProjectBOMEntry extends AbstractDBElement implements UniqueValidatableInte
|
|||
*/
|
||||
#[Assert\Expression('this.getPart() !== null or this.getName() !== null', message: 'validator.project.bom_entry.name_or_part_needed')]
|
||||
#[ORM\Column(type: Types::STRING, nullable: true)]
|
||||
#[Groups(['bom_entry:read', 'bom_entry:write', 'import', 'simple', 'extended', 'full'])]
|
||||
#[Groups(['bom_entry:read', 'bom_entry:write', 'import', 'simple', 'extended', 'full', 'mcp_project_details:read'])]
|
||||
protected ?string $name = null;
|
||||
|
||||
/**
|
||||
* @var string An optional comment for this BOM entry
|
||||
*/
|
||||
#[ORM\Column(type: Types::TEXT)]
|
||||
#[Groups(['bom_entry:read', 'bom_entry:write', 'import', 'extended', 'full'])]
|
||||
#[Groups(['bom_entry:read', 'bom_entry:write', 'import', 'extended', 'full', 'mcp_project_details:read'])]
|
||||
protected string $comment = '';
|
||||
|
||||
/**
|
||||
|
|
@ -121,7 +121,7 @@ class ProjectBOMEntry extends AbstractDBElement implements UniqueValidatableInte
|
|||
*/
|
||||
#[ORM\ManyToOne(targetEntity: Part::class, inversedBy: 'project_bom_entries')]
|
||||
#[ORM\JoinColumn(name: 'id_part')]
|
||||
#[Groups(['bom_entry:read', 'bom_entry:write', 'full'])]
|
||||
#[Groups(['bom_entry:read', 'bom_entry:write', 'full', 'mcp_project_details:read'])]
|
||||
protected ?Part $part = null;
|
||||
|
||||
/**
|
||||
|
|
@ -129,7 +129,7 @@ class ProjectBOMEntry extends AbstractDBElement implements UniqueValidatableInte
|
|||
*/
|
||||
#[Assert\AtLeastOneOf([new BigDecimalPositive(), new Assert\IsNull()])]
|
||||
#[ORM\Column(type: 'big_decimal', precision: 11, scale: 5, nullable: true)]
|
||||
#[Groups(['bom_entry:read', 'bom_entry:write', 'import', 'extended', 'full'])]
|
||||
#[Groups(['bom_entry:read', 'bom_entry:write', 'import', 'extended', 'full', 'mcp_project_details:read'])]
|
||||
protected ?BigDecimal $price = null;
|
||||
|
||||
/**
|
||||
|
|
|
|||
60
src/EventSubscriber/McpAccessSubscriber.php
Normal file
60
src/EventSubscriber/McpAccessSubscriber.php
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
<?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\EventSubscriber;
|
||||
|
||||
use App\Settings\AISettings\McpSettings;
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException;
|
||||
use Symfony\Component\HttpKernel\KernelEvents;
|
||||
|
||||
readonly class McpAccessSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
public function __construct(private McpSettings $mcpSettings)
|
||||
{
|
||||
}
|
||||
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return [
|
||||
KernelEvents::REQUEST => ['onKernelRequest', 10],
|
||||
];
|
||||
}
|
||||
|
||||
public function onKernelRequest(RequestEvent $event): void
|
||||
{
|
||||
if (!$event->isMainRequest()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$path = $event->getRequest()->getPathInfo();
|
||||
|
||||
if (!str_starts_with($path, '/mcp')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->mcpSettings->enabled) {
|
||||
throw new ServiceUnavailableHttpException(null, 'The MCP endpoint is disabled. Enable it in the system settings.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -43,6 +43,7 @@ class InfoProviderNotActiveException extends \RuntimeException
|
|||
*/
|
||||
public static function fromProvider(InfoProviderInterface $provider): self
|
||||
{
|
||||
return new self($provider->getProviderKey(), $provider->getProviderInfo()['name'] ?? '???');
|
||||
$info = $provider->getProviderInfo();
|
||||
return new self($info->key, $info->name);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,8 +66,8 @@ class ProviderSelectType extends AbstractType
|
|||
|
||||
$tmp = [];
|
||||
foreach ($providers as $provider) {
|
||||
$name = $provider->getProviderInfo()['name'];
|
||||
$tmp[$name] = $provider->getProviderKey();
|
||||
$info = $provider->getProviderInfo();
|
||||
$tmp[$info->name] = $info->key;
|
||||
}
|
||||
|
||||
return $tmp;
|
||||
|
|
@ -77,7 +77,7 @@ class ProviderSelectType extends AbstractType
|
|||
$resolver->setDefault('choice_label', function (Options $options) {
|
||||
if ('object' === $options['input']) {
|
||||
return ChoiceList::label($this, static fn(?InfoProviderInterface $choice
|
||||
) => new StaticMessage($choice?->getProviderInfo()['name']));
|
||||
) => new StaticMessage($choice?->getProviderInfo()->name));
|
||||
}
|
||||
|
||||
return static fn($choice, $key, $value) => new StaticMessage($key);
|
||||
|
|
@ -85,7 +85,7 @@ class ProviderSelectType extends AbstractType
|
|||
$resolver->setDefault('choice_value', function (Options $options) {
|
||||
if ('object' === $options['input']) {
|
||||
return ChoiceList::value($this,
|
||||
static fn(?InfoProviderInterface $choice) => $choice?->getProviderKey());
|
||||
static fn(?InfoProviderInterface $choice) => $choice?->getProviderInfo()->key);
|
||||
}
|
||||
|
||||
return null;
|
||||
|
|
|
|||
35
src/Mcp/DTO/ElementByIdInput.php
Normal file
35
src/Mcp/DTO/ElementByIdInput.php
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<?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\Validator\Constraints as Assert;
|
||||
|
||||
readonly class ElementByIdInput
|
||||
{
|
||||
public function __construct(
|
||||
#[Assert\NotNull]
|
||||
#[Assert\Positive]
|
||||
public int $id,
|
||||
) {
|
||||
}
|
||||
}
|
||||
38
src/Mcp/DTO/InfoProviderPartDetailsInput.php
Normal file
38
src/Mcp/DTO/InfoProviderPartDetailsInput.php
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
<?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\Validator\Constraints as Assert;
|
||||
|
||||
readonly class InfoProviderPartDetailsInput
|
||||
{
|
||||
public function __construct(
|
||||
/** @var string The key of the info provider (e.g. "digikey", "mouser", "lcsc"), as returned by search_info_providers */
|
||||
#[Assert\NotBlank]
|
||||
public string $provider_key,
|
||||
/** @var string The provider-specific ID of the part, as returned by search_info_providers */
|
||||
#[Assert\NotBlank]
|
||||
public string $provider_id,
|
||||
) {
|
||||
}
|
||||
}
|
||||
41
src/Mcp/DTO/InfoProviderSearchInput.php
Normal file
41
src/Mcp/DTO/InfoProviderSearchInput.php
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
<?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\Validator\Constraints as Assert;
|
||||
|
||||
readonly class InfoProviderSearchInput
|
||||
{
|
||||
public function __construct(
|
||||
/** @var string The keyword to search for (e.g. a part name, manufacturer product number or GTIN) */
|
||||
#[Assert\NotBlank]
|
||||
public string $keyword,
|
||||
/**
|
||||
* @var string[]|null The keys of the info providers to search in (e.g. "digikey", "mouser", "lcsc"). If not
|
||||
* given, the default search providers configured in the system settings are used.
|
||||
*/
|
||||
#[Assert\All([new Assert\Type('string')])]
|
||||
public ?array $providers = null,
|
||||
) {
|
||||
}
|
||||
}
|
||||
30
src/Mcp/DTO/ListInfoProvidersInput.php
Normal file
30
src/Mcp/DTO/ListInfoProvidersInput.php
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<?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;
|
||||
|
||||
/**
|
||||
* This tool takes no parameters, it just lists the currently available info providers.
|
||||
*/
|
||||
readonly class ListInfoProvidersInput
|
||||
{
|
||||
}
|
||||
50
src/Mcp/DTO/StructuralElementOverview.php
Normal file
50
src/Mcp/DTO/StructuralElementOverview.php
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
<?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,
|
||||
/** @var string The full path of the element (including the names of all its parent elements, e.g. "Parent → Child") */
|
||||
#[Groups(['mcp_structural_overview:read'])]
|
||||
public string $full_path,
|
||||
) {
|
||||
}
|
||||
}
|
||||
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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
69
src/Serializer/StructuralElementOverviewNormalizer.php
Normal file
69
src/Serializer/StructuralElementOverviewNormalizer.php
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
<?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, full_path} 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 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, full_path: 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,
|
||||
'full_path' => $object->full_path,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return bool[]
|
||||
*/
|
||||
public function getSupportedTypes(?string $format): array
|
||||
{
|
||||
return [
|
||||
StructuralElementOverview::class => true,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -72,7 +72,7 @@ final readonly class CreateFromUrlHelper
|
|||
|
||||
$provider = $this->providerRegistry->getProviderHandlingDomain($host);
|
||||
|
||||
if ($provider !== null && $provider->isActive() && $provider->getProviderKey() !== $callingInfoProvider->getProviderKey()) {
|
||||
if ($provider !== null && $provider->isActive() && $provider->getProviderInfo()->key !== $callingInfoProvider->getProviderInfo()->key) {
|
||||
try {
|
||||
$id = $provider->getIDFromURL($url);
|
||||
if ($id !== null) {
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ readonly class BulkSearchFieldMappingDTO
|
|||
//Ensure that providers are provided as keys
|
||||
foreach ($providers as &$provider) {
|
||||
if ($provider instanceof InfoProviderInterface) {
|
||||
$provider = $provider->getProviderKey();
|
||||
$provider = $provider->getProviderInfo()->key;
|
||||
}
|
||||
if (!is_string($provider)) {
|
||||
throw new \InvalidArgumentException('Providers must be provided as strings or InfoProviderInterface instances');
|
||||
|
|
|
|||
|
|
@ -23,11 +23,41 @@ declare(strict_types=1);
|
|||
|
||||
namespace App\Services\InfoProviderSystem\DTOs;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\McpTool;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use ApiPlatform\OpenApi\Model\Operation;
|
||||
use App\Entity\Parts\ManufacturingStatus;
|
||||
use App\Mcp\DTO\InfoProviderPartDetailsInput;
|
||||
use App\State\Mcp\GetInfoProviderPartDetailsProcessor;
|
||||
|
||||
/**
|
||||
* This DTO represents a part with all its details.
|
||||
*/
|
||||
#[ApiResource(
|
||||
description: 'Detailed information about a part from an external info provider (e.g. a distributor or manufacturer catalog), including datasheets, images, parameters and purchase information.',
|
||||
operations: [
|
||||
new Post(
|
||||
uriTemplate: '/info_providers/details',
|
||||
security: 'is_granted("@info_providers.create_parts")',
|
||||
input: InfoProviderPartDetailsInput::class,
|
||||
validate: true,
|
||||
processor: GetInfoProviderPartDetailsProcessor::class,
|
||||
openapi: new Operation(summary: 'Get full detailed information about a specific part from an external info provider.'),
|
||||
),
|
||||
],
|
||||
mcp: [
|
||||
'get_info_provider_part_details' => new McpTool(
|
||||
title: 'Get part details from an info provider',
|
||||
description: 'Get full detailed information (datasheets, images, parameters, prices, ...) about a specific part from an external info provider, identified by the provider key and the provider-specific part ID (both returned by search_info_providers).',
|
||||
annotations: ['readOnlyHint' => true, 'destructiveHint' => false, 'idempotentHint' => true, 'openWorldHint' => true],
|
||||
input: InfoProviderPartDetailsInput::class,
|
||||
security: 'is_granted("@info_providers.create_parts")',
|
||||
validate: true,
|
||||
processor: GetInfoProviderPartDetailsProcessor::class,
|
||||
),
|
||||
],
|
||||
)]
|
||||
class PartDetailDTO extends SearchResultDTO
|
||||
{
|
||||
public function __construct(
|
||||
|
|
|
|||
|
|
@ -24,12 +24,14 @@ declare(strict_types=1);
|
|||
namespace App\Services\InfoProviderSystem\DTOs;
|
||||
|
||||
use Brick\Math\BigDecimal;
|
||||
use Symfony\Component\Serializer\Attribute\Ignore;
|
||||
|
||||
/**
|
||||
* This DTO represents a price for a single unit in a certain discount range
|
||||
*/
|
||||
readonly class PriceDTO
|
||||
{
|
||||
#[Ignore]
|
||||
private BigDecimal $price_as_big_decimal;
|
||||
|
||||
public function __construct(
|
||||
|
|
@ -54,6 +56,7 @@ readonly class PriceDTO
|
|||
* Gets the price as BigDecimal
|
||||
* @return BigDecimal
|
||||
*/
|
||||
#[Ignore]
|
||||
public function getPriceAsBigDecimal(): BigDecimal
|
||||
{
|
||||
return $this->price_as_big_decimal;
|
||||
|
|
|
|||
104
src/Services/InfoProviderSystem/DTOs/ProviderInfoDTO.php
Normal file
104
src/Services/InfoProviderSystem/DTOs/ProviderInfoDTO.php
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
<?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\Services\InfoProviderSystem\DTOs;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\McpToolCollection;
|
||||
use ApiPlatform\OpenApi\Model\Operation;
|
||||
use App\Mcp\DTO\ListInfoProvidersInput;
|
||||
use App\Services\InfoProviderSystem\Providers\ProviderCapabilities;
|
||||
use App\State\Mcp\ListInfoProvidersProcessor;
|
||||
use Symfony\Component\Serializer\Annotation\Groups;
|
||||
|
||||
/**
|
||||
* Immutable, structured description of an info provider, returned by InfoProviderInterface::getProviderInfo()
|
||||
* and (via the 'info_provider:read' group) exposed as the REST GET /api/info_providers collection and the
|
||||
* list_info_providers MCP tool.
|
||||
*
|
||||
* disabledHelp, oauthAppName and settingsClass are internal-only (used by the settings UI) and deliberately
|
||||
* not tagged with the 'info_provider:read' group, so they never appear in the API/MCP output.
|
||||
*/
|
||||
#[ApiResource(
|
||||
uriTemplate: '/info_providers',
|
||||
description: 'An info provider which can be used to search for parts and retrieve part details.',
|
||||
operations: [
|
||||
new GetCollection(
|
||||
openapi: new Operation(summary: 'List the info providers which are currently active and can be used for searching parts.'),
|
||||
security: 'is_granted("@info_providers.create_parts")',
|
||||
provider: ListInfoProvidersProcessor::class,
|
||||
),
|
||||
],
|
||||
normalizationContext: ['groups' => ['info_provider:read']],
|
||||
paginationEnabled: false,
|
||||
mcp: [
|
||||
'list_info_providers' => new McpToolCollection(
|
||||
title: 'List available info providers',
|
||||
description: 'List the info providers (e.g. distributors like Digikey, Mouser, LCSC) which are currently active and can be used with search_info_providers and get_info_provider_part_details. Ask for the user\'s confirmation before using expensive providers (e.g. distributors with strict rate limits or which cost money to use).',
|
||||
annotations: ['readOnlyHint' => true, 'destructiveHint' => false, 'idempotentHint' => true, 'openWorldHint' => false],
|
||||
normalizationContext: ['groups' => ['info_provider:read']],
|
||||
security: 'is_granted("@info_providers.create_parts")',
|
||||
input: ListInfoProvidersInput::class,
|
||||
processor: ListInfoProvidersProcessor::class,
|
||||
),
|
||||
],
|
||||
)]
|
||||
readonly class ProviderInfoDTO
|
||||
{
|
||||
public function __construct(
|
||||
/** @var string A unique key for this provider (e.g. "digikey"), which is saved into the database and used to identify the provider */
|
||||
#[Groups(['info_provider:read'])]
|
||||
public string $key,
|
||||
/** @var string The (user friendly) name of the provider (e.g. "Digikey"), will be translated */
|
||||
#[Groups(['info_provider:read'])]
|
||||
public string $name,
|
||||
/** @var string|null A short description of the provider (e.g. "Digikey is a ..."), will be translated */
|
||||
#[Groups(['info_provider:read'])]
|
||||
public ?string $description = null,
|
||||
/** @var string|null The url of the provider (e.g. "https://www.digikey.com") */
|
||||
#[Groups(['info_provider:read'])]
|
||||
public ?string $url = null,
|
||||
/** @var string|null A help text which is shown when the provider is disabled, explaining how to enable it */
|
||||
public ?string $disabledHelp = null,
|
||||
/** @var string|null The name of the OAuth app which is used for authentication (e.g. "ip_digikey_oauth"). If this is set a connect button will be shown */
|
||||
public ?string $oauthAppName = null,
|
||||
/** @var class-string|null The class name of the settings class which contains the settings for this provider (e.g. "App\Settings\InfoProviderSettings\DigikeySettings"). If this is set a link to the settings will be shown */
|
||||
public ?string $settingsClass = null,
|
||||
/**
|
||||
* A list of capabilities this provider supports (which kind of data it can provide).
|
||||
* Not every part have to contain all of these data, but the provider should be able to provide them in general.
|
||||
* Currently, this list is purely informational and not used in functional checks.
|
||||
* @var ProviderCapabilities[]
|
||||
*/
|
||||
#[Groups(['info_provider:read'])]
|
||||
public array $capabilities = [],
|
||||
/**
|
||||
* @var bool True if this provider is considered "expensive" (e.g. it has a strict rate limit or costs money to use), false otherwise.
|
||||
* Users should be asked for confirmation before using an expensive provider, when making multiple requests (e.g. when searching for multiple parts at once),
|
||||
* and more careful caching should be used for expensive providers.
|
||||
*/
|
||||
#[Groups(['info_provider:read'])]
|
||||
public bool $expensive = false,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
|
@ -23,12 +23,42 @@ declare(strict_types=1);
|
|||
|
||||
namespace App\Services\InfoProviderSystem\DTOs;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\McpToolCollection;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use ApiPlatform\OpenApi\Model\Operation;
|
||||
use App\Entity\Parts\ManufacturingStatus;
|
||||
use App\Mcp\DTO\InfoProviderSearchInput;
|
||||
use App\State\Mcp\SearchInfoProvidersProcessor;
|
||||
|
||||
/**
|
||||
* This DTO represents a search result for a part.
|
||||
* @see \App\Tests\Services\InfoProviderSystem\DTOs\SearchResultDTOTest
|
||||
*/
|
||||
#[ApiResource(
|
||||
description: 'A search result for a part from an external info provider (e.g. a distributor or manufacturer catalog).',
|
||||
operations: [
|
||||
new Post(
|
||||
uriTemplate: '/info_providers/search',
|
||||
security: 'is_granted("@info_providers.create_parts")',
|
||||
input: InfoProviderSearchInput::class,
|
||||
validate: true,
|
||||
processor: SearchInfoProvidersProcessor::class,
|
||||
openapi: new Operation(summary: 'Search external info providers (e.g. distributors like Digikey, Mouser, LCSC) for parts matching a keyword.'),
|
||||
),
|
||||
],
|
||||
mcp: [
|
||||
'search_info_providers' => new McpToolCollection(
|
||||
title: 'Search external info providers',
|
||||
description: 'Search external info providers (e.g. distributors like Digikey, Mouser, LCSC) for parts matching a keyword. Returns a list of search results, which can be passed to get_info_provider_part_details to retrieve full details for a specific result.',
|
||||
annotations: ['readOnlyHint' => true, 'destructiveHint' => false, 'idempotentHint' => true, 'openWorldHint' => true],
|
||||
input: InfoProviderSearchInput::class,
|
||||
security: 'is_granted("@info_providers.create_parts")',
|
||||
validate: true,
|
||||
processor: SearchInfoProvidersProcessor::class,
|
||||
),
|
||||
],
|
||||
)]
|
||||
class SearchResultDTO
|
||||
{
|
||||
/** @var string|null An URL to a preview image */
|
||||
|
|
|
|||
|
|
@ -103,7 +103,7 @@ final class PartInfoRetriever
|
|||
//Generate a hash for the options, to ensure that different options result in different cache entries
|
||||
$options_hash = hash('xxh3', json_encode($options_without_cache, JSON_THROW_ON_ERROR));
|
||||
|
||||
$cache_key = "search_{$provider->getProviderKey()}_{$escaped_keyword}_{$options_hash}";
|
||||
$cache_key = "search_{$provider->getProviderInfo()->key}_{$escaped_keyword}_{$options_hash}";
|
||||
|
||||
//If no_cache is set, bypass the cache and get fresh results from the provider
|
||||
if ($no_cache) {
|
||||
|
|
@ -126,6 +126,7 @@ final class PartInfoRetriever
|
|||
* @param array<string, mixed> $options An associative array of options which can be used to modify the search behavior. The supported options depend on the provider and should be documented in the provider's documentation.
|
||||
* @return PartDetailDTO
|
||||
* @throws InfoProviderNotActiveException if the the given providers is not active
|
||||
* @throws \InvalidArgumentException if the given provider key does not match any registered provider
|
||||
*/
|
||||
public function getDetails(string $provider_key, string $part_id, array $options = []): PartDetailDTO
|
||||
{
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ final class ProviderRegistry
|
|||
private function initStructures(): void
|
||||
{
|
||||
foreach ($this->providers as $provider) {
|
||||
$key = $provider->getProviderKey();
|
||||
$key = $provider->getProviderInfo()->key;
|
||||
|
||||
if (isset($this->providers_by_name[$key])) {
|
||||
throw new \LogicException("Provider with key $key already registered");
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ use App\Services\InfoProviderSystem\SubmittedPageStorage;
|
|||
use App\Services\InfoProviderSystem\CreateFromUrlHelper;
|
||||
use App\Services\InfoProviderSystem\DTOJsonSchemaConverter;
|
||||
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\ProviderInfoDTO;
|
||||
use App\Settings\InfoProviderSystem\AIExtractorSettings;
|
||||
use Jkphl\Micrometa;
|
||||
use League\HTMLToMarkdown\HtmlConverter;
|
||||
|
|
@ -50,6 +51,8 @@ final class AIWebProvider implements InfoProviderInterface
|
|||
{
|
||||
use FixAndValidateUrlTrait;
|
||||
|
||||
public const PROVIDER_KEY = 'ai_web';
|
||||
|
||||
private const DISTRIBUTOR_NAME = 'Website';
|
||||
|
||||
private readonly HttpClientInterface $httpClient;
|
||||
|
|
@ -71,20 +74,23 @@ final class AIWebProvider implements InfoProviderInterface
|
|||
);
|
||||
}
|
||||
|
||||
public function getProviderInfo(): array
|
||||
public function getProviderInfo(): ProviderInfoDTO
|
||||
{
|
||||
return [
|
||||
'name' => 'AI Web Extractor',
|
||||
'description' => 'Extract part info from any URL using LLM',
|
||||
//'url' => 'https://openrouter.ai',
|
||||
'disabled_help' => 'Configure AI settings',
|
||||
'settings_class' => AIExtractorSettings::class,
|
||||
];
|
||||
}
|
||||
|
||||
public function getProviderKey(): string
|
||||
{
|
||||
return 'ai_web';
|
||||
return new ProviderInfoDTO(
|
||||
key: self::PROVIDER_KEY,
|
||||
name: 'AI Web Extractor',
|
||||
description: 'Extract part info from any URL using LLM',
|
||||
disabledHelp: 'Configure AI settings',
|
||||
settingsClass: AIExtractorSettings::class,
|
||||
capabilities: [
|
||||
ProviderCapabilities::BASIC,
|
||||
ProviderCapabilities::PICTURE,
|
||||
ProviderCapabilities::DATASHEET,
|
||||
ProviderCapabilities::PRICE,
|
||||
ProviderCapabilities::PARAMETERS,
|
||||
],
|
||||
expensive: true,
|
||||
);
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
|
|
@ -166,7 +172,7 @@ final class AIWebProvider implements InfoProviderInterface
|
|||
$llmResponse = $this->callLLM($markdown, $url, $structuredData);
|
||||
|
||||
// Build and return PartDetailDTO
|
||||
$result = $this->jsonSchemaConverter->jsonToDTO($llmResponse, $this->getProviderKey(), $url, $url, self::DISTRIBUTOR_NAME);
|
||||
$result = $this->jsonSchemaConverter->jsonToDTO($llmResponse, self::PROVIDER_KEY, $url, $url, self::DISTRIBUTOR_NAME);
|
||||
|
||||
// Cache the result for future use, to improve performance and reduce costs.
|
||||
$cacheItem->set($result);
|
||||
|
|
@ -256,17 +262,6 @@ final class AIWebProvider implements InfoProviderInterface
|
|||
return $converter->convert($htmlToConvert);
|
||||
}
|
||||
|
||||
public function getCapabilities(): array
|
||||
{
|
||||
return [
|
||||
ProviderCapabilities::BASIC,
|
||||
ProviderCapabilities::PICTURE,
|
||||
ProviderCapabilities::DATASHEET,
|
||||
ProviderCapabilities::PRICE,
|
||||
ProviderCapabilities::PARAMETERS,
|
||||
];
|
||||
}
|
||||
|
||||
private function callLLM(string $htmlContent, string $url, ?string $structuredData = null): array
|
||||
{
|
||||
$input = new MessageBag(
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ use App\Services\InfoProviderSystem\DTOs\FileDTO;
|
|||
use App\Services\InfoProviderSystem\DTOs\ParameterDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PriceDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\ProviderInfoDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\SearchResultDTO;
|
||||
use App\Settings\InfoProviderSystem\BuerklinSettings;
|
||||
|
|
@ -40,6 +41,7 @@ class BuerklinProvider implements BatchInfoProviderInterface, URLHandlerInfoProv
|
|||
private const ENDPOINT_URL = 'https://www.buerklin.com/buerklinws/v2/buerklin';
|
||||
|
||||
public const DISTRIBUTOR_NAME = 'Buerklin';
|
||||
public const PROVIDER_KEY = 'buerklin';
|
||||
|
||||
private const CACHE_TTL = 600;
|
||||
/**
|
||||
|
|
@ -175,20 +177,24 @@ class BuerklinProvider implements BatchInfoProviderInterface, URLHandlerInfoProv
|
|||
}
|
||||
|
||||
|
||||
public function getProviderInfo(): array
|
||||
public function getProviderInfo(): ProviderInfoDTO
|
||||
{
|
||||
return [
|
||||
'name' => 'Buerklin',
|
||||
'description' => 'This provider uses the Buerklin API to search for parts.',
|
||||
'url' => 'https://www.buerklin.com/',
|
||||
'disabled_help' => 'Configure the API Client ID, Secret, Username and Password provided by Buerklin in the provider settings to enable.',
|
||||
'settings_class' => BuerklinSettings::class
|
||||
];
|
||||
}
|
||||
|
||||
public function getProviderKey(): string
|
||||
{
|
||||
return 'buerklin';
|
||||
return new ProviderInfoDTO(
|
||||
key: self::PROVIDER_KEY,
|
||||
name: 'Buerklin',
|
||||
description: 'This provider uses the Buerklin API to search for parts.',
|
||||
url: 'https://www.buerklin.com/',
|
||||
disabledHelp: 'Configure the API Client ID, Secret, Username and Password provided by Buerklin in the provider settings to enable.',
|
||||
settingsClass: BuerklinSettings::class,
|
||||
capabilities: [
|
||||
ProviderCapabilities::BASIC,
|
||||
ProviderCapabilities::PICTURE,
|
||||
//ProviderCapabilities::DATASHEET, // currently not implemented
|
||||
ProviderCapabilities::PRICE,
|
||||
ProviderCapabilities::FOOTPRINT,
|
||||
ProviderCapabilities::PARAMETERS
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// This provider is considered active if settings are present
|
||||
|
|
@ -283,7 +289,7 @@ class BuerklinProvider implements BatchInfoProviderInterface, URLHandlerInfoProv
|
|||
}
|
||||
|
||||
return new PartDetailDTO(
|
||||
provider_key: $this->getProviderKey(),
|
||||
provider_key: self::PROVIDER_KEY,
|
||||
provider_id: (string) ($product['code'] ?? $code),
|
||||
|
||||
name: (string) ($product['manufacturerProductId'] ?? $code),
|
||||
|
|
@ -509,17 +515,6 @@ class BuerklinProvider implements BatchInfoProviderInterface, URLHandlerInfoProv
|
|||
return $this->getPartDetail($response);
|
||||
}
|
||||
|
||||
public function getCapabilities(): array
|
||||
{
|
||||
return [
|
||||
ProviderCapabilities::BASIC,
|
||||
ProviderCapabilities::PICTURE,
|
||||
//ProviderCapabilities::DATASHEET, // currently not implemented
|
||||
ProviderCapabilities::PRICE,
|
||||
ProviderCapabilities::FOOTPRINT,
|
||||
];
|
||||
}
|
||||
|
||||
private function complianceToParameters(array $product, ?string $group = 'Compliance'): array
|
||||
{
|
||||
$params = [];
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ namespace App\Services\InfoProviderSystem\Providers;
|
|||
use App\Services\InfoProviderSystem\DTOs\FileDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PriceDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\ProviderInfoDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\SearchResultDTO;
|
||||
use App\Settings\InfoProviderSystem\BuerklinSettings;
|
||||
|
|
@ -39,6 +40,7 @@ use Symfony\Contracts\HttpClient\HttpClientInterface;
|
|||
*/
|
||||
class CanopyProvider implements InfoProviderInterface
|
||||
{
|
||||
public const PROVIDER_KEY = 'canopy';
|
||||
|
||||
public const BASE_URL = "https://rest.canopyapi.co/api";
|
||||
public const SEARCH_API_URL = self::BASE_URL . "/amazon/search";
|
||||
|
|
@ -52,20 +54,22 @@ class CanopyProvider implements InfoProviderInterface
|
|||
|
||||
}
|
||||
|
||||
public function getProviderInfo(): array
|
||||
public function getProviderInfo(): ProviderInfoDTO
|
||||
{
|
||||
return [
|
||||
'name' => 'Amazon (Canopy)',
|
||||
'description' => 'Retrieves part infos from Amazon using the Canopy API',
|
||||
'url' => 'https://canopyapi.co',
|
||||
'disabled_help' => 'Set Canopy API key in the provider configuration to enable this provider',
|
||||
'settings_class' => CanopySettings::class
|
||||
];
|
||||
}
|
||||
|
||||
public function getProviderKey(): string
|
||||
{
|
||||
return 'canopy';
|
||||
return new ProviderInfoDTO(
|
||||
key: self::PROVIDER_KEY,
|
||||
name: 'Amazon (Canopy)',
|
||||
description: 'Retrieves part infos from Amazon using the Canopy API',
|
||||
url: 'https://canopyapi.co',
|
||||
disabledHelp: 'Set Canopy API key in the provider configuration to enable this provider',
|
||||
settingsClass: CanopySettings::class,
|
||||
capabilities: [
|
||||
ProviderCapabilities::BASIC,
|
||||
ProviderCapabilities::PICTURE,
|
||||
ProviderCapabilities::PRICE,
|
||||
],
|
||||
expensive: true,
|
||||
);
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
|
|
@ -131,7 +135,7 @@ class CanopyProvider implements InfoProviderInterface
|
|||
|
||||
|
||||
$dto = new PartDetailDTO(
|
||||
provider_key: $this->getProviderKey(),
|
||||
provider_key: self::PROVIDER_KEY,
|
||||
provider_id: $result['asin'],
|
||||
name: $result["title"],
|
||||
description: "",
|
||||
|
|
@ -209,7 +213,7 @@ class CanopyProvider implements InfoProviderInterface
|
|||
}
|
||||
|
||||
return new PartDetailDTO(
|
||||
provider_key: $this->getProviderKey(),
|
||||
provider_key: self::PROVIDER_KEY,
|
||||
provider_id: $product['asin'],
|
||||
name: $product['title'],
|
||||
description: '',
|
||||
|
|
@ -222,12 +226,4 @@ class CanopyProvider implements InfoProviderInterface
|
|||
);
|
||||
}
|
||||
|
||||
public function getCapabilities(): array
|
||||
{
|
||||
return [
|
||||
ProviderCapabilities::BASIC,
|
||||
ProviderCapabilities::PICTURE,
|
||||
ProviderCapabilities::PRICE,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ use App\Services\InfoProviderSystem\DTOs\FileDTO;
|
|||
use App\Services\InfoProviderSystem\DTOs\ParameterDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PriceDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\ProviderInfoDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\SearchResultDTO;
|
||||
use App\Settings\InfoProviderSystem\ConradSettings;
|
||||
|
|
@ -38,6 +39,7 @@ readonly class ConradProvider implements InfoProviderInterface, URLHandlerInfoPr
|
|||
|
||||
private const SEARCH_ENDPOINT = '/search/1/v3/facetSearch';
|
||||
public const DISTRIBUTOR_NAME = 'Conrad';
|
||||
public const PROVIDER_KEY = 'conrad';
|
||||
|
||||
private HttpClientInterface $httpClient;
|
||||
|
||||
|
|
@ -51,20 +53,25 @@ readonly class ConradProvider implements InfoProviderInterface, URLHandlerInfoPr
|
|||
]);
|
||||
}
|
||||
|
||||
public function getProviderInfo(): array
|
||||
public function getProviderInfo(): ProviderInfoDTO
|
||||
{
|
||||
return [
|
||||
'name' => 'Conrad',
|
||||
'description' => 'Retrieves part information from conrad.de',
|
||||
'url' => 'https://www.conrad.de/',
|
||||
'disabled_help' => 'Set API key in settings',
|
||||
'settings_class' => ConradSettings::class,
|
||||
];
|
||||
}
|
||||
|
||||
public function getProviderKey(): string
|
||||
{
|
||||
return 'conrad';
|
||||
return new ProviderInfoDTO(
|
||||
key: self::PROVIDER_KEY,
|
||||
name: 'Conrad',
|
||||
description: 'Retrieves part information from conrad.de',
|
||||
url: 'https://www.conrad.de/',
|
||||
disabledHelp: 'Set API key in settings',
|
||||
settingsClass: ConradSettings::class,
|
||||
capabilities: [
|
||||
ProviderCapabilities::BASIC,
|
||||
ProviderCapabilities::PICTURE,
|
||||
ProviderCapabilities::DATASHEET,
|
||||
ProviderCapabilities::PRICE,
|
||||
ProviderCapabilities::FOOTPRINT,
|
||||
ProviderCapabilities::GTIN,
|
||||
ProviderCapabilities::PARAMETERS
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
|
|
@ -111,7 +118,7 @@ readonly class ConradProvider implements InfoProviderInterface, URLHandlerInfoPr
|
|||
foreach($results['hits'] as $result) {
|
||||
|
||||
$out[] = new SearchResultDTO(
|
||||
provider_key: $this->getProviderKey(),
|
||||
provider_key: self::PROVIDER_KEY,
|
||||
provider_id: $result['productId'],
|
||||
name: $result['manufacturerId'] ?? $result['productId'],
|
||||
description: $result['title'] ?? '',
|
||||
|
|
@ -293,7 +300,7 @@ readonly class ConradProvider implements InfoProviderInterface, URLHandlerInfoPr
|
|||
$data = $response->toArray();
|
||||
|
||||
return new PartDetailDTO(
|
||||
provider_key: $this->getProviderKey(),
|
||||
provider_key: self::PROVIDER_KEY,
|
||||
provider_id: $data['shortProductNumber'],
|
||||
name: $data['productFullInformation']['manufacturer']['name'] ?? $data['productFullInformation']['manufacturer']['id'] ?? $data['shortProductNumber'],
|
||||
description: $data['productShortInformation']['title'] ?? '',
|
||||
|
|
@ -311,18 +318,6 @@ readonly class ConradProvider implements InfoProviderInterface, URLHandlerInfoPr
|
|||
);
|
||||
}
|
||||
|
||||
public function getCapabilities(): array
|
||||
{
|
||||
return [
|
||||
ProviderCapabilities::BASIC,
|
||||
ProviderCapabilities::PICTURE,
|
||||
ProviderCapabilities::DATASHEET,
|
||||
ProviderCapabilities::PRICE,
|
||||
ProviderCapabilities::FOOTPRINT,
|
||||
ProviderCapabilities::GTIN,
|
||||
];
|
||||
}
|
||||
|
||||
public function getHandledDomains(): array
|
||||
{
|
||||
$domains = [];
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ use App\Services\InfoProviderSystem\DTOs\FileDTO;
|
|||
use App\Services\InfoProviderSystem\DTOs\ParameterDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PriceDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\ProviderInfoDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\SearchResultDTO;
|
||||
use App\Services\OAuth\OAuthTokenManager;
|
||||
|
|
@ -37,6 +38,7 @@ use Symfony\Contracts\HttpClient\HttpClientInterface;
|
|||
|
||||
class DigikeyProvider implements InfoProviderInterface
|
||||
{
|
||||
public const PROVIDER_KEY = 'digikey';
|
||||
|
||||
private const OAUTH_APP_NAME = 'ip_digikey_oauth';
|
||||
|
||||
|
|
@ -72,32 +74,25 @@ class DigikeyProvider implements InfoProviderInterface
|
|||
]);
|
||||
}
|
||||
|
||||
public function getProviderInfo(): array
|
||||
public function getProviderInfo(): ProviderInfoDTO
|
||||
{
|
||||
return [
|
||||
'name' => 'DigiKey',
|
||||
'description' => 'This provider uses the DigiKey API to search for parts.',
|
||||
'url' => 'https://www.digikey.com/',
|
||||
'oauth_app_name' => self::OAUTH_APP_NAME,
|
||||
'disabled_help' => 'Set the Client ID and Secret in provider settings and connect OAuth to enable.',
|
||||
'settings_class' => DigikeySettings::class,
|
||||
];
|
||||
}
|
||||
|
||||
public function getCapabilities(): array
|
||||
{
|
||||
return [
|
||||
ProviderCapabilities::BASIC,
|
||||
ProviderCapabilities::FOOTPRINT,
|
||||
ProviderCapabilities::PICTURE,
|
||||
ProviderCapabilities::DATASHEET,
|
||||
ProviderCapabilities::PRICE,
|
||||
];
|
||||
}
|
||||
|
||||
public function getProviderKey(): string
|
||||
{
|
||||
return 'digikey';
|
||||
return new ProviderInfoDTO(
|
||||
key: self::PROVIDER_KEY,
|
||||
name: 'DigiKey',
|
||||
description: 'This provider uses the DigiKey API to search for parts.',
|
||||
url: 'https://www.digikey.com/',
|
||||
disabledHelp: 'Set the Client ID and Secret in provider settings and connect OAuth to enable.',
|
||||
oauthAppName: self::OAUTH_APP_NAME,
|
||||
settingsClass: DigikeySettings::class,
|
||||
capabilities: [
|
||||
ProviderCapabilities::BASIC,
|
||||
ProviderCapabilities::FOOTPRINT,
|
||||
ProviderCapabilities::PICTURE,
|
||||
ProviderCapabilities::DATASHEET,
|
||||
ProviderCapabilities::PRICE,
|
||||
ProviderCapabilities::PARAMETERS
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
|
|
@ -128,7 +123,7 @@ class DigikeyProvider implements InfoProviderInterface
|
|||
} catch (\InvalidArgumentException $exception) {
|
||||
//Check if the exception was caused by an invalid or expired token
|
||||
if (str_contains($exception->getMessage(), 'access_token')) {
|
||||
throw OAuthReconnectRequiredException::forProvider($this->getProviderKey());
|
||||
throw OAuthReconnectRequiredException::forProvider(self::PROVIDER_KEY);
|
||||
}
|
||||
|
||||
throw $exception;
|
||||
|
|
@ -141,7 +136,7 @@ class DigikeyProvider implements InfoProviderInterface
|
|||
foreach ($products as $product) {
|
||||
foreach ($product['ProductVariations'] as $variation) {
|
||||
$result[] = new SearchResultDTO(
|
||||
provider_key: $this->getProviderKey(),
|
||||
provider_key: self::PROVIDER_KEY,
|
||||
provider_id: $variation['DigiKeyProductNumber'],
|
||||
name: $product['ManufacturerProductNumber'],
|
||||
description: $product['Description']['DetailedDescription'] ?? $product['Description']['ProductDescription'],
|
||||
|
|
@ -168,7 +163,7 @@ class DigikeyProvider implements InfoProviderInterface
|
|||
} catch (\InvalidArgumentException $exception) {
|
||||
//Check if the exception was caused by an invalid or expired token
|
||||
if (str_contains($exception->getMessage(), 'access_token')) {
|
||||
throw OAuthReconnectRequiredException::forProvider($this->getProviderKey());
|
||||
throw OAuthReconnectRequiredException::forProvider(self::PROVIDER_KEY);
|
||||
}
|
||||
|
||||
throw $exception;
|
||||
|
|
@ -191,7 +186,7 @@ class DigikeyProvider implements InfoProviderInterface
|
|||
}
|
||||
|
||||
return new PartDetailDTO(
|
||||
provider_key: $this->getProviderKey(),
|
||||
provider_key: self::PROVIDER_KEY,
|
||||
provider_id: $id,
|
||||
name: $product['ManufacturerProductNumber'],
|
||||
description: $product['Description']['DetailedDescription'] ?? $product['Description']['ProductDescription'],
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ use App\Services\InfoProviderSystem\DTOs\FileDTO;
|
|||
use App\Services\InfoProviderSystem\DTOs\ParameterDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PriceDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\ProviderInfoDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO;
|
||||
use App\Settings\InfoProviderSystem\Element14Settings;
|
||||
use Composer\CaBundle\CaBundle;
|
||||
|
|
@ -35,6 +36,7 @@ use Symfony\Contracts\HttpClient\HttpClientInterface;
|
|||
|
||||
class Element14Provider implements InfoProviderInterface, URLHandlerInfoProviderInterface
|
||||
{
|
||||
public const PROVIDER_KEY = 'element14';
|
||||
|
||||
private const ENDPOINT_URL = 'https://api.element14.com/catalog/products';
|
||||
private const API_VERSION_NUMBER = '1.4';
|
||||
|
|
@ -60,20 +62,23 @@ class Element14Provider implements InfoProviderInterface, URLHandlerInfoProvider
|
|||
]);
|
||||
}
|
||||
|
||||
public function getProviderInfo(): array
|
||||
public function getProviderInfo(): ProviderInfoDTO
|
||||
{
|
||||
return [
|
||||
'name' => 'Farnell element14',
|
||||
'description' => 'This provider uses the Farnell element14 API to search for parts.',
|
||||
'url' => 'https://www.element14.com/',
|
||||
'disabled_help' => 'Configure the API key in the provider settings to enable.',
|
||||
'settings_class' => Element14Settings::class,
|
||||
];
|
||||
}
|
||||
|
||||
public function getProviderKey(): string
|
||||
{
|
||||
return 'element14';
|
||||
return new ProviderInfoDTO(
|
||||
key: self::PROVIDER_KEY,
|
||||
name: 'Farnell element14',
|
||||
description: 'This provider uses the Farnell element14 API to search for parts.',
|
||||
url: 'https://www.element14.com/',
|
||||
disabledHelp: 'Configure the API key in the provider settings to enable.',
|
||||
settingsClass: Element14Settings::class,
|
||||
capabilities: [
|
||||
ProviderCapabilities::BASIC,
|
||||
ProviderCapabilities::PICTURE,
|
||||
ProviderCapabilities::DATASHEET,
|
||||
ProviderCapabilities::PRICE,
|
||||
ProviderCapabilities::PARAMETERS,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
|
|
@ -113,7 +118,7 @@ class Element14Provider implements InfoProviderInterface, URLHandlerInfoProvider
|
|||
|
||||
foreach ($products as $product) {
|
||||
$result[] = new PartDetailDTO(
|
||||
provider_key: $this->getProviderKey(), provider_id: $product['sku'],
|
||||
provider_key: self::PROVIDER_KEY, provider_id: $product['sku'],
|
||||
name: $product['translatedManufacturerPartNumber'],
|
||||
description: $this->displayNameToDescription($product['displayName'], $product['translatedManufacturerPartNumber']),
|
||||
manufacturer: $product['vendorName'] ?? $product['brandName'] ?? null,
|
||||
|
|
@ -301,15 +306,6 @@ class Element14Provider implements InfoProviderInterface, URLHandlerInfoProvider
|
|||
return $tmp[0];
|
||||
}
|
||||
|
||||
public function getCapabilities(): array
|
||||
{
|
||||
return [
|
||||
ProviderCapabilities::BASIC,
|
||||
ProviderCapabilities::PICTURE,
|
||||
ProviderCapabilities::DATASHEET,
|
||||
];
|
||||
}
|
||||
|
||||
public function getHandledDomains(): array
|
||||
{
|
||||
return ['element14.com', 'farnell.com', 'newark.com'];
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ namespace App\Services\InfoProviderSystem\Providers;
|
|||
|
||||
use App\Services\InfoProviderSystem\DTOs\FileDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\ProviderInfoDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\SearchResultDTO;
|
||||
use Symfony\Component\DependencyInjection\Attribute\When;
|
||||
|
||||
|
|
@ -34,19 +35,20 @@ use Symfony\Component\DependencyInjection\Attribute\When;
|
|||
#[When(env: 'test')]
|
||||
class EmptyProvider implements InfoProviderInterface
|
||||
{
|
||||
public function getProviderInfo(): array
|
||||
{
|
||||
return [
|
||||
'name' => 'Empty Provider',
|
||||
'description' => 'This is a test provider',
|
||||
//'url' => 'https://example.com',
|
||||
'disabled_help' => 'This provider is disabled for testing purposes'
|
||||
];
|
||||
}
|
||||
public const PROVIDER_KEY = 'empty';
|
||||
|
||||
public function getProviderKey(): string
|
||||
public function getProviderInfo(): ProviderInfoDTO
|
||||
{
|
||||
return 'empty';
|
||||
return new ProviderInfoDTO(
|
||||
key: self::PROVIDER_KEY,
|
||||
name: 'Empty Provider',
|
||||
description: 'This is a test provider',
|
||||
disabledHelp: 'This provider is disabled for testing purposes',
|
||||
capabilities: [
|
||||
ProviderCapabilities::BASIC,
|
||||
ProviderCapabilities::FOOTPRINT,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
|
|
@ -61,14 +63,6 @@ class EmptyProvider implements InfoProviderInterface
|
|||
];
|
||||
}
|
||||
|
||||
public function getCapabilities(): array
|
||||
{
|
||||
return [
|
||||
ProviderCapabilities::BASIC,
|
||||
ProviderCapabilities::FOOTPRINT,
|
||||
];
|
||||
}
|
||||
|
||||
public function getDetails(string $id, array $options = []): PartDetailDTO
|
||||
{
|
||||
throw new \RuntimeException('No part details available');
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ use App\Services\InfoProviderSystem\CreateFromUrlHelper;
|
|||
use App\Services\InfoProviderSystem\DTOs\ParameterDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PriceDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\ProviderInfoDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\SearchResultDTO;
|
||||
use App\Services\InfoProviderSystem\PartInfoRetriever;
|
||||
|
|
@ -53,6 +54,7 @@ class GenericWebProvider implements InfoProviderInterface
|
|||
use FixAndValidateUrlTrait;
|
||||
|
||||
public const DISTRIBUTOR_NAME = 'Website';
|
||||
public const PROVIDER_KEY = 'generic_web';
|
||||
|
||||
private readonly HttpClientInterface $httpClient;
|
||||
|
||||
|
|
@ -69,20 +71,21 @@ class GenericWebProvider implements InfoProviderInterface
|
|||
);
|
||||
}
|
||||
|
||||
public function getProviderInfo(): array
|
||||
public function getProviderInfo(): ProviderInfoDTO
|
||||
{
|
||||
return [
|
||||
'name' => 'Generic Web URL',
|
||||
'description' => 'Tries to extract a part from a given product webpage URL using common metadata standards like JSON-LD and OpenGraph.',
|
||||
//'url' => 'https://example.com',
|
||||
'disabled_help' => 'Enable in settings to use this provider',
|
||||
'settings_class' => GenericWebProviderSettings::class,
|
||||
];
|
||||
}
|
||||
|
||||
public function getProviderKey(): string
|
||||
{
|
||||
return 'generic_web';
|
||||
return new ProviderInfoDTO(
|
||||
key: self::PROVIDER_KEY,
|
||||
name: 'Generic Web URL',
|
||||
description: 'Tries to extract a part from a given product webpage URL using common metadata standards like JSON-LD and OpenGraph.',
|
||||
disabledHelp: 'Enable in settings to use this provider',
|
||||
settingsClass: GenericWebProviderSettings::class,
|
||||
capabilities: [
|
||||
ProviderCapabilities::BASIC,
|
||||
ProviderCapabilities::PICTURE,
|
||||
ProviderCapabilities::PRICE,
|
||||
ProviderCapabilities::GTIN,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
|
|
@ -227,7 +230,7 @@ class GenericWebProvider implements InfoProviderInterface
|
|||
}
|
||||
|
||||
return new PartDetailDTO(
|
||||
provider_key: $this->getProviderKey(),
|
||||
provider_key: self::PROVIDER_KEY,
|
||||
provider_id: $url,
|
||||
name: $product->name?->toString() ?? $product->alternateName?->toString() ?? $product->mpn?->toString() ?? 'Unknown Name',
|
||||
description: $this->getMetaContent($dom, 'og:description') ?? $this->getMetaContent($dom, 'description') ?? '',
|
||||
|
|
@ -376,7 +379,7 @@ class GenericWebProvider implements InfoProviderInterface
|
|||
)];
|
||||
|
||||
return new PartDetailDTO(
|
||||
provider_key: $this->getProviderKey(),
|
||||
provider_key: self::PROVIDER_KEY,
|
||||
provider_id: $canonicalURL,
|
||||
name: $this->getMetaContent($dom, 'og:title') ?? $pageTitle,
|
||||
description: $this->getMetaContent($dom, 'og:description') ?? $this->getMetaContent($dom, 'description') ?? '',
|
||||
|
|
@ -387,13 +390,4 @@ class GenericWebProvider implements InfoProviderInterface
|
|||
);
|
||||
}
|
||||
|
||||
public function getCapabilities(): array
|
||||
{
|
||||
return [
|
||||
ProviderCapabilities::BASIC,
|
||||
ProviderCapabilities::PICTURE,
|
||||
ProviderCapabilities::PRICE,
|
||||
ProviderCapabilities::GTIN,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ declare(strict_types=1);
|
|||
namespace App\Services\InfoProviderSystem\Providers;
|
||||
|
||||
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\ProviderInfoDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\SearchResultDTO;
|
||||
|
||||
interface InfoProviderInterface
|
||||
|
|
@ -34,26 +35,8 @@ interface InfoProviderInterface
|
|||
|
||||
/**
|
||||
* Get information about this provider
|
||||
*
|
||||
* @return array An associative array with the following keys (? means optional):
|
||||
* - name: The (user friendly) name of the provider (e.g. "Digikey"), will be translated
|
||||
* - description?: A short description of the provider (e.g. "Digikey is a ..."), will be translated
|
||||
* - logo?: The logo of the provider (e.g. "digikey.png")
|
||||
* - url?: The url of the provider (e.g. "https://www.digikey.com")
|
||||
* - disabled_help?: A help text which is shown when the provider is disabled, explaining how to enable it
|
||||
* - oauth_app_name?: The name of the OAuth app which is used for authentication (e.g. "ip_digikey_oauth"). If this is set a connect button will be shown
|
||||
* - settings_class?: The class name of the settings class which contains the settings for this provider (e.g. "App\Settings\InfoProviderSettings\DigikeySettings"). If this is set a link to the settings will be shown
|
||||
*
|
||||
* @phpstan-return array{ name: string, description?: string, logo?: string, url?: string, disabled_help?: string, oauth_app_name?: string, settings_class?: class-string }
|
||||
*/
|
||||
public function getProviderInfo(): array;
|
||||
|
||||
/**
|
||||
* Returns a unique key for this provider, which will be saved into the database
|
||||
* and used to identify the provider
|
||||
* @return string A unique key for this provider (e.g. "digikey")
|
||||
*/
|
||||
public function getProviderKey(): string;
|
||||
public function getProviderInfo(): ProviderInfoDTO;
|
||||
|
||||
/**
|
||||
* Checks if this provider is enabled or not (meaning that it can be used for searching)
|
||||
|
|
@ -76,12 +59,4 @@ interface InfoProviderInterface
|
|||
* @return PartDetailDTO
|
||||
*/
|
||||
public function getDetails(string $id, array $options = []): PartDetailDTO;
|
||||
|
||||
/**
|
||||
* A list of capabilities this provider supports (which kind of data it can provide).
|
||||
* Not every part have to contain all of these data, but the provider should be able to provide them in general.
|
||||
* Currently, this list is purely informational and not used in functional checks.
|
||||
* @return ProviderCapabilities[]
|
||||
*/
|
||||
public function getCapabilities(): array;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ use App\Services\InfoProviderSystem\DTOs\FileDTO;
|
|||
use App\Services\InfoProviderSystem\DTOs\ParameterDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PriceDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\ProviderInfoDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO;
|
||||
use App\Settings\InfoProviderSystem\LCSCSettings;
|
||||
use Symfony\Component\HttpFoundation\Cookie;
|
||||
|
|
@ -39,26 +40,31 @@ class LCSCProvider implements BatchInfoProviderInterface, URLHandlerInfoProvider
|
|||
private const ENDPOINT_URL = 'https://wmsc.lcsc.com/ftps/wm';
|
||||
|
||||
public const DISTRIBUTOR_NAME = 'LCSC';
|
||||
public const PROVIDER_KEY = 'lcsc';
|
||||
|
||||
public function __construct(private readonly HttpClientInterface $lcscClient, private readonly LCSCSettings $settings)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
public function getProviderInfo(): array
|
||||
public function getProviderInfo(): ProviderInfoDTO
|
||||
{
|
||||
return [
|
||||
'name' => 'LCSC',
|
||||
'description' => 'This provider uses the (unofficial) LCSC API to search for parts.',
|
||||
'url' => 'https://www.lcsc.com/',
|
||||
'disabled_help' => 'Enable this provider in the provider settings.',
|
||||
'settings_class' => LCSCSettings::class,
|
||||
];
|
||||
}
|
||||
|
||||
public function getProviderKey(): string
|
||||
{
|
||||
return 'lcsc';
|
||||
return new ProviderInfoDTO(
|
||||
key: self::PROVIDER_KEY,
|
||||
name: 'LCSC',
|
||||
description: 'This provider uses the (unofficial) LCSC API to search for parts.',
|
||||
url: 'https://www.lcsc.com/',
|
||||
disabledHelp: 'Enable this provider in the provider settings.',
|
||||
settingsClass: LCSCSettings::class,
|
||||
capabilities: [
|
||||
ProviderCapabilities::BASIC,
|
||||
ProviderCapabilities::PICTURE,
|
||||
ProviderCapabilities::DATASHEET,
|
||||
ProviderCapabilities::PRICE,
|
||||
ProviderCapabilities::FOOTPRINT,
|
||||
ProviderCapabilities::PARAMETERS
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// This provider is always active
|
||||
|
|
@ -227,7 +233,7 @@ class LCSCProvider implements BatchInfoProviderInterface, URLHandlerInfoProvider
|
|||
}
|
||||
|
||||
return new PartDetailDTO(
|
||||
provider_key: $this->getProviderKey(),
|
||||
provider_key: self::PROVIDER_KEY,
|
||||
provider_id: $product['productCode'],
|
||||
name: $product['productModel'],
|
||||
description: $this->sanitizeField($product['productIntroEn']) ?? '',
|
||||
|
|
@ -455,17 +461,6 @@ class LCSCProvider implements BatchInfoProviderInterface, URLHandlerInfoProvider
|
|||
return $tmp[0];
|
||||
}
|
||||
|
||||
public function getCapabilities(): array
|
||||
{
|
||||
return [
|
||||
ProviderCapabilities::BASIC,
|
||||
ProviderCapabilities::PICTURE,
|
||||
ProviderCapabilities::DATASHEET,
|
||||
ProviderCapabilities::PRICE,
|
||||
ProviderCapabilities::FOOTPRINT,
|
||||
];
|
||||
}
|
||||
|
||||
public function getHandledDomains(): array
|
||||
{
|
||||
return ['lcsc.com'];
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ use App\Entity\Parts\ManufacturingStatus;
|
|||
use App\Services\InfoProviderSystem\DTOs\FileDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PriceDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\ProviderInfoDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO;
|
||||
use App\Settings\InfoProviderSystem\MouserSettings;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
|
@ -48,6 +49,7 @@ class MouserProvider implements InfoProviderInterface
|
|||
private const ENDPOINT_URL = 'https://api.mouser.com/api/v2/search';
|
||||
|
||||
public const DISTRIBUTOR_NAME = 'Mouser';
|
||||
public const PROVIDER_KEY = 'mouser';
|
||||
|
||||
public function __construct(
|
||||
private readonly HttpClientInterface $mouserClient,
|
||||
|
|
@ -55,20 +57,22 @@ class MouserProvider implements InfoProviderInterface
|
|||
) {
|
||||
}
|
||||
|
||||
public function getProviderInfo(): array
|
||||
public function getProviderInfo(): ProviderInfoDTO
|
||||
{
|
||||
return [
|
||||
'name' => 'Mouser',
|
||||
'description' => 'This provider uses the Mouser API to search for parts.',
|
||||
'url' => 'https://www.mouser.com/',
|
||||
'disabled_help' => 'Configure the API key in the provider settings to enable.',
|
||||
'settings_class' => MouserSettings::class
|
||||
];
|
||||
}
|
||||
|
||||
public function getProviderKey(): string
|
||||
{
|
||||
return 'mouser';
|
||||
return new ProviderInfoDTO(
|
||||
key: self::PROVIDER_KEY,
|
||||
name: 'Mouser',
|
||||
description: 'This provider uses the Mouser API to search for parts.',
|
||||
url: 'https://www.mouser.com/',
|
||||
disabledHelp: 'Configure the API key in the provider settings to enable.',
|
||||
settingsClass: MouserSettings::class,
|
||||
capabilities: [
|
||||
ProviderCapabilities::BASIC,
|
||||
ProviderCapabilities::PICTURE,
|
||||
ProviderCapabilities::DATASHEET,
|
||||
ProviderCapabilities::PRICE,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
|
|
@ -207,17 +211,6 @@ class MouserProvider implements InfoProviderInterface
|
|||
return reset($tmp);
|
||||
}
|
||||
|
||||
public function getCapabilities(): array
|
||||
{
|
||||
return [
|
||||
ProviderCapabilities::BASIC,
|
||||
ProviderCapabilities::PICTURE,
|
||||
ProviderCapabilities::DATASHEET,
|
||||
ProviderCapabilities::PRICE,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @param ResponseInterface $response
|
||||
* @return PartDetailDTO[]
|
||||
|
|
@ -251,7 +244,7 @@ class MouserProvider implements InfoProviderInterface
|
|||
|
||||
|
||||
$result[] = new PartDetailDTO(
|
||||
provider_key: $this->getProviderKey(),
|
||||
provider_key: self::PROVIDER_KEY,
|
||||
provider_id: $product['MouserPartNumber'],
|
||||
name: $product['ManufacturerPartNumber'],
|
||||
description: $product['Description'],
|
||||
|
|
|
|||
|
|
@ -88,6 +88,7 @@ use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
|
|||
use App\Services\InfoProviderSystem\DTOs\PriceDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\ParameterDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\ProviderInfoDTO;
|
||||
use App\Settings\InfoProviderSystem\OEMSecretsSettings;
|
||||
use App\Settings\InfoProviderSystem\OEMSecretsSortMode;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
|
@ -96,6 +97,7 @@ use Psr\Cache\CacheItemPoolInterface;
|
|||
|
||||
class OEMSecretsProvider implements InfoProviderInterface
|
||||
{
|
||||
public const PROVIDER_KEY = 'oemsecrets';
|
||||
|
||||
private const ENDPOINT_URL = 'https://oemsecretsapi.com/partsearch';
|
||||
|
||||
|
|
@ -227,37 +229,23 @@ class OEMSecretsProvider implements InfoProviderInterface
|
|||
private array $distributorCountryCodes = [];
|
||||
private array $countryCodeToRegionMap = [];
|
||||
|
||||
/**
|
||||
* Get information about this provider
|
||||
*
|
||||
* @return array An associative array with the following keys (? means optional):
|
||||
* - name: The (user friendly) name of the provider (e.g. "Digikey"), will be translated
|
||||
* - description?: A short description of the provider (e.g. "Digikey is a ..."), will be translated
|
||||
* - logo?: The logo of the provider (e.g. "digikey.png")
|
||||
* - url?: The url of the provider (e.g. "https://www.digikey.com")
|
||||
* - disabled_help?: A help text which is shown when the provider is disabled, explaining how to enable it
|
||||
* - oauth_app_name?: The name of the OAuth app which is used for authentication (e.g. "ip_digikey_oauth"). If this is set a connect button will be shown
|
||||
*
|
||||
* @phpstan-return array{ name: string, description?: string, logo?: string, url?: string, disabled_help?: string, oauth_app_name?: string }
|
||||
*/
|
||||
public function getProviderInfo(): array
|
||||
public function getProviderInfo(): ProviderInfoDTO
|
||||
{
|
||||
return [
|
||||
'name' => 'OEMSecrets',
|
||||
'description' => 'This provider uses the OEMSecrets API to search for parts.',
|
||||
'url' => 'https://www.oemsecrets.com/',
|
||||
'disabled_help' => 'Configure the API key in the provider settings to enable.',
|
||||
'settings_class' => OEMSecretsSettings::class
|
||||
];
|
||||
}
|
||||
/**
|
||||
* Returns a unique key for this provider, which will be saved into the database
|
||||
* and used to identify the provider
|
||||
* @return string A unique key for this provider (e.g. "digikey")
|
||||
*/
|
||||
public function getProviderKey(): string
|
||||
{
|
||||
return 'oemsecrets';
|
||||
return new ProviderInfoDTO(
|
||||
key: self::PROVIDER_KEY,
|
||||
name: 'OEMSecrets',
|
||||
description: 'This provider uses the OEMSecrets API to search for parts.',
|
||||
url: 'https://www.oemsecrets.com/',
|
||||
disabledHelp: 'Configure the API key in the provider settings to enable.',
|
||||
settingsClass: OEMSecretsSettings::class,
|
||||
capabilities: [
|
||||
ProviderCapabilities::BASIC,
|
||||
ProviderCapabilities::PICTURE,
|
||||
ProviderCapabilities::DATASHEET,
|
||||
ProviderCapabilities::PRICE,
|
||||
ProviderCapabilities::PARAMETERS
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -456,17 +444,6 @@ class OEMSecretsProvider implements InfoProviderInterface
|
|||
* Currently, this list is purely informational and not used in functional checks.
|
||||
* @return ProviderCapabilities[]
|
||||
*/
|
||||
public function getCapabilities(): array
|
||||
{
|
||||
return [
|
||||
ProviderCapabilities::BASIC,
|
||||
ProviderCapabilities::PICTURE,
|
||||
ProviderCapabilities::DATASHEET,
|
||||
ProviderCapabilities::PRICE,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Processes a single product and updates arrays for basic information, datasheets, images, parameters,
|
||||
* and purchase information. Aggregates and organizes data received for a specific `part_number` and `manufacturer_id`.
|
||||
|
|
@ -896,7 +873,7 @@ class OEMSecretsProvider implements InfoProviderInterface
|
|||
// If there is no existing basic info array, we create a new one
|
||||
if (is_null($existingBasicInfo)) {
|
||||
return [
|
||||
'provider_key' => $this->getProviderKey(),
|
||||
'provider_key' => self::PROVIDER_KEY,
|
||||
'provider_id' => $provider_id,
|
||||
'name' => $product['part_number'],
|
||||
'description' => $description,
|
||||
|
|
@ -916,7 +893,7 @@ class OEMSecretsProvider implements InfoProviderInterface
|
|||
|
||||
// Update fields only if empty or undefined, with additional check for preview_image_url
|
||||
return [
|
||||
'provider_key' => $existingBasicInfo['provider_key'] ?? $this->getProviderKey(),
|
||||
'provider_key' => $existingBasicInfo['provider_key'] ?? self::PROVIDER_KEY,
|
||||
'provider_id' => $existingBasicInfo['provider_id'] ?? $provider_id,
|
||||
'name' => $existingBasicInfo['name'] ?? $product['part_number'],
|
||||
// Update description if it's null/empty
|
||||
|
|
@ -1270,7 +1247,7 @@ class OEMSecretsProvider implements InfoProviderInterface
|
|||
*/
|
||||
private function generateInquiryUrl(string $partNumber, string $oemInquiry = 'compare/'): string
|
||||
{
|
||||
$baseUrl = rtrim($this->getProviderInfo()['url'], '/') . '/';
|
||||
$baseUrl = rtrim($this->getProviderInfo()->url, '/') . '/';
|
||||
$inquiryPath = trim($oemInquiry, '/') . '/';
|
||||
$encodedPartNumber = urlencode(trim($partNumber));
|
||||
return $baseUrl . $inquiryPath . $encodedPartNumber;
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ use App\Services\InfoProviderSystem\DTOs\FileDTO;
|
|||
use App\Services\InfoProviderSystem\DTOs\ParameterDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PriceDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\ProviderInfoDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO;
|
||||
use App\Services\OAuth\OAuthTokenManager;
|
||||
use App\Settings\InfoProviderSystem\OctopartSettings;
|
||||
|
|
@ -43,6 +44,8 @@ use Symfony\Contracts\HttpClient\HttpClientInterface;
|
|||
*/
|
||||
class OctopartProvider implements InfoProviderInterface
|
||||
{
|
||||
public const PROVIDER_KEY = 'octopart';
|
||||
|
||||
private const OAUTH_APP_NAME = 'ip_octopart_oauth';
|
||||
|
||||
/**
|
||||
|
|
@ -164,20 +167,25 @@ class OctopartProvider implements InfoProviderInterface
|
|||
return $response->toArray(true);
|
||||
}
|
||||
|
||||
public function getProviderInfo(): array
|
||||
public function getProviderInfo(): ProviderInfoDTO
|
||||
{
|
||||
return [
|
||||
'name' => 'Octopart',
|
||||
'description' => 'This provider uses the Nexar/Octopart API to search for parts on Octopart.',
|
||||
'url' => 'https://www.octopart.com/',
|
||||
'disabled_help' => 'Set the Client ID and Secret in provider settings.',
|
||||
'settings_class' => OctopartSettings::class
|
||||
];
|
||||
}
|
||||
|
||||
public function getProviderKey(): string
|
||||
{
|
||||
return 'octopart';
|
||||
return new ProviderInfoDTO(
|
||||
key: self::PROVIDER_KEY,
|
||||
name: 'Octopart',
|
||||
description: 'This provider uses the Nexar/Octopart API to search for parts on Octopart.',
|
||||
url: 'https://www.octopart.com/',
|
||||
disabledHelp: 'Set the Client ID and Secret in provider settings.',
|
||||
settingsClass: OctopartSettings::class,
|
||||
capabilities: [
|
||||
ProviderCapabilities::BASIC,
|
||||
ProviderCapabilities::FOOTPRINT,
|
||||
ProviderCapabilities::PICTURE,
|
||||
ProviderCapabilities::DATASHEET,
|
||||
ProviderCapabilities::PRICE,
|
||||
ProviderCapabilities::PARAMETERS
|
||||
],
|
||||
expensive: true,
|
||||
);
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
|
|
@ -307,7 +315,7 @@ class OctopartProvider implements InfoProviderInterface
|
|||
}
|
||||
|
||||
return new PartDetailDTO(
|
||||
provider_key: $this->getProviderKey(),
|
||||
provider_key: self::PROVIDER_KEY,
|
||||
provider_id: $part['id'],
|
||||
name: $part['mpn'],
|
||||
description: $part['shortDescription'] ?? null,
|
||||
|
|
@ -397,14 +405,4 @@ class OctopartProvider implements InfoProviderInterface
|
|||
return $tmp;
|
||||
}
|
||||
|
||||
public function getCapabilities(): array
|
||||
{
|
||||
return [
|
||||
ProviderCapabilities::BASIC,
|
||||
ProviderCapabilities::FOOTPRINT,
|
||||
ProviderCapabilities::PICTURE,
|
||||
ProviderCapabilities::DATASHEET,
|
||||
ProviderCapabilities::PRICE,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ use App\Services\InfoProviderSystem\DTOs\FileDTO;
|
|||
use App\Services\InfoProviderSystem\DTOs\ParameterDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PriceDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\ProviderInfoDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\SearchResultDTO;
|
||||
use App\Settings\InfoProviderSystem\PollinSettings;
|
||||
|
|
@ -38,6 +39,7 @@ use Symfony\Contracts\HttpClient\HttpClientInterface;
|
|||
|
||||
class PollinProvider implements InfoProviderInterface, URLHandlerInfoProviderInterface
|
||||
{
|
||||
public const PROVIDER_KEY = 'pollin';
|
||||
|
||||
public function __construct(private readonly HttpClientInterface $client,
|
||||
private readonly PollinSettings $settings,
|
||||
|
|
@ -45,20 +47,23 @@ class PollinProvider implements InfoProviderInterface, URLHandlerInfoProviderInt
|
|||
{
|
||||
}
|
||||
|
||||
public function getProviderInfo(): array
|
||||
public function getProviderInfo(): ProviderInfoDTO
|
||||
{
|
||||
return [
|
||||
'name' => 'Pollin',
|
||||
'description' => 'Webscraping from pollin.de to get part information',
|
||||
'url' => 'https://www.pollin.de/',
|
||||
'disabled_help' => 'Enable the provider in provider settings',
|
||||
'settings_class' => PollinSettings::class,
|
||||
];
|
||||
}
|
||||
|
||||
public function getProviderKey(): string
|
||||
{
|
||||
return 'pollin';
|
||||
return new ProviderInfoDTO(
|
||||
key: self::PROVIDER_KEY,
|
||||
name: 'Pollin',
|
||||
description: 'Webscraping from pollin.de to get part information',
|
||||
url: 'https://www.pollin.de/',
|
||||
disabledHelp: 'Enable the provider in provider settings',
|
||||
settingsClass: PollinSettings::class,
|
||||
capabilities: [
|
||||
ProviderCapabilities::BASIC,
|
||||
ProviderCapabilities::PICTURE,
|
||||
ProviderCapabilities::PRICE,
|
||||
ProviderCapabilities::DATASHEET,
|
||||
ProviderCapabilities::PARAMETERS
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
|
|
@ -88,7 +93,7 @@ class PollinProvider implements InfoProviderInterface, URLHandlerInfoProviderInt
|
|||
//Iterate over each div.product-box
|
||||
$dom->filter('div.product-box')->each(function (Crawler $node) use (&$results) {
|
||||
$results[] = new SearchResultDTO(
|
||||
provider_key: $this->getProviderKey(),
|
||||
provider_key: self::PROVIDER_KEY,
|
||||
provider_id: $node->filter('meta[itemprop="productID"]')->attr('content'),
|
||||
name: $node->filter('a.product-name')->text(),
|
||||
description: '',
|
||||
|
|
@ -156,7 +161,7 @@ class PollinProvider implements InfoProviderInterface, URLHandlerInfoProviderInt
|
|||
$purchaseInfo = new PurchaseInfoDTO('Pollin', $orderId, $this->parsePrices($dom), $productPageUrl);
|
||||
|
||||
return new PartDetailDTO(
|
||||
provider_key: $this->getProviderKey(),
|
||||
provider_key: self::PROVIDER_KEY,
|
||||
provider_id: $orderId,
|
||||
name: trim($dom->filter('meta[property="og:title"]')->attr('content')),
|
||||
description: $dom->filter('meta[property="og:description"]')->attr('content'),
|
||||
|
|
@ -244,16 +249,6 @@ class PollinProvider implements InfoProviderInterface, URLHandlerInfoProviderInt
|
|||
];
|
||||
}
|
||||
|
||||
public function getCapabilities(): array
|
||||
{
|
||||
return [
|
||||
ProviderCapabilities::BASIC,
|
||||
ProviderCapabilities::PICTURE,
|
||||
ProviderCapabilities::PRICE,
|
||||
ProviderCapabilities::DATASHEET
|
||||
];
|
||||
}
|
||||
|
||||
public function getHandledDomains(): array
|
||||
{
|
||||
return ['pollin.de'];
|
||||
|
|
|
|||
|
|
@ -26,28 +26,28 @@ namespace App\Services\InfoProviderSystem\Providers;
|
|||
/**
|
||||
* This enum contains all capabilities (which data it can provide) a provider can have.
|
||||
*/
|
||||
enum ProviderCapabilities
|
||||
enum ProviderCapabilities: string
|
||||
{
|
||||
/** Basic information about a part, like the name, description, part number, manufacturer etc */
|
||||
case BASIC;
|
||||
case BASIC = 'BASIC';
|
||||
|
||||
/** Provider can provide a picture for a part */
|
||||
case PICTURE;
|
||||
case PICTURE = 'PICTURE';
|
||||
|
||||
/** Provider can provide datasheets for a part */
|
||||
case DATASHEET;
|
||||
case DATASHEET = 'DATASHEET';
|
||||
|
||||
/** Provider can provide prices for a part */
|
||||
case PRICE;
|
||||
case PRICE = 'PRICE';
|
||||
|
||||
/** Information about the footprint of a part */
|
||||
case FOOTPRINT;
|
||||
case FOOTPRINT = 'FOOTPRINT';
|
||||
|
||||
/** Provider can provide GTIN for a part */
|
||||
case GTIN;
|
||||
case GTIN = 'GTIN';
|
||||
|
||||
/** Provider can provide parameters/specifications for a part */
|
||||
case PARAMETERS;
|
||||
case PARAMETERS = 'PARAMETERS';
|
||||
|
||||
/**
|
||||
* Get the order index for displaying capabilities in a stable order.
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ use App\Services\InfoProviderSystem\DTOs\FileDTO;
|
|||
use App\Services\InfoProviderSystem\DTOs\ParameterDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PriceDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\ProviderInfoDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\SearchResultDTO;
|
||||
use App\Settings\InfoProviderSystem\ReicheltSettings;
|
||||
|
|
@ -38,6 +39,7 @@ class ReicheltProvider implements InfoProviderInterface
|
|||
{
|
||||
|
||||
public const DISTRIBUTOR_NAME = "Reichelt";
|
||||
public const PROVIDER_KEY = 'reichelt';
|
||||
|
||||
private readonly HttpClientInterface $client;
|
||||
|
||||
|
|
@ -48,20 +50,24 @@ class ReicheltProvider implements InfoProviderInterface
|
|||
$this->client = new RandomizeUseragentHttpClient($client);
|
||||
}
|
||||
|
||||
public function getProviderInfo(): array
|
||||
public function getProviderInfo(): ProviderInfoDTO
|
||||
{
|
||||
return [
|
||||
'name' => 'Reichelt',
|
||||
'description' => 'Webscraping from reichelt.com to get part information',
|
||||
'url' => 'https://www.reichelt.com/',
|
||||
'disabled_help' => 'Enable provider in provider settings.',
|
||||
'settings_class' => ReicheltSettings::class,
|
||||
];
|
||||
}
|
||||
|
||||
public function getProviderKey(): string
|
||||
{
|
||||
return 'reichelt';
|
||||
return new ProviderInfoDTO(
|
||||
key: self::PROVIDER_KEY,
|
||||
name: 'Reichelt',
|
||||
description: 'Webscraping from reichelt.com to get part information',
|
||||
url: 'https://www.reichelt.com/',
|
||||
disabledHelp: 'Enable provider in provider settings.',
|
||||
settingsClass: ReicheltSettings::class,
|
||||
capabilities: [
|
||||
ProviderCapabilities::BASIC,
|
||||
ProviderCapabilities::PICTURE,
|
||||
ProviderCapabilities::DATASHEET,
|
||||
ProviderCapabilities::PRICE,
|
||||
ProviderCapabilities::GTIN,
|
||||
ProviderCapabilities::PARAMETERS
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
|
|
@ -93,7 +99,7 @@ class ReicheltProvider implements InfoProviderInterface
|
|||
$pictureURL = $element->filter("div.al_artlogo img")->attr('src');
|
||||
|
||||
$results[] = new SearchResultDTO(
|
||||
provider_key: $this->getProviderKey(),
|
||||
provider_key: self::PROVIDER_KEY,
|
||||
provider_id: $artId,
|
||||
name: $productID,
|
||||
description: $name,
|
||||
|
|
@ -173,7 +179,7 @@ class ReicheltProvider implements InfoProviderInterface
|
|||
|
||||
//Create part object
|
||||
return new PartDetailDTO(
|
||||
provider_key: $this->getProviderKey(),
|
||||
provider_key: self::PROVIDER_KEY,
|
||||
provider_id: $id,
|
||||
name: $json[0]['article_artnr'],
|
||||
description: $json[0]['article_besch'],
|
||||
|
|
@ -282,14 +288,4 @@ class ReicheltProvider implements InfoProviderInterface
|
|||
return 'https://www.reichelt.com/' . strtolower($this->settings->country) . '/' . strtolower($this->settings->language);
|
||||
}
|
||||
|
||||
public function getCapabilities(): array
|
||||
{
|
||||
return [
|
||||
ProviderCapabilities::BASIC,
|
||||
ProviderCapabilities::PICTURE,
|
||||
ProviderCapabilities::DATASHEET,
|
||||
ProviderCapabilities::PRICE,
|
||||
ProviderCapabilities::GTIN,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,12 +28,14 @@ use App\Services\InfoProviderSystem\DTOs\FileDTO;
|
|||
use App\Services\InfoProviderSystem\DTOs\ParameterDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PriceDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\ProviderInfoDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\SearchResultDTO;
|
||||
use App\Settings\InfoProviderSystem\TMESettings;
|
||||
|
||||
class TMEProvider implements InfoProviderInterface, URLHandlerInfoProviderInterface
|
||||
{
|
||||
public const PROVIDER_KEY = 'tme';
|
||||
|
||||
private const VENDOR_NAME = 'TME';
|
||||
|
||||
|
|
@ -48,20 +50,24 @@ class TMEProvider implements InfoProviderInterface, URLHandlerInfoProviderInterf
|
|||
}
|
||||
}
|
||||
|
||||
public function getProviderInfo(): array
|
||||
public function getProviderInfo(): ProviderInfoDTO
|
||||
{
|
||||
return [
|
||||
'name' => 'TME',
|
||||
'description' => 'This provider uses the API of TME (Transfer Multipart).',
|
||||
'url' => 'https://tme.eu/',
|
||||
'disabled_help' => 'Configure the API Token and secret in provider settings to use this provider.',
|
||||
'settings_class' => TMESettings::class
|
||||
];
|
||||
}
|
||||
|
||||
public function getProviderKey(): string
|
||||
{
|
||||
return 'tme';
|
||||
return new ProviderInfoDTO(
|
||||
key: self::PROVIDER_KEY,
|
||||
name: 'TME',
|
||||
description: 'This provider uses the API of TME (Transfer Multipart).',
|
||||
url: 'https://tme.eu/',
|
||||
disabledHelp: 'Configure the API Token and secret in provider settings to use this provider.',
|
||||
settingsClass: TMESettings::class,
|
||||
capabilities: [
|
||||
ProviderCapabilities::BASIC,
|
||||
ProviderCapabilities::FOOTPRINT,
|
||||
ProviderCapabilities::PICTURE,
|
||||
ProviderCapabilities::DATASHEET,
|
||||
ProviderCapabilities::PRICE,
|
||||
ProviderCapabilities::PARAMETERS
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
|
|
@ -83,7 +89,7 @@ class TMEProvider implements InfoProviderInterface, URLHandlerInfoProviderInterf
|
|||
|
||||
foreach($data['ProductList'] as $product) {
|
||||
$result[] = new SearchResultDTO(
|
||||
provider_key: $this->getProviderKey(),
|
||||
provider_key: self::PROVIDER_KEY,
|
||||
provider_id: $product['Symbol'],
|
||||
name: empty($product['OriginalSymbol']) ? $product['Symbol'] : $product['OriginalSymbol'],
|
||||
description: $product['Description'],
|
||||
|
|
@ -119,7 +125,7 @@ class TMEProvider implements InfoProviderInterface, URLHandlerInfoProviderInterf
|
|||
$parameters = $this->getParameters($id, $footprint);
|
||||
|
||||
return new PartDetailDTO(
|
||||
provider_key: $this->getProviderKey(),
|
||||
provider_key: self::PROVIDER_KEY,
|
||||
provider_id: $product['Symbol'],
|
||||
name: empty($product['OriginalSymbol']) ? $product['Symbol'] : $product['OriginalSymbol'],
|
||||
description: $product['Description'],
|
||||
|
|
@ -290,17 +296,6 @@ class TMEProvider implements InfoProviderInterface, URLHandlerInfoProviderInterf
|
|||
return $url;
|
||||
}
|
||||
|
||||
public function getCapabilities(): array
|
||||
{
|
||||
return [
|
||||
ProviderCapabilities::BASIC,
|
||||
ProviderCapabilities::FOOTPRINT,
|
||||
ProviderCapabilities::PICTURE,
|
||||
ProviderCapabilities::DATASHEET,
|
||||
ProviderCapabilities::PRICE,
|
||||
];
|
||||
}
|
||||
|
||||
public function getHandledDomains(): array
|
||||
{
|
||||
return ['tme.eu'];
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ namespace App\Services\InfoProviderSystem\Providers;
|
|||
|
||||
use App\Services\InfoProviderSystem\DTOs\FileDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\ProviderInfoDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\SearchResultDTO;
|
||||
use Symfony\Component\DependencyInjection\Attribute\When;
|
||||
|
||||
|
|
@ -34,20 +35,20 @@ use Symfony\Component\DependencyInjection\Attribute\When;
|
|||
#[When(env: 'test')]
|
||||
class TestProvider implements InfoProviderInterface
|
||||
{
|
||||
public const PROVIDER_KEY = 'test';
|
||||
|
||||
public function getProviderInfo(): array
|
||||
public function getProviderInfo(): ProviderInfoDTO
|
||||
{
|
||||
return [
|
||||
'name' => 'Test Provider',
|
||||
'description' => 'This is a test provider',
|
||||
//'url' => 'https://example.com',
|
||||
'disabled_help' => 'This provider is disabled for testing purposes'
|
||||
];
|
||||
}
|
||||
|
||||
public function getProviderKey(): string
|
||||
{
|
||||
return 'test';
|
||||
return new ProviderInfoDTO(
|
||||
key: self::PROVIDER_KEY,
|
||||
name: 'Test Provider',
|
||||
description: 'This is a test provider',
|
||||
disabledHelp: 'This provider is disabled for testing purposes',
|
||||
capabilities: [
|
||||
ProviderCapabilities::BASIC,
|
||||
ProviderCapabilities::FOOTPRINT,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
|
|
@ -58,24 +59,16 @@ class TestProvider implements InfoProviderInterface
|
|||
public function searchByKeyword(string $keyword, array $options = []): array
|
||||
{
|
||||
return [
|
||||
new SearchResultDTO(provider_key: $this->getProviderKey(), provider_id: 'element1', name: 'Element 1', description: 'fd'),
|
||||
new SearchResultDTO(provider_key: $this->getProviderKey(), provider_id: 'element2', name: 'Element 2', description: 'fd'),
|
||||
new SearchResultDTO(provider_key: $this->getProviderKey(), provider_id: 'element3', name: 'Element 3', description: 'fd'),
|
||||
];
|
||||
}
|
||||
|
||||
public function getCapabilities(): array
|
||||
{
|
||||
return [
|
||||
ProviderCapabilities::BASIC,
|
||||
ProviderCapabilities::FOOTPRINT,
|
||||
new SearchResultDTO(provider_key: self::PROVIDER_KEY, provider_id: 'element1', name: 'Element 1', description: 'fd'),
|
||||
new SearchResultDTO(provider_key: self::PROVIDER_KEY, provider_id: 'element2', name: 'Element 2', description: 'fd'),
|
||||
new SearchResultDTO(provider_key: self::PROVIDER_KEY, provider_id: 'element3', name: 'Element 3', description: 'fd'),
|
||||
];
|
||||
}
|
||||
|
||||
public function getDetails(string $id, array $options = []): PartDetailDTO
|
||||
{
|
||||
return new PartDetailDTO(
|
||||
provider_key: $this->getProviderKey(),
|
||||
provider_key: self::PROVIDER_KEY,
|
||||
provider_id: $id,
|
||||
name: 'Test Element',
|
||||
description: 'fd',
|
||||
|
|
|
|||
|
|
@ -37,6 +37,9 @@ class AISettings
|
|||
|
||||
public const TIMEOUT_LIMIT = 600;
|
||||
|
||||
#[EmbeddedSettings]
|
||||
public ?McpSettings $mcp = null;
|
||||
|
||||
#[EmbeddedSettings]
|
||||
public ?OpenRouterSettings $openRouter = null;
|
||||
|
||||
|
|
|
|||
45
src/Settings/AISettings/McpSettings.php
Normal file
45
src/Settings/AISettings/McpSettings.php
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
<?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\Settings\AISettings;
|
||||
|
||||
use App\Settings\SettingsIcon;
|
||||
use Jbtronics\SettingsBundle\Metadata\EnvVarMode;
|
||||
use Jbtronics\SettingsBundle\Settings\Settings;
|
||||
use Jbtronics\SettingsBundle\Settings\SettingsParameter;
|
||||
use Jbtronics\SettingsBundle\Settings\SettingsTrait;
|
||||
use Symfony\Component\Translation\TranslatableMessage as TM;
|
||||
|
||||
#[Settings(label: new TM("settings.misc.mcp"))]
|
||||
#[SettingsIcon("fa-robot")]
|
||||
class McpSettings
|
||||
{
|
||||
use SettingsTrait;
|
||||
|
||||
#[SettingsParameter(
|
||||
label: new TM("settings.misc.mcp.enabled"),
|
||||
description: new TM("settings.misc.mcp.enabled.help"),
|
||||
envVar: "bool:MCP_ENABLED",
|
||||
envVarMode: EnvVarMode::OVERWRITE,
|
||||
)]
|
||||
public bool $enabled = false;
|
||||
}
|
||||
51
src/State/Mcp/GetInfoProviderPartDetailsProcessor.php
Normal file
51
src/State/Mcp/GetInfoProviderPartDetailsProcessor.php
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
<?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\Exceptions\InfoProviderNotActiveException;
|
||||
use App\Mcp\DTO\InfoProviderPartDetailsInput;
|
||||
use App\Services\InfoProviderSystem\PartInfoRetriever;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
|
||||
class GetInfoProviderPartDetailsProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PartInfoRetriever $infoRetriever,
|
||||
) {
|
||||
}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = [])
|
||||
{
|
||||
if (!$data instanceof InfoProviderPartDetailsInput) {
|
||||
throw new BadRequestHttpException('Expected InfoProviderPartDetailsInput');
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->infoRetriever->getDetails($data->provider_key, $data->provider_id);
|
||||
} catch (InfoProviderNotActiveException|\InvalidArgumentException $e) {
|
||||
throw new BadRequestHttpException($e->getMessage(), $e);
|
||||
}
|
||||
}
|
||||
}
|
||||
60
src/State/Mcp/GetPartByIdProcessor.php
Normal file
60
src/State/Mcp/GetPartByIdProcessor.php
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
<?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\Parts\Part;
|
||||
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;
|
||||
|
||||
class GetPartByIdProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly AuthorizationCheckerInterface $authorizationChecker,
|
||||
) {
|
||||
}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = [])
|
||||
{
|
||||
if (!$data instanceof ElementByIdInput) {
|
||||
throw new \InvalidArgumentException('Expected PartByIdInput');
|
||||
}
|
||||
|
||||
$part = $this->entityManager->find(Part::class, $data->id);
|
||||
|
||||
if (!$part instanceof Part) {
|
||||
throw new NotFoundHttpException(sprintf('Part with id %d not found', $data->id));
|
||||
}
|
||||
|
||||
if (!$this->authorizationChecker->isGranted('read', $part)) {
|
||||
throw new AccessDeniedException(sprintf('Access denied to part with id %d', $data->id));
|
||||
}
|
||||
|
||||
return $part;
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
71
src/State/Mcp/ListInfoProvidersProcessor.php
Normal file
71
src/State/Mcp/ListInfoProvidersProcessor.php
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
<?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 ApiPlatform\State\ProviderInterface;
|
||||
use App\Services\InfoProviderSystem\DTOs\ProviderInfoDTO;
|
||||
use App\Services\InfoProviderSystem\ProviderRegistry;
|
||||
|
||||
/**
|
||||
* Used both as the state processor for the MCP list_info_providers tool and as the state provider for the
|
||||
* REST GET /api/info_providers collection endpoint.
|
||||
*/
|
||||
class ListInfoProvidersProcessor implements ProcessorInterface, ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ProviderRegistry $providerRegistry,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return ProviderInfoDTO[]
|
||||
*/
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
|
||||
{
|
||||
return $this->listActiveProviders();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return ProviderInfoDTO[]
|
||||
*/
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): array
|
||||
{
|
||||
return $this->listActiveProviders();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return ProviderInfoDTO[]
|
||||
*/
|
||||
private function listActiveProviders(): array
|
||||
{
|
||||
$result = [];
|
||||
|
||||
foreach ($this->providerRegistry->getActiveProviders() as $provider) {
|
||||
$result[] = $provider->getProviderInfo();
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
98
src/State/Mcp/ListStructuralElementsProcessor.php
Normal file
98
src/State/Mcp/ListStructuralElementsProcessor.php
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
<?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 App\Services\Trees\NodesListBuilder;
|
||||
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+full_path overview only; use the corresponding get_X_details tool for full details.
|
||||
*/
|
||||
readonly class ListStructuralElementsProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManagerInterface $entityManager,
|
||||
private NodesListBuilder $nodesListBuilder,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @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));
|
||||
}
|
||||
|
||||
if ($data->keyword === null || $data->keyword === '') {
|
||||
//Without a filter, use the (cached) NodesListBuilder, which already returns the elements in
|
||||
//correct hierarchical tree order (parents immediately followed by their own children), instead
|
||||
//of fetching everything and re-sorting it ourselves.
|
||||
/** @var AbstractStructuralDBElement[] $elements */
|
||||
$elements = $this->nodesListBuilder->typeToNodesList($class);
|
||||
|
||||
return array_map($this->toOverview(...), $elements);
|
||||
}
|
||||
|
||||
$qb = $this->entityManager->getRepository($class)->createQueryBuilder('element');
|
||||
|
||||
//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.'%');
|
||||
|
||||
/** @var AbstractStructuralDBElement[] $elements */
|
||||
$elements = $qb->getQuery()->getResult();
|
||||
|
||||
$overviews = array_map($this->toOverview(...), $elements);
|
||||
|
||||
//Sort by full path (rather than the DB query), so that parents are always immediately followed by
|
||||
//their own children, allowing clients to derive the hierarchical structure from a flat list.
|
||||
usort($overviews, static fn (StructuralElementOverview $a, StructuralElementOverview $b): int => strnatcasecmp($a->full_path, $b->full_path));
|
||||
|
||||
return $overviews;
|
||||
}
|
||||
|
||||
private function toOverview(AbstractStructuralDBElement $element): StructuralElementOverview
|
||||
{
|
||||
return new StructuralElementOverview(
|
||||
id: $element->getID(),
|
||||
name: $element->getName(),
|
||||
full_path: $element->getFullPath(),
|
||||
);
|
||||
}
|
||||
}
|
||||
90
src/State/Mcp/SearchInfoProvidersProcessor.php
Normal file
90
src/State/Mcp/SearchInfoProvidersProcessor.php
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
<?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\Exceptions\InfoProviderNotActiveException;
|
||||
use App\Mcp\DTO\InfoProviderSearchInput;
|
||||
use App\Services\InfoProviderSystem\PartInfoRetriever;
|
||||
use App\Services\InfoProviderSystem\ProviderRegistry;
|
||||
use App\Settings\InfoProviderSystem\InfoProviderGeneralSettings;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
|
||||
class SearchInfoProvidersProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PartInfoRetriever $infoRetriever,
|
||||
private readonly ProviderRegistry $providerRegistry,
|
||||
private readonly InfoProviderGeneralSettings $infoProviderSettings,
|
||||
) {
|
||||
}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = [])
|
||||
{
|
||||
if (!$data instanceof InfoProviderSearchInput) {
|
||||
throw new BadRequestHttpException('Expected InfoProviderSearchInput');
|
||||
}
|
||||
|
||||
$providers = $this->resolveProviderKeys($data->providers);
|
||||
|
||||
try {
|
||||
return $this->infoRetriever->searchByKeyword($data->keyword, $providers);
|
||||
} catch (InfoProviderNotActiveException|\InvalidArgumentException $e) {
|
||||
throw new BadRequestHttpException($e->getMessage(), $e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the provider keys to search in. If none are given, falls back to the active default search
|
||||
* providers configured in the system settings (mirroring the behavior of the info providers search page).
|
||||
* @param string[]|null $providerKeys
|
||||
* @return string[]
|
||||
*/
|
||||
private function resolveProviderKeys(?array $providerKeys): array
|
||||
{
|
||||
if (!empty($providerKeys)) {
|
||||
return $providerKeys;
|
||||
}
|
||||
|
||||
$providers = [];
|
||||
foreach ($this->infoProviderSettings->defaultSearchProviders as $providerKey) {
|
||||
try {
|
||||
if ($this->providerRegistry->getProviderByKey($providerKey)->isActive()) {
|
||||
$providers[] = $providerKey;
|
||||
}
|
||||
} catch (\InvalidArgumentException) {
|
||||
//Ignore providers configured as default, which do not exist (anymore)
|
||||
}
|
||||
}
|
||||
|
||||
if ($providers === []) {
|
||||
throw new BadRequestHttpException(sprintf(
|
||||
'No providers were given and no default search providers are configured or active. Available provider keys: %s',
|
||||
implode(', ', array_keys($this->providerRegistry->getActiveProviders()))
|
||||
));
|
||||
}
|
||||
|
||||
return $providers;
|
||||
}
|
||||
}
|
||||
80
src/State/Mcp/SearchPartsProcessor.php
Normal file
80
src/State/Mcp/SearchPartsProcessor.php
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
<?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\DataTables\Filters\PartSearchFilter;
|
||||
use App\Entity\Parts\Part;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
|
||||
class SearchPartsProcessor implements ProcessorInterface
|
||||
{
|
||||
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
) {
|
||||
|
||||
}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = [])
|
||||
{
|
||||
if (!$data instanceof PartSearchFilter) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$qb = $this->entityManager->getRepository(Part::class)->createQueryBuilder('part');
|
||||
|
||||
$data->apply($qb);
|
||||
$this->addJoins($qb);
|
||||
|
||||
$qb->addGroupBy('part');
|
||||
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
|
||||
private function addJoins(QueryBuilder $qb): void
|
||||
{
|
||||
$dql = $qb->getDQL();
|
||||
|
||||
if (str_contains($dql, '_category')) {
|
||||
$qb->leftJoin('part.category', '_category');
|
||||
}
|
||||
if (str_contains($dql, '_storelocations')) {
|
||||
$qb->leftJoin('part.partLots', '_partLots');
|
||||
$qb->leftJoin('_partLots.storage_location', '_storelocations');
|
||||
}
|
||||
if (str_contains($dql, '_orderdetails') || str_contains($dql, '_suppliers')) {
|
||||
$qb->leftJoin('part.orderdetails', '_orderdetails');
|
||||
$qb->leftJoin('_orderdetails.supplier', '_suppliers');
|
||||
}
|
||||
if (str_contains($dql, '_manufacturer')) {
|
||||
$qb->leftJoin('part.manufacturer', '_manufacturer');
|
||||
}
|
||||
if (str_contains($dql, '_footprint')) {
|
||||
$qb->leftJoin('part.footprint', '_footprint');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -59,7 +59,7 @@ final readonly class InfoProviderExtension
|
|||
public function getInfoProviderName(string $key): ?string
|
||||
{
|
||||
try {
|
||||
return $this->providerRegistry->getProviderByKey($key)->getProviderInfo()['name'];
|
||||
return $this->providerRegistry->getProviderByKey($key)->getProviderInfo()->name;
|
||||
} catch (\InvalidArgumentException) {
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -597,6 +597,9 @@
|
|||
"ref": "fadbfe33303a76e25cb63401050439aa9b1a9c7f"
|
||||
}
|
||||
},
|
||||
"symfony/mcp-bundle": {
|
||||
"version": "v0.8.0"
|
||||
},
|
||||
"symfony/mime": {
|
||||
"version": "v4.3.1"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -8,35 +8,41 @@
|
|||
<div class="row">
|
||||
<div class="col-6">
|
||||
<h5>
|
||||
{% if provider.providerInfo.url is defined and provider.providerInfo.url is not empty %}
|
||||
{% if provider.providerInfo.url is not empty %}
|
||||
<a href="{{ provider.providerInfo.url }}" target="_blank">{{ provider.providerInfo.name }}</a>
|
||||
{% else %}
|
||||
{{ provider.providerInfo.name | trans }}
|
||||
{{ provider.providerInfo.name }}
|
||||
{% endif %}
|
||||
</h5>
|
||||
<div>
|
||||
{% if provider.providerInfo.description is defined and provider.providerInfo.description is not null %}
|
||||
{{ provider.providerInfo.description | trans }}
|
||||
{% if provider.providerInfo.description is not null %}
|
||||
{{ provider.providerInfo.description }}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="col-6">
|
||||
{% if provider.providerInfo.settings_class is defined %}
|
||||
<a href="{{ path('info_providers_provider_settings', {'provider': provider.providerKey}) }}" class="btn btn-primary btn-sm {% if not is_granted('@config.change_system_settings') %}disabled{% endif %}"
|
||||
{% if provider.providerInfo.settingsClass is not null %}
|
||||
<a href="{{ path('info_providers_provider_settings', {'provider': provider.providerInfo.key}) }}" class="btn btn-primary btn-sm {% if not is_granted('@config.change_system_settings') %}disabled{% endif %}"
|
||||
title="{% trans %}info_providers.settings.title{% endtrans %}"
|
||||
><i class="fa-solid fa-cog"></i></a>
|
||||
{% endif %}
|
||||
{% for capability in provider.capabilities|sort((a, b) => a.orderIndex <=> b.orderIndex) %}
|
||||
{% if provider.providerInfo.expensive %}
|
||||
<span class="badge bg-warning" title="{% trans %}info_providers.expensive.tooltip{% endtrans %}">
|
||||
<i class="fa-fw fa-solid fa-euro-sign"></i>
|
||||
{% trans %}info_providers.expensive{% endtrans %}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% for capability in provider.providerInfo.capabilities|sort((a, b) => a.orderIndex <=> b.orderIndex) %}
|
||||
{# @var capability \App\Services\InfoProviderSystem\Providers\ProviderCapabilities #}
|
||||
<span class="badge text-bg-secondary">
|
||||
<i class="{{ capability.fAIconClass }} fa-fw"></i>
|
||||
{{ capability.translationKey|trans }}
|
||||
</span>
|
||||
{% endfor %}
|
||||
{% if provider.providerInfo.oauth_app_name is defined and provider.providerInfo.oauth_app_name is not empty %}
|
||||
{% if provider.providerInfo.oauthAppName is not empty %}
|
||||
<br>
|
||||
<a href="{{ path('oauth_client_connect', {'name': provider.providerInfo.oauth_app_name}) }}" target="_blank" class="btn btn-outline-secondary btn-sm mt-2">{% trans %}oauth_client.connect.btn{% endtrans %}</a>
|
||||
<a href="{{ path('oauth_client_connect', {'name': provider.providerInfo.oauthAppName}) }}" target="_blank" class="btn btn-outline-secondary btn-sm mt-2">{% trans %}oauth_client.connect.btn{% endtrans %}</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -44,9 +50,9 @@
|
|||
<div class="row">
|
||||
<div class="col text-danger">
|
||||
<i class="fa-solid fa-circle-exclamation"></i> <b>{% trans %}info_providers.providers_list.disabled{% endtrans %}</b>
|
||||
{% if provider.providerInfo.disabled_help is defined and provider.providerInfo.disabled_help is not empty %}
|
||||
{% if provider.providerInfo.disabledHelp is not empty %}
|
||||
<br>
|
||||
<span class="text-muted">{{ provider.providerInfo.disabled_help|trans }}</span>
|
||||
<span class="text-muted">{{ provider.providerInfo.disabledHelp }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
{% block card_content %}
|
||||
<div class="{{ offset_label }}">
|
||||
<h3>
|
||||
{% if info_provider_info.url is defined %}
|
||||
{% if info_provider_info.url is not empty %}
|
||||
<a href="{{ info_provider_info.url }}" class="link-external" target="_blank" rel="nofollow">{{ info_provider_info.name }}</a>
|
||||
{% else %}
|
||||
{{ info_provider_info.name }}
|
||||
|
|
|
|||
|
|
@ -94,6 +94,21 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{% if is_granted("@api.use_mcp") %}
|
||||
<div class="row">
|
||||
<div class="col-sm-2">
|
||||
<b>{% trans %}api.api_endpoints.mcp_url{% endtrans %}:</b>
|
||||
</div>
|
||||
<div class="col-sm-10">
|
||||
<button class="btn btn-outline-dark d-print-none btn-sm" data-clipboard-text="{{ absolute_url('/mcp') }}">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
<span>{{ absolute_url('/mcp') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endif %}
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
158
tests/API/Endpoints/InfoProviderEndpointTest.php
Normal file
158
tests/API/Endpoints/InfoProviderEndpointTest.php
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
<?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\Tests\API\Endpoints;
|
||||
|
||||
use App\DataFixtures\APITokenFixtures;
|
||||
use App\Tests\API\AuthenticatedApiTestCase;
|
||||
|
||||
class InfoProviderEndpointTest extends AuthenticatedApiTestCase
|
||||
{
|
||||
public function testListInfoProviders(): void
|
||||
{
|
||||
$response = self::createAuthenticatedClient()->request('GET', '/api/info_providers');
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
$json = $response->toArray();
|
||||
self::assertIsArray($json['hydra:member']);
|
||||
self::assertNotEmpty($json['hydra:member']);
|
||||
|
||||
$keys = array_column($json['hydra:member'], 'key');
|
||||
self::assertContains('test', $keys);
|
||||
|
||||
//The 'test' provider has a disabledHelp set internally, but it must not leak into the API response
|
||||
$testProvider = $json['hydra:member'][array_search('test', $keys, true)];
|
||||
self::assertArrayHasKey('capabilities', $testProvider);
|
||||
self::assertArrayNotHasKey('disabledHelp', $testProvider);
|
||||
self::assertArrayNotHasKey('oauthAppName', $testProvider);
|
||||
self::assertArrayNotHasKey('settingsClass', $testProvider);
|
||||
|
||||
//Capabilities must serialize as plain enum-name strings, not objects
|
||||
self::assertSame(['BASIC', 'FOOTPRINT'], $testProvider['capabilities']);
|
||||
}
|
||||
|
||||
public function testListInfoProvidersRequiresAuthentication(): void
|
||||
{
|
||||
self::createClient()->request('GET', '/api/info_providers');
|
||||
self::assertResponseStatusCodeSame(401);
|
||||
}
|
||||
|
||||
public function testListInfoProvidersRequiresPermission(): void
|
||||
{
|
||||
self::createAuthenticatedClient(APITokenFixtures::TOKEN_READONLY)->request('GET', '/api/info_providers');
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
public function testSearchInfoProviders(): void
|
||||
{
|
||||
$response = self::createAuthenticatedClient()->request('POST', '/api/info_providers/search', [
|
||||
'json' => [
|
||||
'keyword' => 'foo',
|
||||
'providers' => ['test'],
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
$json = $response->toArray();
|
||||
self::assertIsArray($json['hydra:member']);
|
||||
self::assertNotEmpty($json['hydra:member']);
|
||||
self::assertSame('test', $json['hydra:member'][0]['provider_key']);
|
||||
}
|
||||
|
||||
public function testSearchInfoProvidersWithUnknownProviderReturnsBadRequest(): void
|
||||
{
|
||||
self::createAuthenticatedClient()->request('POST', '/api/info_providers/search', [
|
||||
'json' => [
|
||||
'keyword' => 'foo',
|
||||
'providers' => ['unknown'],
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(400);
|
||||
}
|
||||
|
||||
public function testSearchInfoProvidersRequiresAuthentication(): void
|
||||
{
|
||||
self::createClient()->request('POST', '/api/info_providers/search', [
|
||||
'json' => ['keyword' => 'foo', 'providers' => ['test']],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(401);
|
||||
}
|
||||
|
||||
public function testSearchInfoProvidersRequiresPermission(): void
|
||||
{
|
||||
self::createAuthenticatedClient(APITokenFixtures::TOKEN_READONLY)->request('POST', '/api/info_providers/search', [
|
||||
'json' => ['keyword' => 'foo', 'providers' => ['test']],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
public function testGetInfoProviderPartDetails(): void
|
||||
{
|
||||
$response = self::createAuthenticatedClient()->request('POST', '/api/info_providers/details', [
|
||||
'json' => [
|
||||
'provider_key' => 'test',
|
||||
'provider_id' => 'element1',
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
self::assertJsonContains([
|
||||
'provider_key' => 'test',
|
||||
'provider_id' => 'element1',
|
||||
]);
|
||||
}
|
||||
|
||||
public function testGetInfoProviderPartDetailsWithUnknownProviderReturnsBadRequest(): void
|
||||
{
|
||||
self::createAuthenticatedClient()->request('POST', '/api/info_providers/details', [
|
||||
'json' => [
|
||||
'provider_key' => 'unknown',
|
||||
'provider_id' => 'element1',
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(400);
|
||||
}
|
||||
|
||||
public function testGetInfoProviderPartDetailsRequiresAuthentication(): void
|
||||
{
|
||||
self::createClient()->request('POST', '/api/info_providers/details', [
|
||||
'json' => ['provider_key' => 'test', 'provider_id' => 'element1'],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(401);
|
||||
}
|
||||
|
||||
public function testGetInfoProviderPartDetailsRequiresPermission(): void
|
||||
{
|
||||
self::createAuthenticatedClient(APITokenFixtures::TOKEN_READONLY)->request('POST', '/api/info_providers/details', [
|
||||
'json' => ['provider_key' => 'test', 'provider_id' => 'element1'],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
}
|
||||
55
tests/Serializer/StructuralElementOverviewNormalizerTest.php
Normal file
55
tests/Serializer/StructuralElementOverviewNormalizerTest.php
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
<?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', full_path: 'Parent → Test Node');
|
||||
|
||||
//Must be a plain array with exactly id+name+full_path, no JSON-LD "@id"/"@type" clutter
|
||||
$this->assertSame(
|
||||
['id' => 42, 'name' => 'Test Node', 'full_path' => 'Parent → 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', full_path: 'X')));
|
||||
}
|
||||
}
|
||||
|
|
@ -24,6 +24,7 @@ namespace App\Tests\Services\InfoProviderSystem\DTOs;
|
|||
|
||||
use App\Services\InfoProviderSystem\Providers\InfoProviderInterface;
|
||||
use App\Services\InfoProviderSystem\DTOs\BulkSearchFieldMappingDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\ProviderInfoDTO;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class BulkSearchFieldMappingDTOTest extends TestCase
|
||||
|
|
@ -32,7 +33,7 @@ final class BulkSearchFieldMappingDTOTest extends TestCase
|
|||
public function testProviderInstanceNormalization(): void
|
||||
{
|
||||
$mockProvider = $this->createMock(InfoProviderInterface::class);
|
||||
$mockProvider->method('getProviderKey')->willReturn('mock_provider');
|
||||
$mockProvider->method('getProviderInfo')->willReturn(new ProviderInfoDTO(key: 'mock_provider', name: 'mock_provider'));
|
||||
|
||||
$fieldMapping = new BulkSearchFieldMappingDTO(field: 'mpn', providers: ['provider1', $mockProvider], priority: 5);
|
||||
$this->assertSame(['provider1', 'mock_provider'], $fieldMapping->providers);
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ declare(strict_types=1);
|
|||
*/
|
||||
namespace App\Tests\Services\InfoProviderSystem;
|
||||
|
||||
use App\Services\InfoProviderSystem\DTOs\ProviderInfoDTO;
|
||||
use App\Services\InfoProviderSystem\ProviderRegistry;
|
||||
use App\Services\InfoProviderSystem\Providers\InfoProviderInterface;
|
||||
use App\Services\InfoProviderSystem\Providers\URLHandlerInfoProviderInterface;
|
||||
|
|
@ -46,7 +47,7 @@ final class ProviderRegistryTest extends TestCase
|
|||
public function getMockProvider(string $key, bool $active = true): InfoProviderInterface
|
||||
{
|
||||
$mock = $this->createMockForIntersectionOfInterfaces([InfoProviderInterface::class, URLHandlerInfoProviderInterface::class]);
|
||||
$mock->method('getProviderKey')->willReturn($key);
|
||||
$mock->method('getProviderInfo')->willReturn(new ProviderInfoDTO(key: $key, name: $key));
|
||||
$mock->method('isActive')->willReturn($active);
|
||||
$mock->method('getHandledDomains')->willReturn(["$key.com", "test.$key.de"]);
|
||||
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ use App\Services\InfoProviderSystem\DTOs\FileDTO;
|
|||
use App\Services\InfoProviderSystem\DTOs\ParameterDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PriceDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\ProviderInfoDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO;
|
||||
use App\Services\InfoProviderSystem\Providers\LCSCProvider;
|
||||
use App\Services\InfoProviderSystem\Providers\ProviderCapabilities;
|
||||
|
|
@ -56,18 +57,10 @@ final class LCSCProviderTest extends TestCase
|
|||
{
|
||||
$info = $this->provider->getProviderInfo();
|
||||
|
||||
$this->assertIsArray($info);
|
||||
$this->assertArrayHasKey('name', $info);
|
||||
$this->assertArrayHasKey('description', $info);
|
||||
$this->assertArrayHasKey('url', $info);
|
||||
$this->assertArrayHasKey('disabled_help', $info);
|
||||
$this->assertEquals('LCSC', $info['name']);
|
||||
$this->assertEquals('https://www.lcsc.com/', $info['url']);
|
||||
}
|
||||
|
||||
public function testGetProviderKey(): void
|
||||
{
|
||||
$this->assertSame('lcsc', $this->provider->getProviderKey());
|
||||
$this->assertInstanceOf(ProviderInfoDTO::class, $info);
|
||||
$this->assertSame('lcsc', $info->key);
|
||||
$this->assertEquals('LCSC', $info->name);
|
||||
$this->assertEquals('https://www.lcsc.com/', $info->url);
|
||||
}
|
||||
|
||||
public function testIsActiveWhenEnabled(): void
|
||||
|
|
@ -86,7 +79,7 @@ final class LCSCProviderTest extends TestCase
|
|||
|
||||
public function testGetCapabilities(): void
|
||||
{
|
||||
$capabilities = $this->provider->getCapabilities();
|
||||
$capabilities = $this->provider->getProviderInfo()->capabilities;
|
||||
|
||||
$this->assertIsArray($capabilities);
|
||||
$this->assertContains(ProviderCapabilities::BASIC, $capabilities);
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ namespace App\Tests\Services\InfoProviderSystem\Providers;
|
|||
|
||||
use App\Entity\Parts\ManufacturingStatus;
|
||||
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\ProviderInfoDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\SearchResultDTO;
|
||||
use App\Services\InfoProviderSystem\Providers\ProviderCapabilities;
|
||||
|
|
@ -212,17 +213,10 @@ final class TMEProviderTest extends TestCase
|
|||
{
|
||||
$info = $this->provider->getProviderInfo();
|
||||
|
||||
$this->assertIsArray($info);
|
||||
$this->assertArrayHasKey('name', $info);
|
||||
$this->assertArrayHasKey('description', $info);
|
||||
$this->assertArrayHasKey('url', $info);
|
||||
$this->assertEquals('TME', $info['name']);
|
||||
$this->assertEquals('https://tme.eu/', $info['url']);
|
||||
}
|
||||
|
||||
public function testGetProviderKey(): void
|
||||
{
|
||||
$this->assertSame('tme', $this->provider->getProviderKey());
|
||||
$this->assertInstanceOf(ProviderInfoDTO::class, $info);
|
||||
$this->assertSame('tme', $info->key);
|
||||
$this->assertEquals('TME', $info->name);
|
||||
$this->assertEquals('https://tme.eu/', $info->url);
|
||||
}
|
||||
|
||||
public function testIsActiveWithCredentials(): void
|
||||
|
|
@ -239,7 +233,7 @@ final class TMEProviderTest extends TestCase
|
|||
|
||||
public function testGetCapabilities(): void
|
||||
{
|
||||
$capabilities = $this->provider->getCapabilities();
|
||||
$capabilities = $this->provider->getProviderInfo()->capabilities;
|
||||
|
||||
$this->assertIsArray($capabilities);
|
||||
$this->assertContains(ProviderCapabilities::BASIC, $capabilities);
|
||||
|
|
|
|||
100
tests/State/Mcp/GetInfoProviderPartDetailsProcessorTest.php
Normal file
100
tests/State/Mcp/GetInfoProviderPartDetailsProcessorTest.php
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
<?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 App\Mcp\DTO\InfoProviderPartDetailsInput;
|
||||
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\ProviderInfoDTO;
|
||||
use App\Services\InfoProviderSystem\DTOtoEntityConverter;
|
||||
use App\Services\InfoProviderSystem\PartInfoRetriever;
|
||||
use App\Services\InfoProviderSystem\Providers\InfoProviderInterface;
|
||||
use App\Services\InfoProviderSystem\ProviderRegistry;
|
||||
use App\Settings\SystemSettings\LocalizationSettings;
|
||||
use App\State\Mcp\GetInfoProviderPartDetailsProcessor;
|
||||
use App\Tests\SettingsTestHelper;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
|
||||
final class GetInfoProviderPartDetailsProcessorTest extends TestCase
|
||||
{
|
||||
private PartInfoRetriever $infoRetriever;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$activeProvider = $this->createMock(InfoProviderInterface::class);
|
||||
$activeProvider->method('getProviderInfo')->willReturn(new ProviderInfoDTO(key: 'test1', name: 'test1'));
|
||||
$activeProvider->method('isActive')->willReturn(true);
|
||||
$activeProvider->method('getDetails')->willReturn(
|
||||
new PartDetailDTO(provider_key: 'test1', provider_id: '42', name: 'Element 42', description: 'desc')
|
||||
);
|
||||
|
||||
$inactiveProvider = $this->createMock(InfoProviderInterface::class);
|
||||
$inactiveProvider->method('getProviderInfo')->willReturn(new ProviderInfoDTO(key: 'test2', name: 'test2'));
|
||||
$inactiveProvider->method('isActive')->willReturn(false);
|
||||
|
||||
$providerRegistry = new ProviderRegistry([$activeProvider, $inactiveProvider]);
|
||||
|
||||
$dtoToEntityConverter = new DTOtoEntityConverter(
|
||||
$this->createMock(EntityManagerInterface::class),
|
||||
SettingsTestHelper::createSettingsDummy(LocalizationSettings::class)
|
||||
);
|
||||
|
||||
$this->infoRetriever = new PartInfoRetriever(
|
||||
$providerRegistry,
|
||||
$dtoToEntityConverter,
|
||||
new ArrayAdapter(),
|
||||
debugMode: true
|
||||
);
|
||||
}
|
||||
|
||||
public function testGetDetails(): void
|
||||
{
|
||||
$processor = new GetInfoProviderPartDetailsProcessor($this->infoRetriever);
|
||||
|
||||
$result = $processor->process(new InfoProviderPartDetailsInput(provider_key: 'test1', provider_id: '42'), new Get());
|
||||
|
||||
$this->assertInstanceOf(PartDetailDTO::class, $result);
|
||||
$this->assertSame('test1', $result->provider_key);
|
||||
$this->assertSame('42', $result->provider_id);
|
||||
}
|
||||
|
||||
public function testGetDetailsWithUnknownProviderThrowsBadRequest(): void
|
||||
{
|
||||
$processor = new GetInfoProviderPartDetailsProcessor($this->infoRetriever);
|
||||
|
||||
$this->expectException(BadRequestHttpException::class);
|
||||
$processor->process(new InfoProviderPartDetailsInput(provider_key: 'unknown', provider_id: '42'), new Get());
|
||||
}
|
||||
|
||||
public function testGetDetailsWithInactiveProviderThrowsBadRequest(): void
|
||||
{
|
||||
$processor = new GetInfoProviderPartDetailsProcessor($this->infoRetriever);
|
||||
|
||||
$this->expectException(BadRequestHttpException::class);
|
||||
$processor->process(new InfoProviderPartDetailsInput(provider_key: 'test2', provider_id: '42'), new Get());
|
||||
}
|
||||
}
|
||||
66
tests/State/Mcp/ListInfoProvidersProcessorTest.php
Normal file
66
tests/State/Mcp/ListInfoProvidersProcessorTest.php
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
<?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 App\Mcp\DTO\ListInfoProvidersInput;
|
||||
use App\Services\InfoProviderSystem\DTOs\ProviderInfoDTO;
|
||||
use App\Services\InfoProviderSystem\Providers\InfoProviderInterface;
|
||||
use App\Services\InfoProviderSystem\Providers\ProviderCapabilities;
|
||||
use App\Services\InfoProviderSystem\ProviderRegistry;
|
||||
use App\State\Mcp\ListInfoProvidersProcessor;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class ListInfoProvidersProcessorTest extends TestCase
|
||||
{
|
||||
public function testOnlyActiveProvidersAreListed(): void
|
||||
{
|
||||
$active = $this->createMock(InfoProviderInterface::class);
|
||||
$active->method('isActive')->willReturn(true);
|
||||
$active->method('getProviderInfo')->willReturn(new ProviderInfoDTO(
|
||||
key: 'active_provider',
|
||||
name: 'Active Provider',
|
||||
description: 'A provider that is active',
|
||||
url: 'https://example.com',
|
||||
capabilities: [ProviderCapabilities::BASIC, ProviderCapabilities::PRICE],
|
||||
));
|
||||
|
||||
$disabled = $this->createMock(InfoProviderInterface::class);
|
||||
$disabled->method('isActive')->willReturn(false);
|
||||
$disabled->method('getProviderInfo')->willReturn(new ProviderInfoDTO(key: 'disabled_provider', name: 'Disabled Provider'));
|
||||
|
||||
$registry = new ProviderRegistry([$active, $disabled]);
|
||||
$processor = new ListInfoProvidersProcessor($registry);
|
||||
|
||||
$result = $processor->process(new ListInfoProvidersInput(), new Get());
|
||||
|
||||
$this->assertCount(1, $result);
|
||||
$this->assertInstanceOf(ProviderInfoDTO::class, $result[0]);
|
||||
$this->assertSame('active_provider', $result[0]->key);
|
||||
$this->assertSame('Active Provider', $result[0]->name);
|
||||
$this->assertSame('A provider that is active', $result[0]->description);
|
||||
$this->assertSame('https://example.com', $result[0]->url);
|
||||
$this->assertSame([ProviderCapabilities::BASIC, ProviderCapabilities::PRICE], $result[0]->capabilities);
|
||||
}
|
||||
}
|
||||
129
tests/State/Mcp/SearchInfoProvidersProcessorTest.php
Normal file
129
tests/State/Mcp/SearchInfoProvidersProcessorTest.php
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
<?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 App\Mcp\DTO\InfoProviderSearchInput;
|
||||
use App\Services\InfoProviderSystem\DTOs\ProviderInfoDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\SearchResultDTO;
|
||||
use App\Services\InfoProviderSystem\DTOtoEntityConverter;
|
||||
use App\Services\InfoProviderSystem\PartInfoRetriever;
|
||||
use App\Services\InfoProviderSystem\Providers\InfoProviderInterface;
|
||||
use App\Services\InfoProviderSystem\ProviderRegistry;
|
||||
use App\Settings\InfoProviderSystem\InfoProviderGeneralSettings;
|
||||
use App\Settings\SystemSettings\LocalizationSettings;
|
||||
use App\State\Mcp\SearchInfoProvidersProcessor;
|
||||
use App\Tests\SettingsTestHelper;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
|
||||
final class SearchInfoProvidersProcessorTest extends TestCase
|
||||
{
|
||||
private ProviderRegistry $providerRegistry;
|
||||
private PartInfoRetriever $infoRetriever;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$providers = [
|
||||
$this->getMockProvider('test1'),
|
||||
$this->getMockProvider('test2', active: false),
|
||||
];
|
||||
|
||||
$this->providerRegistry = new ProviderRegistry($providers);
|
||||
|
||||
$dtoToEntityConverter = new DTOtoEntityConverter(
|
||||
$this->createMock(EntityManagerInterface::class),
|
||||
SettingsTestHelper::createSettingsDummy(LocalizationSettings::class)
|
||||
);
|
||||
|
||||
$this->infoRetriever = new PartInfoRetriever(
|
||||
$this->providerRegistry,
|
||||
$dtoToEntityConverter,
|
||||
new ArrayAdapter(),
|
||||
debugMode: true
|
||||
);
|
||||
}
|
||||
|
||||
private function getMockProvider(string $key, bool $active = true): InfoProviderInterface
|
||||
{
|
||||
$mock = $this->createMock(InfoProviderInterface::class);
|
||||
$mock->method('isActive')->willReturn($active);
|
||||
$mock->method('getProviderInfo')->willReturn(new ProviderInfoDTO(key: $key, name: $key));
|
||||
$mock->method('searchByKeyword')->willReturn([
|
||||
new SearchResultDTO(provider_key: $key, provider_id: '1', name: 'Element 1', description: 'desc'),
|
||||
]);
|
||||
|
||||
return $mock;
|
||||
}
|
||||
|
||||
public function testSearchWithExplicitProvider(): void
|
||||
{
|
||||
$processor = new SearchInfoProvidersProcessor($this->infoRetriever, $this->providerRegistry, SettingsTestHelper::createSettingsDummy(InfoProviderGeneralSettings::class));
|
||||
|
||||
$result = $processor->process(new InfoProviderSearchInput(keyword: 'foo', providers: ['test1']), new Get());
|
||||
|
||||
$this->assertCount(1, $result);
|
||||
$this->assertInstanceOf(SearchResultDTO::class, $result[0]);
|
||||
$this->assertSame('test1', $result[0]->provider_key);
|
||||
}
|
||||
|
||||
public function testSearchWithUnknownProviderThrowsBadRequest(): void
|
||||
{
|
||||
$processor = new SearchInfoProvidersProcessor($this->infoRetriever, $this->providerRegistry, SettingsTestHelper::createSettingsDummy(InfoProviderGeneralSettings::class));
|
||||
|
||||
$this->expectException(BadRequestHttpException::class);
|
||||
$processor->process(new InfoProviderSearchInput(keyword: 'foo', providers: ['unknown']), new Get());
|
||||
}
|
||||
|
||||
public function testSearchWithInactiveProviderThrowsBadRequest(): void
|
||||
{
|
||||
$processor = new SearchInfoProvidersProcessor($this->infoRetriever, $this->providerRegistry, SettingsTestHelper::createSettingsDummy(InfoProviderGeneralSettings::class));
|
||||
|
||||
$this->expectException(BadRequestHttpException::class);
|
||||
$processor->process(new InfoProviderSearchInput(keyword: 'foo', providers: ['test2']), new Get());
|
||||
}
|
||||
|
||||
public function testSearchFallsBackToConfiguredDefaultProviders(): void
|
||||
{
|
||||
$settings = SettingsTestHelper::createSettingsDummy(InfoProviderGeneralSettings::class);
|
||||
$settings->defaultSearchProviders = ['test1'];
|
||||
|
||||
$processor = new SearchInfoProvidersProcessor($this->infoRetriever, $this->providerRegistry, $settings);
|
||||
|
||||
$result = $processor->process(new InfoProviderSearchInput(keyword: 'foo'), new Get());
|
||||
|
||||
$this->assertCount(1, $result);
|
||||
$this->assertSame('test1', $result[0]->provider_key);
|
||||
}
|
||||
|
||||
public function testSearchWithoutProvidersAndNoDefaultsThrowsBadRequest(): void
|
||||
{
|
||||
$processor = new SearchInfoProvidersProcessor($this->infoRetriever, $this->providerRegistry, SettingsTestHelper::createSettingsDummy(InfoProviderGeneralSettings::class));
|
||||
|
||||
$this->expectException(BadRequestHttpException::class);
|
||||
$processor->process(new InfoProviderSearchInput(keyword: 'foo'), new Get());
|
||||
}
|
||||
}
|
||||
326
tests/State/Mcp/StructuralElementProcessorsTest.php
Normal file
326
tests/State/Mcp/StructuralElementProcessorsTest.php
Normal file
|
|
@ -0,0 +1,326 @@
|
|||
<?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\ProjectSystem\Project;
|
||||
use App\Entity\UserSystem\User;
|
||||
use App\Entity\ProjectSystem\ProjectBOMEntry;
|
||||
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+full_path overview only
|
||||
self::assertIsInt($overview->id);
|
||||
self::assertIsString($overview->name);
|
||||
self::assertIsString($overview->full_path);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
//Node 3 is a root node, so its full path is just its own name
|
||||
self::assertSame('Node 3', $result[0]->full_path);
|
||||
|
||||
$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 testListIncludesFullPathForNestedElements(): void
|
||||
{
|
||||
$result = $this->listProcessor->process(
|
||||
new StructuralElementSearchInput(keyword: 'Node 1.1.1'),
|
||||
$this->getOperationForClass(Category::class)
|
||||
);
|
||||
|
||||
self::assertCount(1, $result);
|
||||
self::assertSame('Node 1.1.1', $result[0]->name);
|
||||
self::assertSame('Node 1 → Node 1.1 → Node 1.1.1', $result[0]->full_path);
|
||||
}
|
||||
|
||||
public function testListIsSortedByFullPathSoParentsPrecedeTheirChildren(): void
|
||||
{
|
||||
$result = $this->listProcessor->process(new StructuralElementSearchInput(), $this->getOperationForClass(Category::class));
|
||||
|
||||
$fullPaths = array_map(static fn (StructuralElementOverview $overview): string => $overview->full_path, $result);
|
||||
|
||||
//The list must be sorted by full_path itself, not e.g. by name
|
||||
$sorted = $fullPaths;
|
||||
sort($sorted, SORT_NATURAL | SORT_FLAG_CASE);
|
||||
self::assertSame($sorted, $fullPaths);
|
||||
|
||||
//Since parent paths are always a prefix of their children's paths, sorting by full_path means
|
||||
//"Node 1" is immediately followed by all of its descendants ("Node 1 → ...") before any sibling
|
||||
//root node that sorts after "Node 1" (e.g. "Node 2") appears.
|
||||
$node1Index = array_search('Node 1', $fullPaths, true);
|
||||
$node11Index = array_search('Node 1 → Node 1.1', $fullPaths, true);
|
||||
$node111Index = array_search('Node 1 → Node 1.1 → Node 1.1.1', $fullPaths, true);
|
||||
$node2Index = array_search('Node 2', $fullPaths, true);
|
||||
|
||||
self::assertNotFalse($node1Index);
|
||||
self::assertNotFalse($node11Index);
|
||||
self::assertNotFalse($node111Index);
|
||||
self::assertNotFalse($node2Index);
|
||||
|
||||
self::assertLessThan($node11Index, $node1Index);
|
||||
self::assertLessThan($node111Index, $node11Index);
|
||||
self::assertLessThan($node2Index, $node111Index);
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
/**
|
||||
* get_project_details is the only get_X_details tool whose entity (Project) has a substantial nested
|
||||
* collection (bom_entries) worth exposing. Since bom_entries -> part is a relation to the (very large)
|
||||
* Part entity, the mcp_project_details:read group deliberately does NOT pull in the "full"/"part:read"
|
||||
* groups on Part, so that each bom entry's part reference stays a lean id+name reference instead of
|
||||
* exploding into the part's whole object graph (category, storage locations, lots, ...).
|
||||
*/
|
||||
public function testGetProjectDetailsSerializesBomEntriesWithLeanPartReference(): void
|
||||
{
|
||||
$project = $this->em->getRepository(Project::class)->findOneBy(['name' => 'Node 1']);
|
||||
self::assertNotNull($project);
|
||||
|
||||
$part = $this->em->getRepository(Part::class)->findOneBy([]);
|
||||
self::assertNotNull($part);
|
||||
|
||||
$bomEntry = (new ProjectBOMEntry())->setPart($part)->setQuantity(3)->setMountnames('R1,R2,R3');
|
||||
$project->addBomEntry($bomEntry);
|
||||
$this->em->persist($bomEntry);
|
||||
$this->em->flush();
|
||||
|
||||
$resourceMetadataCollectionFactory = self::getContainer()->get(ResourceMetadataCollectionFactoryInterface::class);
|
||||
$metadata = $resourceMetadataCollectionFactory->create(Project::class);
|
||||
|
||||
$operation = null;
|
||||
foreach ($metadata as $resource) {
|
||||
foreach ($resource->getMcp() ?? [] as $mcp) {
|
||||
if ($mcp instanceof McpTool && $mcp->getName() === 'get_project_details') {
|
||||
$operation = $mcp;
|
||||
}
|
||||
}
|
||||
}
|
||||
self::assertNotNull($operation);
|
||||
|
||||
$result = $this->getProcessor->process(new ElementByIdInput($project->getID()), $operation);
|
||||
|
||||
$serializer = self::getContainer()->get(SerializerInterface::class);
|
||||
$json = $serializer->serialize($result, 'jsonld', [
|
||||
'groups' => $operation->getNormalizationContext()['groups'] ?? null,
|
||||
'resource_class' => Project::class,
|
||||
'operation' => $operation,
|
||||
]);
|
||||
$decoded = json_decode($json, true, flags: \JSON_THROW_ON_ERROR);
|
||||
|
||||
self::assertArrayHasKey('bom_entries', $decoded);
|
||||
$entry = null;
|
||||
foreach ($decoded['bom_entries'] as $candidate) {
|
||||
if ($candidate['id'] === $bomEntry->getId()) {
|
||||
$entry = $candidate;
|
||||
}
|
||||
}
|
||||
self::assertNotNull($entry, 'Newly added bom entry not found in serialized output');
|
||||
|
||||
self::assertEquals(3.0, $entry['quantity']);
|
||||
self::assertSame('R1,R2,R3', $entry['mountnames']);
|
||||
self::assertArrayHasKey('part', $entry);
|
||||
self::assertSame($part->getID(), $entry['part']['id']);
|
||||
self::assertSame($part->getName(), $entry['part']['name']);
|
||||
//The part reference must stay lean (id+name only) - it must NOT explode into the part's full object graph
|
||||
self::assertArrayNotHasKey('category', $entry['part']);
|
||||
self::assertArrayNotHasKey('partLots', $entry['part']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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', full_path: 'Parent → 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);
|
||||
self::assertSame('Parent → Regression test node', $decoded['hydra:member'][0]['full_path'] ?? null);
|
||||
//The list_X tools are a lean 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'];
|
||||
yield 'projects' => [Project::class, 'list_projects'];
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<xliff xmlns="urn:oasis:names:tc:xliff:document:2.0" version="2.0" srcLang="en" trgLang="de">
|
||||
<file id="messages.en">
|
||||
<file id="messages.de">
|
||||
<unit id="x_wTSQS" name="attachment_type.caption">
|
||||
<segment state="translated">
|
||||
<source>attachment_type.caption</source>
|
||||
|
|
@ -10034,6 +10034,24 @@ Bitte beachten Sie, dass Sie sich nicht als deaktivierter Benutzer ausgeben kön
|
|||
<target>Servereinstellungen</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="ZDiR5ya" name="settings.misc.mcp">
|
||||
<segment state="translated">
|
||||
<source>settings.misc.mcp</source>
|
||||
<target>MCP (Model Context Protocol) Server</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="CdFWDgN" name="settings.misc.mcp.enabled">
|
||||
<segment state="translated">
|
||||
<source>settings.misc.mcp.enabled</source>
|
||||
<target>MCP-Endpunkt aktivieren</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="Gyos.5e" name="settings.misc.mcp.enabled.help">
|
||||
<segment state="translated">
|
||||
<source>settings.misc.mcp.enabled.help</source>
|
||||
<target>Aktiviert den MCP-Endpunkt (Model Context Protocol) unter /mcp, der es KI-Assistenten ermöglicht, mit Part-DB zu interagieren.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="xtw_ol7" name="settings.misc.kicad_eda">
|
||||
<segment state="translated">
|
||||
<source>settings.misc.kicad_eda</source>
|
||||
|
|
|
|||
|
|
@ -10035,6 +10035,24 @@ Please note, that you can not impersonate a disabled user. If you try you will g
|
|||
<target>Server settings</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="ZDiR5ya" name="settings.misc.mcp">
|
||||
<segment state="translated">
|
||||
<source>settings.misc.mcp</source>
|
||||
<target>MCP (Model Context Protocol) Server</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="CdFWDgN" name="settings.misc.mcp.enabled">
|
||||
<segment state="translated">
|
||||
<source>settings.misc.mcp.enabled</source>
|
||||
<target>Enable MCP endpoint</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="Gyos.5e" name="settings.misc.mcp.enabled.help">
|
||||
<segment state="translated">
|
||||
<source>settings.misc.mcp.enabled.help</source>
|
||||
<target>Enable the MCP (Model Context Protocol) endpoint at /mcp, which allows AI assistants to interact with Part-DB.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="xtw_ol7" name="settings.misc.kicad_eda">
|
||||
<segment state="translated">
|
||||
<source>settings.misc.kicad_eda</source>
|
||||
|
|
@ -13775,5 +13793,35 @@ Buerklin-API Authentication server:
|
|||
<target>Warning: Changing values here can break the info retrieval mechanism! You should use the "update from info provider" functionality whenever possible.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="bNrxe9i" name="perm.api.use_mcp">
|
||||
<segment>
|
||||
<source>perm.api.use_mcp</source>
|
||||
<target>Use MCP tools (for AI agents)</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="ced_8yl" name="api.api_endpoints.mcp_url">
|
||||
<segment>
|
||||
<source>api.api_endpoints.mcp_url</source>
|
||||
<target>MCP endpoint</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="a1wK4h_" name="info_providers.capabilities.parameters">
|
||||
<segment>
|
||||
<source>info_providers.capabilities.parameters</source>
|
||||
<target>Parameters</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="QivC_me" name="info_providers.expensive">
|
||||
<segment>
|
||||
<source>info_providers.expensive</source>
|
||||
<target>Expensive</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="Hiw406i" name="info_providers.expensive.tooltip">
|
||||
<segment>
|
||||
<source>info_providers.expensive.tooltip</source>
|
||||
<target>Using this info provider can cause additional costs, or has very strict rate limits.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
</file>
|
||||
</xliff>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue