Skip to content

Commit 4427ba8

Browse files
committed
Add KiCad HTTP Library API v2 with volatile field support
Implements preliminary support for the KiCad HTTP Library API v2 spec (currently in draft). Key differences from v1: - Volatile fields: Stock and Storage Location are marked volatile (shown in KiCad UI but not saved to schematic files) - Root endpoint returns links to categories endpoint - Uses int $apiVersion parameter for clean version switching 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 5126f7f commit 4427ba8

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
@@ -197,8 +197,15 @@ function (ItemInterface $item) use ($category) {
197197
});
198198
}
199199

200-
public function getKiCADPart(Part $part): array
200+
/**
201+
* @param int $apiVersion The API version to use (1 or 2). Version 2 adds volatile field support.
202+
*/
203+
public function getKiCADPart(Part $part, int $apiVersion = 1): array
201204
{
205+
if ($apiVersion < 1 || $apiVersion > 2) {
206+
throw new \InvalidArgumentException(sprintf('Unsupported API version %d. Supported versions: 1, 2.', $apiVersion));
207+
}
208+
202209
$result = [
203210
'id' => (string)$part->getId(),
204211
'name' => $part->getName(),
@@ -332,9 +339,10 @@ public function getKiCADPart(Part $part): array
332339
}
333340
}
334341
}
335-
$result['fields']['Stock'] = $this->createField($totalStock);
342+
// In API v2, stock and location are volatile (shown but not saved to schematic)
343+
$result['fields']['Stock'] = $this->createField($totalStock, false, $apiVersion >= 2);
336344
if ($locations !== []) {
337-
$result['fields']['Storage Location'] = $this->createField(implode(', ', array_unique($locations)));
345+
$result['fields']['Storage Location'] = $this->createField(implode(', ', array_unique($locations)), false, $apiVersion >= 2);
338346
}
339347

340348
//Add parameters marked for EDA export (explicit true, or system default when null)
@@ -446,14 +454,21 @@ private function boolToKicadBool(bool $value): string
446454
* Creates a field array for KiCAD
447455
* @param string|int|float $value
448456
* @param bool $visible
457+
* @param bool $volatile If true (API v2), field is shown in KiCad but NOT saved to schematic
449458
* @return array
450459
*/
451-
private function createField(string|int|float $value, bool $visible = false): array
460+
private function createField(string|int|float $value, bool $visible = false, bool $volatile = false): array
452461
{
453-
return [
462+
$field = [
454463
'value' => (string)$value,
455464
'visible' => $this->boolToKicadBool($visible),
456465
];
466+
467+
if ($volatile) {
468+
$field['volatile'] = $this->boolToKicadBool(true);
469+
}
470+
471+
return $field;
457472
}
458473

459474
/**
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)