Skip to content
Closed
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
31 changes: 25 additions & 6 deletions src/Controller/KiCadApiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@
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;

Expand Down Expand Up @@ -55,15 +57,16 @@ public function root(): Response
}

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

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

#[Route('/parts/category/{category}.json', name: 'kicad_api_category')]
public function categoryParts(?Category $category): Response
public function categoryParts(Request $request, ?Category $category): Response
{
if ($category !== null) {
$this->denyAccessUnlessGranted('read', $category);
Expand All @@ -72,14 +75,30 @@ public function categoryParts(?Category $category): Response
}
$this->denyAccessUnlessGranted('@parts.read');

return $this->json($this->kiCADHelper->getCategoryParts($category));
$data = $this->kiCADHelper->getCategoryParts($category);
return $this->createCachedJsonResponse($request, $data, 300);
}

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

return $this->json($this->kiCADHelper->getKiCADPart($part));
$data = $this->kiCADHelper->getKiCADPart($part);
return $this->createCachedJsonResponse($request, $data, 60);
}

/**
* Creates a JSON response with HTTP cache headers (ETag and Cache-Control).
* Returns 304 Not Modified if the client's ETag matches.
*/
private function createCachedJsonResponse(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;
}
}
94 changes: 88 additions & 6 deletions src/Services/EDA/KiCadHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@

namespace App\Services\EDA;

use App\Entity\Attachments\Attachment;
use App\Entity\Parts\Category;
use App\Entity\Parts\Footprint;
use App\Entity\Parts\Part;
Expand Down Expand Up @@ -198,14 +199,18 @@ public function getKiCADPart(Part $part): array
$result["fields"]["value"] = $this->createField($part->getEdaInfo()->getValue() ?? $part->getName(), true);
$result["fields"]["keywords"] = $this->createField($part->getTags());

//Use the part info page as datasheet link. It must be an absolute URL.
$result["fields"]["datasheet"] = $this->createField(
$this->urlGenerator->generate(
'part_info',
['id' => $part->getId()],
UrlGeneratorInterface::ABSOLUTE_URL)
//Use the part info page as Part-DB link. It must be an absolute URL.
$partUrl = $this->urlGenerator->generate(
'part_info',
['id' => $part->getId()],
UrlGeneratorInterface::ABSOLUTE_URL
);

//Try to find an actual datasheet attachment (by type name, attachment name, or PDF extension)
$datasheetUrl = $this->findDatasheetUrl($part);
$result["fields"]["datasheet"] = $this->createField($datasheetUrl ?? $partUrl);
$result["fields"]["Part-DB URL"] = $this->createField($partUrl);

//Add basic fields
$result["fields"]["description"] = $this->createField($part->getDescription());
if ($part->getCategory() !== null) {
Expand Down Expand Up @@ -289,6 +294,23 @@ public function getKiCADPart(Part $part): array
}
}

//Add stock quantity and storage locations (only count non-expired lots with known quantity)
$totalStock = 0;
$locations = [];
foreach ($part->getPartLots() as $lot) {
$isAvailable = !$lot->isInstockUnknown() && $lot->isExpired() !== true;
if ($isAvailable) {
$totalStock += $lot->getAmount();
if ($lot->getAmount() > 0 && $lot->getStorageLocation() !== null) {
$locations[] = $lot->getStorageLocation()->getName();
}
}
}
$result['fields']['Stock'] = $this->createField($totalStock);
if ($locations !== []) {
$result['fields']['Storage Location'] = $this->createField(implode(', ', array_unique($locations)));
}

return $result;
}

Expand Down Expand Up @@ -395,4 +417,64 @@ private function createField(string|int|float $value, bool $visible = false): ar
'visible' => $this->boolToKicadBool($visible),
];
}

/**
* Finds the URL to the actual datasheet file for the given part.
* Searches attachments by type name, attachment name, and file extension.
* @return string|null The datasheet URL, or null if no datasheet was found.
*/
private function findDatasheetUrl(Part $part): ?string
{
$firstPdf = null;

foreach ($part->getAttachments() as $attachment) {
//Check if the attachment type name contains "datasheet"
$typeName = $attachment->getAttachmentType()?->getName() ?? '';
if (str_contains(mb_strtolower($typeName), 'datasheet')) {
return $this->getAttachmentUrl($attachment);
}

//Check if the attachment name contains "datasheet"
$name = mb_strtolower($attachment->getName());
if (str_contains($name, 'datasheet') || str_contains($name, 'data sheet')) {
return $this->getAttachmentUrl($attachment);
}

//Track first PDF as fallback (check internal extension or external URL path)
if ($firstPdf === null) {
$extension = $attachment->getExtension();
if ($extension === null && $attachment->hasExternal()) {
$urlPath = parse_url($attachment->getExternalPath(), PHP_URL_PATH);
$extension = is_string($urlPath) ? strtolower(pathinfo($urlPath, PATHINFO_EXTENSION)) : null;
}
if ($extension === 'pdf') {
$firstPdf = $attachment;
}
}
}

//Use first PDF attachment as fallback
if ($firstPdf !== null) {
return $this->getAttachmentUrl($firstPdf);
}

return null;
}

/**
* Returns an absolute URL for viewing the given attachment.
* Prefers the external URL (direct link) over the internal view route.
*/
private function getAttachmentUrl(Attachment $attachment): string
{
if ($attachment->hasExternal()) {
return $attachment->getExternalPath();
}

return $this->urlGenerator->generate(
'attachment_view',
['id' => $attachment->getId()],
UrlGeneratorInterface::ABSOLUTE_URL
);
}
}
97 changes: 84 additions & 13 deletions tests/Controller/KiCadApiControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,11 @@ public function testPartDetailsPart1(): void
'value' => 'http://localhost/en/part/1/info',
'visible' => 'False',
),
'Part-DB URL' =>
array(
'value' => 'http://localhost/en/part/1/info',
'visible' => 'False',
),
'description' =>
array(
'value' => '',
Expand All @@ -168,6 +173,11 @@ public function testPartDetailsPart1(): void
'value' => '1',
'visible' => 'False',
),
'Stock' =>
array(
'value' => '0',
'visible' => 'False',
),
),
);

Expand All @@ -177,48 +187,52 @@ public function testPartDetailsPart1(): void
public function testPartDetailsPart2(): void
{
$client = $this->createClientWithCredentials();
$client->request('GET', self::BASE_URL.'/parts/1.json');
$client->request('GET', self::BASE_URL.'/parts/2.json');

//Response should still be successful, but the result should be empty
self::assertResponseIsSuccessful();
$content = $client->getResponse()->getContent();
self::assertJson($content);

$data = json_decode($content, true);

//For part 2 things info should be taken from the category and footprint
//For part 2, EDA info should be inherited from category and footprint (no part-level overrides)
$expected = array (
'id' => '1',
'name' => 'Part 1',
'symbolIdStr' => 'Part:1',
'id' => '2',
'name' => 'Part 2',
'symbolIdStr' => 'Category:1',
'exclude_from_bom' => 'False',
'exclude_from_board' => 'True',
'exclude_from_sim' => 'False',
'fields' =>
array (
'footprint' =>
array (
'value' => 'Part:1',
'value' => 'Footprint:1',
'visible' => 'False',
),
'reference' =>
array (
'value' => 'P',
'value' => 'C',
'visible' => 'True',
),
'value' =>
array (
'value' => 'Part 1',
'value' => 'Part 2',
'visible' => 'True',
),
'keywords' =>
array (
'value' => '',
'value' => 'test, Test, Part2',
'visible' => 'False',
),
'datasheet' =>
array (
'value' => 'http://localhost/en/part/1/info',
'value' => 'http://localhost/en/part/2/info',
'visible' => 'False',
),
'Part-DB URL' =>
array (
'value' => 'http://localhost/en/part/2/info',
'visible' => 'False',
),
'description' =>
Expand All @@ -231,14 +245,44 @@ public function testPartDetailsPart2(): void
'value' => 'Node 1',
'visible' => 'False',
),
'Manufacturer' =>
array (
'value' => 'Node 1',
'visible' => 'False',
),
'Manufacturing Status' =>
array (
'value' => '',
'value' => 'Active',
'visible' => 'False',
),
'Part-DB Footprint' =>
array (
'value' => 'Node 1',
'visible' => 'False',
),
'Mass' =>
array (
'value' => '100.2 g',
'visible' => 'False',
),
'Part-DB ID' =>
array (
'value' => '1',
'value' => '2',
'visible' => 'False',
),
'Part-DB IPN' =>
array (
'value' => 'IPN123',
'visible' => 'False',
),
'manf' =>
array (
'value' => 'Node 1',
'visible' => 'False',
),
'Stock' =>
array (
'value' => '0',
'visible' => 'False',
),
),
Expand All @@ -247,4 +291,31 @@ public function testPartDetailsPart2(): void
self::assertEquals($expected, $data);
}

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);

//Make a conditional request with the ETag
$client->request('GET', self::BASE_URL.'/categories.json', [], [], [
'HTTP_IF_NONE_MATCH' => $etag,
]);

self::assertResponseStatusCodeSame(304);
}

}
Loading
Loading