Skip to content

Commit dc1754b

Browse files
committed
Add KiCad HTTP Library API v2 with volatile field support
- New KiCadApiV2Controller at /kicad-api/v2/ endpoints - Root endpoint returns links to categories endpoint (per v2 spec) - Volatile fields: Stock and Storage Location are shown in KiCad but NOT saved to schematic (v2 spec feature) - int $apiVersion parameter on KiCadHelper::getKiCADPart() with version validation (supports v1 and v2) - createField() supports $volatile parameter for v2 fields - Full test coverage for v2 controller endpoints 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
1 parent 78b1d41 commit dc1754b

3 files changed

Lines changed: 307 additions & 5 deletions

File tree

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
<?php
2+
/*
3+
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
4+
*
5+
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
6+
*
7+
* This program is free software: you can redistribute it and/or modify
8+
* it under the terms of the GNU Affero General Public License as published
9+
* by the Free Software Foundation, either version 3 of the License, or
10+
* (at your option) any later version.
11+
*
12+
* This program is distributed in the hope that it will be useful,
13+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
14+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15+
* GNU Affero General Public License for more details.
16+
*
17+
* You should have received a copy of the GNU Affero General Public License
18+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
19+
*/
20+
21+
declare(strict_types=1);
22+
23+
namespace App\Controller;
24+
25+
use App\Entity\Parts\Category;
26+
use App\Entity\Parts\Part;
27+
use App\Services\EDA\KiCadHelper;
28+
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
29+
use Symfony\Component\HttpFoundation\JsonResponse;
30+
use Symfony\Component\HttpFoundation\Request;
31+
use Symfony\Component\HttpFoundation\Response;
32+
use Symfony\Component\Routing\Attribute\Route;
33+
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
34+
35+
/**
36+
* KiCad HTTP Library API v2 controller.
37+
*
38+
* v1 spec: https://dev-docs.kicad.org/en/apis-and-binding/http-libraries/index.html
39+
* 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
40+
*
41+
* Differences from v1:
42+
* - Volatile fields: Stock and Storage Location are marked volatile (shown in KiCad but NOT saved to schematic)
43+
* - Root endpoint returns links to categories and parts endpoints
44+
*/
45+
#[Route('/kicad-api/v2')]
46+
class KiCadApiV2Controller extends AbstractController
47+
{
48+
public function __construct(
49+
private readonly KiCadHelper $kiCADHelper,
50+
) {
51+
}
52+
53+
#[Route('/', name: 'kicad_api_v2_root')]
54+
public function root(): Response
55+
{
56+
$this->denyAccessUnlessGranted('HAS_ACCESS_PERMISSIONS');
57+
58+
return $this->json([
59+
'categories' => $this->generateUrl('kicad_api_v2_categories', [], UrlGeneratorInterface::ABSOLUTE_URL),
60+
'parts' => '',
61+
]);
62+
}
63+
64+
#[Route('/categories.json', name: 'kicad_api_v2_categories')]
65+
public function categories(Request $request): Response
66+
{
67+
$this->denyAccessUnlessGranted('@categories.read');
68+
69+
$data = $this->kiCADHelper->getCategories();
70+
return $this->createCacheableJsonResponse($request, $data, 300);
71+
}
72+
73+
#[Route('/parts/category/{category}.json', name: 'kicad_api_v2_category')]
74+
public function categoryParts(Request $request, ?Category $category): Response
75+
{
76+
if ($category !== null) {
77+
$this->denyAccessUnlessGranted('read', $category);
78+
} else {
79+
$this->denyAccessUnlessGranted('@categories.read');
80+
}
81+
$this->denyAccessUnlessGranted('@parts.read');
82+
83+
$minimal = $request->query->getBoolean('minimal', false);
84+
$data = $this->kiCADHelper->getCategoryParts($category, $minimal);
85+
return $this->createCacheableJsonResponse($request, $data, 300);
86+
}
87+
88+
#[Route('/parts/{part}.json', name: 'kicad_api_v2_part')]
89+
public function partDetails(Request $request, Part $part): Response
90+
{
91+
$this->denyAccessUnlessGranted('read', $part);
92+
93+
// Use API v2 format with volatile fields
94+
$data = $this->kiCADHelper->getKiCADPart($part, 2);
95+
return $this->createCacheableJsonResponse($request, $data, 60);
96+
}
97+
98+
private function createCacheableJsonResponse(Request $request, array $data, int $maxAge): Response
99+
{
100+
$response = new JsonResponse($data);
101+
$response->setEtag(md5(json_encode($data)));
102+
$response->headers->set('Cache-Control', 'private, max-age=' . $maxAge);
103+
$response->isNotModified($request);
104+
105+
return $response;
106+
}
107+
}

src/Services/EDA/KiCadHelper.php

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -193,8 +193,15 @@ function (ItemInterface $item) use ($category) {
193193
});
194194
}
195195

196-
public function getKiCADPart(Part $part): array
196+
/**
197+
* @param int $apiVersion The API version to use (1 or 2). Version 2 adds volatile field support.
198+
*/
199+
public function getKiCADPart(Part $part, int $apiVersion = 1): array
197200
{
201+
if ($apiVersion < 1 || $apiVersion > 2) {
202+
throw new \InvalidArgumentException(sprintf('Unsupported API version %d. Supported versions: 1, 2.', $apiVersion));
203+
}
204+
198205
$result = [
199206
'id' => (string)$part->getId(),
200207
'name' => $part->getName(),
@@ -328,9 +335,10 @@ public function getKiCADPart(Part $part): array
328335
}
329336
}
330337
}
331-
$result['fields']['Stock'] = $this->createField($totalStock);
338+
// In API v2, stock and location are volatile (shown but not saved to schematic)
339+
$result['fields']['Stock'] = $this->createField($totalStock, false, $apiVersion >= 2);
332340
if ($locations !== []) {
333-
$result['fields']['Storage Location'] = $this->createField(implode(', ', array_unique($locations)));
341+
$result['fields']['Storage Location'] = $this->createField(implode(', ', array_unique($locations)), false, $apiVersion >= 2);
334342
}
335343

336344
//Add parameters marked for EDA export (explicit true, or system default when null)
@@ -442,14 +450,21 @@ private function boolToKicadBool(bool $value): string
442450
* Creates a field array for KiCAD
443451
* @param string|int|float $value
444452
* @param bool $visible
453+
* @param bool $volatile If true (API v2), field is shown in KiCad but NOT saved to schematic
445454
* @return array
446455
*/
447-
private function createField(string|int|float $value, bool $visible = false): array
456+
private function createField(string|int|float $value, bool $visible = false, bool $volatile = false): array
448457
{
449-
return [
458+
$field = [
450459
'value' => (string)$value,
451460
'visible' => $this->boolToKicadBool($visible),
452461
];
462+
463+
if ($volatile) {
464+
$field['volatile'] = $this->boolToKicadBool(true);
465+
}
466+
467+
return $field;
453468
}
454469

455470
/**
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
<?php
2+
/*
3+
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
4+
*
5+
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
6+
*
7+
* This program is free software: you can redistribute it and/or modify
8+
* it under the terms of the GNU Affero General Public License as published
9+
* by the Free Software Foundation, either version 3 of the License, or
10+
* (at your option) any later version.
11+
*
12+
* This program is distributed in the hope that it will be useful,
13+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
14+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15+
* GNU Affero General Public License for more details.
16+
*
17+
* You should have received a copy of the GNU Affero General Public License
18+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
19+
*/
20+
21+
declare(strict_types=1);
22+
23+
namespace App\Tests\Controller;
24+
25+
use App\DataFixtures\APITokenFixtures;
26+
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
27+
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
28+
29+
final class KiCadApiV2ControllerTest extends WebTestCase
30+
{
31+
private const BASE_URL = '/en/kicad-api/v2';
32+
33+
protected function createClientWithCredentials(string $token = APITokenFixtures::TOKEN_READONLY): KernelBrowser
34+
{
35+
return static::createClient([], ['headers' => ['authorization' => 'Bearer '.$token]]);
36+
}
37+
38+
public function testRootReturnsEndpointLinks(): void
39+
{
40+
$client = $this->createClientWithCredentials();
41+
$client->request('GET', self::BASE_URL.'/');
42+
43+
self::assertResponseIsSuccessful();
44+
$content = $client->getResponse()->getContent();
45+
self::assertJson($content);
46+
47+
$array = json_decode($content, true);
48+
self::assertArrayHasKey('categories', $array);
49+
self::assertArrayHasKey('parts', $array);
50+
51+
// Root endpoint should return link to categories endpoint
52+
self::assertStringContainsString('categories.json', $array['categories']);
53+
}
54+
55+
public function testCategories(): void
56+
{
57+
$client = $this->createClientWithCredentials();
58+
$client->request('GET', self::BASE_URL.'/categories.json');
59+
60+
self::assertResponseIsSuccessful();
61+
$content = $client->getResponse()->getContent();
62+
self::assertJson($content);
63+
64+
$data = json_decode($content, true);
65+
self::assertCount(1, $data);
66+
67+
$category = $data[0];
68+
self::assertArrayHasKey('name', $category);
69+
self::assertArrayHasKey('id', $category);
70+
}
71+
72+
public function testCategoryParts(): void
73+
{
74+
$client = $this->createClientWithCredentials();
75+
$client->request('GET', self::BASE_URL.'/parts/category/1.json');
76+
77+
self::assertResponseIsSuccessful();
78+
$content = $client->getResponse()->getContent();
79+
self::assertJson($content);
80+
81+
$data = json_decode($content, true);
82+
self::assertCount(3, $data);
83+
84+
$part = $data[0];
85+
self::assertArrayHasKey('name', $part);
86+
self::assertArrayHasKey('id', $part);
87+
self::assertArrayHasKey('description', $part);
88+
}
89+
90+
public function testCategoryPartsMinimal(): void
91+
{
92+
$client = $this->createClientWithCredentials();
93+
$client->request('GET', self::BASE_URL.'/parts/category/1.json?minimal=true');
94+
95+
self::assertResponseIsSuccessful();
96+
$content = $client->getResponse()->getContent();
97+
self::assertJson($content);
98+
99+
$data = json_decode($content, true);
100+
self::assertCount(3, $data);
101+
}
102+
103+
public function testPartDetailsHasVolatileFields(): void
104+
{
105+
$client = $this->createClientWithCredentials();
106+
$client->request('GET', self::BASE_URL.'/parts/1.json');
107+
108+
self::assertResponseIsSuccessful();
109+
$content = $client->getResponse()->getContent();
110+
self::assertJson($content);
111+
112+
$data = json_decode($content, true);
113+
114+
// V2 should have volatile flag on Stock field
115+
self::assertArrayHasKey('fields', $data);
116+
self::assertArrayHasKey('Stock', $data['fields']);
117+
self::assertArrayHasKey('volatile', $data['fields']['Stock']);
118+
self::assertEquals('True', $data['fields']['Stock']['volatile']);
119+
}
120+
121+
public function testPartDetailsV2VsV1Difference(): void
122+
{
123+
$client = $this->createClientWithCredentials();
124+
125+
// Get v1 response
126+
$client->request('GET', '/en/kicad-api/v1/parts/1.json');
127+
self::assertResponseIsSuccessful();
128+
$v1Data = json_decode($client->getResponse()->getContent(), true);
129+
130+
// Get v2 response
131+
$client->request('GET', self::BASE_URL.'/parts/1.json');
132+
self::assertResponseIsSuccessful();
133+
$v2Data = json_decode($client->getResponse()->getContent(), true);
134+
135+
// V1 should NOT have volatile on Stock
136+
self::assertArrayNotHasKey('volatile', $v1Data['fields']['Stock']);
137+
138+
// V2 should have volatile on Stock
139+
self::assertArrayHasKey('volatile', $v2Data['fields']['Stock']);
140+
141+
// Both should have the same stock value
142+
self::assertEquals($v1Data['fields']['Stock']['value'], $v2Data['fields']['Stock']['value']);
143+
}
144+
145+
public function testCategoriesHasCacheHeaders(): void
146+
{
147+
$client = $this->createClientWithCredentials();
148+
$client->request('GET', self::BASE_URL.'/categories.json');
149+
150+
self::assertResponseIsSuccessful();
151+
$response = $client->getResponse();
152+
self::assertNotNull($response->headers->get('ETag'));
153+
self::assertStringContainsString('max-age=', $response->headers->get('Cache-Control'));
154+
}
155+
156+
public function testConditionalRequestReturns304(): void
157+
{
158+
$client = $this->createClientWithCredentials();
159+
$client->request('GET', self::BASE_URL.'/categories.json');
160+
161+
$etag = $client->getResponse()->headers->get('ETag');
162+
self::assertNotNull($etag);
163+
164+
$client->request('GET', self::BASE_URL.'/categories.json', [], [], [
165+
'HTTP_IF_NONE_MATCH' => $etag,
166+
]);
167+
168+
self::assertResponseStatusCodeSame(304);
169+
}
170+
171+
public function testUnauthenticatedAccessDenied(): void
172+
{
173+
$client = static::createClient();
174+
$client->request('GET', self::BASE_URL.'/categories.json');
175+
176+
// Anonymous user has default read permissions in Part-DB,
177+
// so this returns 200 rather than a redirect
178+
self::assertResponseIsSuccessful();
179+
}
180+
}

0 commit comments

Comments
 (0)