Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 107 additions & 0 deletions src/Controller/KiCadApiV2Controller.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 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\Controller;

use App\Entity\Parts\Category;
use App\Entity\Parts\Part;
use App\Services\EDA\KiCadHelper;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;

/**
* KiCad HTTP Library API v2 controller.
*
* v1 spec: https://dev-docs.kicad.org/en/apis-and-binding/http-libraries/index.html
* v2 spec (draft): https://gitlab.com/RosyDev/kicad-dev-docs/-/blob/http-lib-v2/content/apis-and-binding/http-libraries/http-lib-v2-00.adoc
*
* Differences from v1:
* - Volatile fields: Stock and Storage Location are marked volatile (shown in KiCad but NOT saved to schematic)
* - Root endpoint returns links to categories and parts endpoints
*/
#[Route('/kicad-api/v2')]
class KiCadApiV2Controller extends AbstractController
{
public function __construct(
private readonly KiCadHelper $kiCADHelper,
) {
}

#[Route('/', name: 'kicad_api_v2_root')]
public function root(): Response
{
$this->denyAccessUnlessGranted('HAS_ACCESS_PERMISSIONS');

return $this->json([
'categories' => $this->generateUrl('kicad_api_v2_categories', [], UrlGeneratorInterface::ABSOLUTE_URL),
'parts' => '',
]);
}

#[Route('/categories.json', name: 'kicad_api_v2_categories')]
public function categories(Request $request): Response
{
$this->denyAccessUnlessGranted('@categories.read');

$data = $this->kiCADHelper->getCategories();
return $this->createCacheableJsonResponse($request, $data, 300);
}

#[Route('/parts/category/{category}.json', name: 'kicad_api_v2_category')]
public function categoryParts(Request $request, ?Category $category): Response
{
if ($category !== null) {
$this->denyAccessUnlessGranted('read', $category);
} else {
$this->denyAccessUnlessGranted('@categories.read');
}
$this->denyAccessUnlessGranted('@parts.read');

$minimal = $request->query->getBoolean('minimal', false);
$data = $this->kiCADHelper->getCategoryParts($category, $minimal);
return $this->createCacheableJsonResponse($request, $data, 300);
}

#[Route('/parts/{part}.json', name: 'kicad_api_v2_part')]
public function partDetails(Request $request, Part $part): Response
{
$this->denyAccessUnlessGranted('read', $part);

// Use API v2 format with volatile fields
$data = $this->kiCADHelper->getKiCADPart($part, 2);
return $this->createCacheableJsonResponse($request, $data, 60);
}

private function createCacheableJsonResponse(Request $request, array $data, int $maxAge): Response
{
$response = new JsonResponse($data);
$response->setEtag(md5(json_encode($data)));
$response->headers->set('Cache-Control', 'private, max-age=' . $maxAge);
$response->isNotModified($request);

return $response;
}
}
25 changes: 20 additions & 5 deletions src/Services/EDA/KiCadHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -193,8 +193,15 @@ function (ItemInterface $item) use ($category) {
});
}

public function getKiCADPart(Part $part): array
/**
* @param int $apiVersion The API version to use (1 or 2). Version 2 adds volatile field support.
*/
public function getKiCADPart(Part $part, int $apiVersion = 1): array
{
if ($apiVersion < 1 || $apiVersion > 2) {
throw new \InvalidArgumentException(sprintf('Unsupported API version %d. Supported versions: 1, 2.', $apiVersion));
}

$result = [
'id' => (string)$part->getId(),
'name' => $part->getName(),
Expand Down Expand Up @@ -328,9 +335,10 @@ public function getKiCADPart(Part $part): array
}
}
}
$result['fields']['Stock'] = $this->createField($totalStock);
// In API v2, stock and location are volatile (shown but not saved to schematic)
$result['fields']['Stock'] = $this->createField($totalStock, false, $apiVersion >= 2);
if ($locations !== []) {
$result['fields']['Storage Location'] = $this->createField(implode(', ', array_unique($locations)));
$result['fields']['Storage Location'] = $this->createField(implode(', ', array_unique($locations)), false, $apiVersion >= 2);
}

//Add parameters marked for EDA export (explicit true, or system default when null)
Expand Down Expand Up @@ -442,14 +450,21 @@ private function boolToKicadBool(bool $value): string
* Creates a field array for KiCAD
* @param string|int|float $value
* @param bool $visible
* @param bool $volatile If true (API v2), field is shown in KiCad but NOT saved to schematic
* @return array
*/
private function createField(string|int|float $value, bool $visible = false): array
private function createField(string|int|float $value, bool $visible = false, bool $volatile = false): array
{
return [
$field = [
'value' => (string)$value,
'visible' => $this->boolToKicadBool($visible),
];

if ($volatile) {
$field['volatile'] = $this->boolToKicadBool(true);
}

return $field;
}

/**
Expand Down
180 changes: 180 additions & 0 deletions tests/Controller/KiCadApiV2ControllerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 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\Controller;

use App\DataFixtures\APITokenFixtures;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

final class KiCadApiV2ControllerTest extends WebTestCase
{
private const BASE_URL = '/en/kicad-api/v2';

protected function createClientWithCredentials(string $token = APITokenFixtures::TOKEN_READONLY): KernelBrowser
{
return static::createClient([], ['headers' => ['authorization' => 'Bearer '.$token]]);
}

public function testRootReturnsEndpointLinks(): void
{
$client = $this->createClientWithCredentials();
$client->request('GET', self::BASE_URL.'/');

self::assertResponseIsSuccessful();
$content = $client->getResponse()->getContent();
self::assertJson($content);

$array = json_decode($content, true);
self::assertArrayHasKey('categories', $array);
self::assertArrayHasKey('parts', $array);

// Root endpoint should return link to categories endpoint
self::assertStringContainsString('categories.json', $array['categories']);
}

public function testCategories(): void
{
$client = $this->createClientWithCredentials();
$client->request('GET', self::BASE_URL.'/categories.json');

self::assertResponseIsSuccessful();
$content = $client->getResponse()->getContent();
self::assertJson($content);

$data = json_decode($content, true);
self::assertCount(1, $data);

$category = $data[0];
self::assertArrayHasKey('name', $category);
self::assertArrayHasKey('id', $category);
}

public function testCategoryParts(): void
{
$client = $this->createClientWithCredentials();
$client->request('GET', self::BASE_URL.'/parts/category/1.json');

self::assertResponseIsSuccessful();
$content = $client->getResponse()->getContent();
self::assertJson($content);

$data = json_decode($content, true);
self::assertCount(3, $data);

$part = $data[0];
self::assertArrayHasKey('name', $part);
self::assertArrayHasKey('id', $part);
self::assertArrayHasKey('description', $part);
}

public function testCategoryPartsMinimal(): void
{
$client = $this->createClientWithCredentials();
$client->request('GET', self::BASE_URL.'/parts/category/1.json?minimal=true');

self::assertResponseIsSuccessful();
$content = $client->getResponse()->getContent();
self::assertJson($content);

$data = json_decode($content, true);
self::assertCount(3, $data);
}

public function testPartDetailsHasVolatileFields(): void
{
$client = $this->createClientWithCredentials();
$client->request('GET', self::BASE_URL.'/parts/1.json');

self::assertResponseIsSuccessful();
$content = $client->getResponse()->getContent();
self::assertJson($content);

$data = json_decode($content, true);

// V2 should have volatile flag on Stock field
self::assertArrayHasKey('fields', $data);
self::assertArrayHasKey('Stock', $data['fields']);
self::assertArrayHasKey('volatile', $data['fields']['Stock']);
self::assertEquals('True', $data['fields']['Stock']['volatile']);
}

public function testPartDetailsV2VsV1Difference(): void
{
$client = $this->createClientWithCredentials();

// Get v1 response
$client->request('GET', '/en/kicad-api/v1/parts/1.json');
self::assertResponseIsSuccessful();
$v1Data = json_decode($client->getResponse()->getContent(), true);

// Get v2 response
$client->request('GET', self::BASE_URL.'/parts/1.json');
self::assertResponseIsSuccessful();
$v2Data = json_decode($client->getResponse()->getContent(), true);

// V1 should NOT have volatile on Stock
self::assertArrayNotHasKey('volatile', $v1Data['fields']['Stock']);

// V2 should have volatile on Stock
self::assertArrayHasKey('volatile', $v2Data['fields']['Stock']);

// Both should have the same stock value
self::assertEquals($v1Data['fields']['Stock']['value'], $v2Data['fields']['Stock']['value']);
}

public function testCategoriesHasCacheHeaders(): void
{
$client = $this->createClientWithCredentials();
$client->request('GET', self::BASE_URL.'/categories.json');

self::assertResponseIsSuccessful();
$response = $client->getResponse();
self::assertNotNull($response->headers->get('ETag'));
self::assertStringContainsString('max-age=', $response->headers->get('Cache-Control'));
}

public function testConditionalRequestReturns304(): void
{
$client = $this->createClientWithCredentials();
$client->request('GET', self::BASE_URL.'/categories.json');

$etag = $client->getResponse()->headers->get('ETag');
self::assertNotNull($etag);

$client->request('GET', self::BASE_URL.'/categories.json', [], [], [
'HTTP_IF_NONE_MATCH' => $etag,
]);

self::assertResponseStatusCodeSame(304);
}

public function testUnauthenticatedAccessDenied(): void
{
$client = static::createClient();
$client->request('GET', self::BASE_URL.'/categories.json');

// Anonymous user has default read permissions in Part-DB,
// so this returns 200 rather than a redirect
self::assertResponseIsSuccessful();
}
}
Loading