Update EspoCRM Best Practices to version 2.4 with new features and implementation patterns for Custom API Endpoints; remove outdated TESTERGEBNISSE_JUNCTION_TABLE.md file.
This commit is contained in:
@@ -1,11 +1,31 @@
|
||||
# EspoCRM Best Practices & Entwicklungsrichtlinien
|
||||
|
||||
**Version:** 2.3
|
||||
**Datum:** 11. März 2026
|
||||
**Version:** 2.4
|
||||
**Datum:** 12. März 2026
|
||||
**Zielgruppe:** AI Code Agents & Entwickler
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Letzte Änderungen (v2.4 - 12. März 2026)
|
||||
|
||||
**Neue Features:**
|
||||
- ✅ **Custom API Endpoints mit routes.json**: Vollständiges Pattern für moderne Custom API (EspoCRM 7+)
|
||||
- ✅ **Junction Table Custom API**: Best Practice für direkte SQL-basierte Junction-Zugriffe
|
||||
- ✅ **Real-World Beispiel**: CAIKnowledge Junction API (GET/PUT/POST) mit vollständigem Code
|
||||
- ✅ **Performance-Optimierung**: JOIN-Queries in einem Call statt mehrere API-Requests
|
||||
- ✅ **ACL-Workaround**: Umgehung von ACL-Problemen bei Junction Entities
|
||||
|
||||
**Dokumentierte Patterns:**
|
||||
- routes.json Setup und Configuration
|
||||
- Action Classes mit Constructor Property Promotion
|
||||
- Direkte PDO-Queries für Junction Tables
|
||||
- Dynamische UPDATE-Queries mit variablen Feldern
|
||||
- Error Handling (BadRequest, NotFound, Forbidden)
|
||||
- Snake_case vs CamelCase in DB-Queries
|
||||
- Typische Fehler und Debugging-Strategien
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Letzte Änderungen (v2.3 - 11. März 2026)
|
||||
|
||||
**Neue Features:**
|
||||
@@ -951,6 +971,368 @@ curl -X GET "https://crm.example.com/api/v1/CMyEntity" \
|
||||
|
||||
---
|
||||
|
||||
### Custom API Endpoints mit routes.json (Modern, EspoCRM 7+)
|
||||
|
||||
**Status:** ✅ Empfohlene Methode seit EspoCRM 7.4+ (Controller-Methode deprecated)
|
||||
|
||||
#### Wann Custom API Endpoints verwenden?
|
||||
|
||||
**✅ Verwende Custom API wenn:**
|
||||
- Junction Table additionalColumns lesen/schreiben
|
||||
- JOINs über mehrere Tabellen erforderlich
|
||||
- ACL-Probleme mit Standard Junction-Entity API
|
||||
- Performance-kritische Operationen (viele Datensätze)
|
||||
- Spezielle Business Logic ohne Entity-Hooks
|
||||
- Hooks sollen NICHT ausgelöst werden
|
||||
|
||||
**❌ Verwende Standard API wenn:**
|
||||
- Einfache CRUD-Operationen ohne Junction-Spalten
|
||||
- ACL-System explizit gewünscht
|
||||
- Entity-Hooks sollen ausgelöst werden
|
||||
|
||||
#### Implementierungs-Pattern
|
||||
|
||||
**1. Routes definieren**
|
||||
|
||||
**Datei:** `custom/Espo/Custom/Resources/routes.json`
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"route": "/JunctionData/CAIKnowledge/:knowledgeId/dokumentes",
|
||||
"method": "get",
|
||||
"actionClassName": "Espo\\Custom\\Api\\JunctionData\\GetDokumentes"
|
||||
},
|
||||
{
|
||||
"route": "/JunctionData/CAIKnowledge/:knowledgeId/dokumentes/:documentId",
|
||||
"method": "put",
|
||||
"actionClassName": "Espo\\Custom\\Api\\JunctionData\\UpdateJunction"
|
||||
},
|
||||
{
|
||||
"route": "/JunctionData/CAIKnowledge/:knowledgeId/dokumentes/:documentId",
|
||||
"method": "post",
|
||||
"actionClassName": "Espo\\Custom\\Api\\JunctionData\\LinkDokument"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
**Wichtig:**
|
||||
- **Dateiname:** `routes.json` (NICHT `api.json`!)
|
||||
- **Location:** `custom/Espo/Custom/Resources/routes.json`
|
||||
- **Namespace:** Doppelte Backslashes in `actionClassName`!
|
||||
- **Cache:** Nach Änderungen **IMMER** Clear Cache + Rebuild!
|
||||
|
||||
**2. Action Class implementieren**
|
||||
|
||||
**Datei:** `custom/Espo/Custom/Api/JunctionData/GetDokumentes.php`
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace Espo\Custom\Api\JunctionData;
|
||||
|
||||
use Espo\Core\Api\Action;
|
||||
use Espo\Core\Api\Request;
|
||||
use Espo\Core\Api\Response;
|
||||
use Espo\Core\Api\ResponseComposer;
|
||||
use Espo\Core\Exceptions\BadRequest;
|
||||
use Espo\Core\Exceptions\NotFound;
|
||||
use Espo\ORM\EntityManager;
|
||||
|
||||
/**
|
||||
* GET /api/v1/JunctionData/CAIKnowledge/:knowledgeId/dokumentes
|
||||
*
|
||||
* Retrieves all documents linked to a knowledge entry with junction data.
|
||||
* Uses direct SQL JOIN for optimal performance.
|
||||
*/
|
||||
class GetDokumentes implements Action
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManager $entityManager
|
||||
) {}
|
||||
|
||||
public function process(Request $request): Response
|
||||
{
|
||||
$knowledgeId = $request->getRouteParam('knowledgeId');
|
||||
|
||||
if (!$knowledgeId) {
|
||||
throw new BadRequest('Knowledge ID is required');
|
||||
}
|
||||
|
||||
// Validate entity exists
|
||||
$knowledge = $this->entityManager->getEntityById('CAIKnowledge', $knowledgeId);
|
||||
if (!$knowledge) {
|
||||
throw new NotFound('Knowledge entry not found');
|
||||
}
|
||||
|
||||
$pdo = $this->entityManager->getPDO();
|
||||
|
||||
// Direct SQL with JOIN - much faster than multiple API calls!
|
||||
$sql = "
|
||||
SELECT
|
||||
j.id as junctionId,
|
||||
j.c_a_i_knowledge_id as cAIKnowledgeId,
|
||||
j.c_dokumente_id as cDokumenteId,
|
||||
j.ai_document_id as aiDocumentId,
|
||||
j.syncstatus,
|
||||
j.last_sync as lastSync,
|
||||
d.id as documentId,
|
||||
d.name as documentName,
|
||||
d.blake3hash as blake3hash,
|
||||
d.created_at as documentCreatedAt,
|
||||
d.modified_at as documentModifiedAt
|
||||
FROM c_a_i_knowledge_dokumente j
|
||||
INNER JOIN c_dokumente d ON j.c_dokumente_id = d.id
|
||||
WHERE j.c_a_i_knowledge_id = :knowledgeId
|
||||
AND j.deleted = 0
|
||||
AND d.deleted = 0
|
||||
ORDER BY j.id DESC
|
||||
";
|
||||
|
||||
$sth = $pdo->prepare($sql);
|
||||
$sth->execute(['knowledgeId' => $knowledgeId]);
|
||||
|
||||
$results = $sth->fetchAll(\PDO::FETCH_ASSOC);
|
||||
|
||||
return ResponseComposer::json([
|
||||
'total' => count($results),
|
||||
'list' => $results
|
||||
]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**3. Action Class für UPDATE**
|
||||
|
||||
**Datei:** `custom/Espo/Custom/Api/JunctionData/UpdateJunction.php`
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace Espo\Custom\Api\JunctionData;
|
||||
|
||||
use Espo\Core\Api\Action;
|
||||
use Espo\Core\Api\Request;
|
||||
use Espo\Core\Api\Response;
|
||||
use Espo\Core\Api\ResponseComposer;
|
||||
use Espo\Core\Exceptions\BadRequest;
|
||||
use Espo\Core\Exceptions\NotFound;
|
||||
use Espo\ORM\EntityManager;
|
||||
|
||||
/**
|
||||
* PUT /api/v1/JunctionData/CAIKnowledge/:knowledgeId/dokumentes/:documentId
|
||||
*
|
||||
* Updates junction table columns without triggering entity hooks.
|
||||
*/
|
||||
class UpdateJunction implements Action
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManager $entityManager
|
||||
) {}
|
||||
|
||||
public function process(Request $request): Response
|
||||
{
|
||||
$knowledgeId = $request->getRouteParam('knowledgeId');
|
||||
$documentId = $request->getRouteParam('documentId');
|
||||
$data = $request->getParsedBody();
|
||||
|
||||
if (!$knowledgeId || !$documentId) {
|
||||
throw new BadRequest('Knowledge ID and Document ID required');
|
||||
}
|
||||
|
||||
$pdo = $this->entityManager->getPDO();
|
||||
|
||||
// Dynamic UPDATE - only fields provided in request body
|
||||
$setClauses = [];
|
||||
$params = [
|
||||
'knowledgeId' => $knowledgeId,
|
||||
'documentId' => $documentId
|
||||
];
|
||||
|
||||
if (isset($data->aiDocumentId)) {
|
||||
$setClauses[] = "ai_document_id = :aiDocumentId";
|
||||
$params['aiDocumentId'] = $data->aiDocumentId;
|
||||
}
|
||||
|
||||
if (isset($data->syncstatus)) {
|
||||
$allowedStatuses = ['new', 'unclean', 'synced', 'failed', 'unsupported'];
|
||||
if (!in_array($data->syncstatus, $allowedStatuses)) {
|
||||
throw new BadRequest('Invalid syncstatus. Allowed: ' . implode(', ', $allowedStatuses));
|
||||
}
|
||||
$setClauses[] = "syncstatus = :syncstatus";
|
||||
$params['syncstatus'] = $data->syncstatus;
|
||||
}
|
||||
|
||||
if (isset($data->lastSync)) {
|
||||
$setClauses[] = "last_sync = :lastSync";
|
||||
$params['lastSync'] = $data->lastSync;
|
||||
} elseif (isset($data->updateLastSync) && $data->updateLastSync === true) {
|
||||
$setClauses[] = "last_sync = NOW()";
|
||||
}
|
||||
|
||||
if (empty($setClauses)) {
|
||||
throw new BadRequest('No fields to update');
|
||||
}
|
||||
|
||||
$sql = "
|
||||
UPDATE c_a_i_knowledge_dokumente
|
||||
SET " . implode(', ', $setClauses) . "
|
||||
WHERE c_a_i_knowledge_id = :knowledgeId
|
||||
AND c_dokumente_id = :documentId
|
||||
AND deleted = 0
|
||||
";
|
||||
|
||||
$sth = $pdo->prepare($sql);
|
||||
$sth->execute($params);
|
||||
|
||||
if ($sth->rowCount() === 0) {
|
||||
throw new NotFound('Junction entry not found or no changes made');
|
||||
}
|
||||
|
||||
// Return updated entry
|
||||
return ResponseComposer::json($this->getJunctionEntry($knowledgeId, $documentId));
|
||||
}
|
||||
|
||||
private function getJunctionEntry(string $knowledgeId, string $documentId): array
|
||||
{
|
||||
$pdo = $this->entityManager->getPDO();
|
||||
|
||||
$sql = "
|
||||
SELECT
|
||||
id as junctionId,
|
||||
c_a_i_knowledge_id as cAIKnowledgeId,
|
||||
c_dokumente_id as cDokumenteId,
|
||||
ai_document_id as aiDocumentId,
|
||||
syncstatus,
|
||||
last_sync as lastSync
|
||||
FROM c_a_i_knowledge_dokumente
|
||||
WHERE c_a_i_knowledge_id = :knowledgeId
|
||||
AND c_dokumente_id = :documentId
|
||||
AND deleted = 0
|
||||
";
|
||||
|
||||
$sth = $pdo->prepare($sql);
|
||||
$sth->execute([
|
||||
'knowledgeId' => $knowledgeId,
|
||||
'documentId' => $documentId
|
||||
]);
|
||||
|
||||
$result = $sth->fetch(\PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$result) {
|
||||
throw new NotFound('Junction entry not found');
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Verwendung
|
||||
|
||||
**GET: Alle Dokumente mit Junction-Daten**
|
||||
```bash
|
||||
curl -X GET "http://localhost:8080/api/v1/JunctionData/CAIKnowledge/69b1b03582bb6e2da/dokumentes" \
|
||||
-H "X-Api-Key: e53def10eea27b92a6cd00f40a3e09a4"
|
||||
```
|
||||
|
||||
**PUT: Junction-Spalten aktualisieren**
|
||||
```bash
|
||||
curl -X PUT "http://localhost:8080/api/v1/JunctionData/CAIKnowledge/69b1b03582bb6e2da/dokumentes/69a68b556a39771bf" \
|
||||
-H "X-Api-Key: e53def10eea27b92a6cd00f40a3e09a4" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"aiDocumentId": "EXTERNAL-AI-123",
|
||||
"syncstatus": "synced",
|
||||
"updateLastSync": true
|
||||
}'
|
||||
```
|
||||
|
||||
#### Best Practices
|
||||
|
||||
✅ **DO:**
|
||||
- Constructor Property Promotion (`private EntityManager $entityManager`)
|
||||
- Prepared Statements IMMER (`$pdo->prepare()` + `execute()`)
|
||||
- Validierung BEVOR DB-Operations
|
||||
- Spezifische Exceptions (`BadRequest`, `NotFound`, `Forbidden`)
|
||||
- PHPDoc mit Endpoint-Beschreibung
|
||||
- `snake_case` für DB-Spaltennamen beachten!
|
||||
- Cache IMMER löschen nach routes.json Änderungen
|
||||
|
||||
❌ **DON'T:**
|
||||
- Raw SQL ohne Prepared Statements (SQL Injection!)
|
||||
- Vergessen `deleted = 0` zu prüfen
|
||||
- Camel Case für DB-Spalten annonehmen (DB verwendet snake_case!)
|
||||
- Entity-Methods auf routes.json Änderungen verzichten
|
||||
- ResponseComposer::json() vergessen
|
||||
- Controller-Methode für neue Projekte (deprecated!)
|
||||
|
||||
#### Vorteile gegenüber Standard API
|
||||
|
||||
| Feature | Standard Junction API | Custom routes.json API |
|
||||
|---------|----------------------|------------------------|
|
||||
| JOINs | ❌ Mehrere Calls nötig | ✅ Ein Call mit JOIN |
|
||||
| Performance | ⚠️ Langsam bei vielen Records | ✅ Optimiert mit direktem SQL |
|
||||
| ACL-Probleme | ❌ Oft 403 Forbidden | ✅ Keine ACL-Issues |
|
||||
| Hooks | ✅ Werden ausgelöst | ❌ Werden umgangen |
|
||||
| Flexibilität | ⚠️ Eingeschränkt | ✅ Volle SQL-Kontrolle |
|
||||
| Wartbarkeit | ✅ Standard-Konformität | ⚠️ Custom Code |
|
||||
|
||||
#### Typische Fehler
|
||||
|
||||
**1. routes.json nicht gefunden**
|
||||
```
|
||||
❌ custom/Espo/Custom/Resources/metadata/app/api.json
|
||||
✅ custom/Espo/Custom/Resources/routes.json
|
||||
```
|
||||
|
||||
**2. Cache nicht gelöscht**
|
||||
```bash
|
||||
# PFLICHT nach routes.json Änderungen!
|
||||
docker exec espocrm php clear_cache.php
|
||||
docker exec espocrm php rebuild.php
|
||||
```
|
||||
|
||||
**3. Falsche Spalten-Namen**
|
||||
```php
|
||||
❌ j.lastSync // CamelCase (falsch!)
|
||||
✅ j.last_sync // snake_case (richtig!)
|
||||
```
|
||||
|
||||
**4. File Permissions falsch**
|
||||
```bash
|
||||
# Alle Custom-Dateien müssen www-data:www-data gehören
|
||||
chown -R www-data:www-data custom/Espo/Custom/Api/
|
||||
```
|
||||
|
||||
**5. Namespace-Fehler**
|
||||
```json
|
||||
❌ "actionClassName": "Espo\Custom\Api\MyAction"
|
||||
✅ "actionClassName": "Espo\\Custom\\Api\\MyAction"
|
||||
```
|
||||
|
||||
#### Debugging
|
||||
|
||||
**Check routes cache:**
|
||||
```bash
|
||||
docker exec espocrm cat data/cache/application/routes.php | grep -i "YourRoute"
|
||||
```
|
||||
|
||||
**Check logs:**
|
||||
```bash
|
||||
docker exec espocrm tail -100 data/logs/espo.log | grep -i "error\|exception"
|
||||
```
|
||||
|
||||
**Test mit curl verbose:**
|
||||
```bash
|
||||
curl -v "http://localhost:8080/api/v1/YourEndpoint" \
|
||||
-H "X-Api-Key: your-key" 2>&1 | head -30
|
||||
```
|
||||
|
||||
**Siehe:** `custom/docs/API_ENDPOINTS.md` für vollständige Beispiele
|
||||
|
||||
---
|
||||
|
||||
## Hook-Entwicklung
|
||||
|
||||
### Überblick
|
||||
|
||||
Reference in New Issue
Block a user