Custom Directus Endpoints: הרחבת REST API לכל לוגיקה עסקית
פרויקטים רבים על Directus נתקלים במשימות שלא ניתן לפתור עם ה-API המובנה. דוגמה טיפוסית: ביצוע הזמנה בחנות מסחר אלקטרוני. צריך לבדוק מלאי, ליצור רשומה בטבלת ההזמנות, ליצור סשן תשלום של Stripe ולשלוח אימייל ללקוח. בשיטות סטנדרטיות, זה הופך לשרשרת של webhooks ושירותים חיצוניים, מה שמסבך את התחזוקה. נקודת קצה מותאמת אישית פותרת את הבעיה עם בקשת POST אחת. אנו מפתחים הרחבות כאלה למסחר אלקטרוני, SaaS ופורטלים ארגוניים. הניסיון שלנו: מעל 15 פרויקטים, זמן פיתוח ממוצע של 3 ימים. אתם מקבלים API שתואם בדיוק לתהליכים העסקיים שלכם ללא שכבות מיותרות.
הרחבת נקודת קצה היא מנגנון המאפשר להוסיף נתיבים חדשים ל-API של Directus באמצעות נתב Express מוכר. אתם מקבלים שליטה מלאה על הלוגיקה ומבנה התגובה, גישה לשירותי Directus (ItemsService וכו') ולסכמת מסד הנתונים, יכולת אינטגרציה עם כל API חיצוני (מערכות תשלום, CRM), וניהול גמיש של הרשאות ברמת נקודת הקצה. כל נקודת קצה עוברת בדיקת קוד ומכוסה בבדיקות. ניסיון עם Directus: מעל 4 שנים, יותר מ-10 פרויקטים שבוצעו.
אילו בעיות אנו פותרים
בעיה 1: לוגיקה עסקית טרנזקציונית. בעת יצירת הזמנה, יש צורך לנכות מלאי באופן אטומי, ליצור רשומת הזמנה וליזום תשלום. נקודת קצה מותאמת אישית מיישמת את הטרנזקציה באמצעות שירותי Directus ושער תשלום. שגיאות מתבטלות, והנתונים נשארים עקביים.
בעיה 2: דוחות מצטברים. ה-API הסטנדרטי אינו יכול לחשב סיכומי מכירות לפי סטטוס לאורך תקופה. אנו כותבים נקודת קצה אחת שמבצעת סינון ואגרגציה על השרת, ומחזירה JSON מוכן לדשבורד. זמן הפקת הדוח יורד מכמה שעות לכמה שניות.
בעיה 3: Webhooks ממערכות חיצוניות. Stripe, PayPal ושירותים אחרים שולחים webhooks שצריך לעבד ולעדכן נתונים ב-Directus. נקודת קצה מותאמת אישית היא המקום האידיאלי לקבלת ואימות webhooks. אנו מבטיחים עיבוד עם זמינות של 99.9%.
דוגמה: תשלום ב-Directus
נבחן תרחיש טיפוסי של מסחר אלקטרוני. אנו צריכים נקודת קצה // extensions/endpoints/checkout/index.ts import type { EndpointExtensionContext } from '@directus/types' import { Router } from 'express' export default (router: Router, { services, getSchema, env, logger }: EndpointExtensionContext) => { // POST /checkout — оформление заказа router.post('/checkout', async (req, res) => { const schema = await getSchema() const { ItemsService } = services // Проверка аутентификации if (!req.accountability?.user) { return res.status(401).json({ errors: [{ message: 'Unauthorized' }] }) } const { items, shipping_address, payment_method } = req.body if (!items?.length) { return res.status(400).json({ errors: [{ message: 'Cart is empty' }] }) } try { const productsService = new ItemsService('products', { schema, accountability: req.accountability }) // Проверить наличие и посчитать итог let total = 0 const enrichedItems: any[] = [] for (const item of items) { const product = await productsService.readOne(item.product_id, { fields: ['id', 'name', 'price', 'stock'], }) if (product.stock < item.quantity) { return res.status(409).json({ errors: [{ message: `Insufficient stock for "${product.name}"` }], }) } total += product.price * item.quantity enrichedItems.push({ ...item, price: product.price, name: product.name }) } // Создать заказ const ordersService = new ItemsService('orders', { schema, accountability: req.accountability }) const order = await ordersService.createOne({ user: req.accountability.user, items: enrichedItems, total, shipping_address, status: 'pending', date_created: new Date().toISOString(), }) // Создать платёжную сессию const paymentSession = await createPaymentSession(order, total, env) return res.json({ data: { orderId: order, paymentUrl: paymentSession.url, total, }, }) } catch (error) { logger.error('Checkout error:', error) return res.status(500).json({ errors: [{ message: 'Checkout failed' }] }) } }) // POST /checkout/webhook/stripe router.post('/webhook/stripe', async (req, res) => { const sig = req.headers['stripe-signature'] as string let event try { event = verifyStripeWebhook(req.rawBody, sig, env.STRIPE_WEBHOOK_SECRET) } catch { return res.status(400).json({ error: 'Webhook signature invalid' }) } if (event.type === 'checkout.session.completed') { const session = event.data.object const orderId = session.metadata?.orderId if (orderId) { const schema = await getSchema() const ordersService = new services.ItemsService('orders', { schema }) await ordersService.updateOne(Number(orderId), { status: 'paid', payment_id: session.payment_intent, paid_at: new Date().toISOString(), }) } } return res.json({ received: true }) }) // GET /reports/sales router.get('/reports/sales', async (req, res) => { // Только для admin if (!req.accountability?.admin) { return res.status(403).json({ errors: [{ message: 'Admin access required' }] }) } const { period = 'week' } = req.query const schema = await getSchema() const ordersService = new services.ItemsService('orders', { schema, accountability: req.accountability }) const periodDays: Record<string, number> = { day: 1, week: 7, month: 30 } const days = periodDays[period as string] || 7 const since = new Date(Date.now() - days * 86400000).toISOString() const orders = await ordersService.readByQuery({ filter: { date_created: { _gte: since }, status: { _in: ['paid', 'shipped', 'delivered'] }, }, fields: ['id', 'total', 'date_created', 'status'], limit: -1, }) const totalRevenue = orders.reduce((sum: number, o: any) => sum + (o.total || 0), 0) return res.json({ data: { count: orders.length, revenue: totalRevenue, avgOrder: orders.length > 0 ? Math.round(totalRevenue / orders.length) : 0, period, }, }) }) // GET /search router.get('/search', async (req, res) => { const { q, collections = 'articles,products' } = req.query as { q: string; collections: string } if (!q || q.length < 2) { return res.json({ data: [] }) } const schema = await getSchema() const collectionList = (collections as string).split(',') const searchMap: Record<string, string[]> = { articles: ['title', 'excerpt'], products: ['name', 'description'], pages: ['title'], } const results = await Promise.all( collectionList .filter(c => searchMap[c]) .map(async collection => { const service = new services.ItemsService(collection, { schema, accountability: req.accountability }) const orFilter = searchMap[collection].map(field => ({ [field]: { _icontains: q }, })) const items = await service.readByQuery({ filter: { _or: orFilter }, fields: ['id', ...searchMap[collection]], limit: 5, }) return items.map((item: any) => ({ ...item, _collection: collection })) }) ) return res.json({ data: results.flat() }) }) } async function createPaymentSession(orderId: number, total: number, env: any) { // Stripe checkout session const response = await fetch('https://api.stripe.com/v1/checkout/sessions', { method: 'POST', headers: { Authorization: `Bearer ${env.STRIPE_SECRET_KEY}`, 'Content-Type': 'application/x-www-form-urlencoded', }, body: new URLSearchParams({ 'payment_method_types[]': 'card', 'line_items[0][price_data][currency]': 'rub', 'line_items[0][price_data][unit_amount]': String(Math.round(total * 100)), 'line_items[0][price_data][product_data][name]': `Order #${orderId}`, 'line_items[0][quantity]': '1', mode: 'payment', 'metadata[orderId]': String(orderId), success_url: `${env.FRONTEND_URL}/order/${orderId}/success`, cancel_url: `${env.FRONTEND_URL}/cart`, }), }) return response.json() } שמקבלת עגלת קניות, כתובת משלוח ואמצעי תשלום. על השרת:
- אימות התחברות המשתמש
- שימוש ב-ItemsService לקבלת נתונים עבור כל מוצר (מחיר, מלאי)
- אם המלאי אינו מספיק, החזרת שגיאה 409
- יצירת רשומת הזמנה עם הסכום הכולל
- יצירת סשן תשלום של Stripe והחזרת קישור התשלום
- לאחר תשלום מוצלח, Stripe שולח webhook שמעדכן את סטטוס ההזמנה
// extensions/endpoints/checkout/index.ts
import type { EndpointExtensionContext } from '@directus/types'
import { Router } from 'express'
export default (router: Router, { services, getSchema, env, logger }: EndpointExtensionContext) => {
// POST /checkout — оформление заказа
router.post('/checkout', async (req, res) => {
const schema = await getSchema()
const { ItemsService } = services
// Проверка аутентификации
if (!req.accountability?.user) {
return res.status(401).json({ errors: [{ message: 'Unauthorized' }] })
}
const { items, shipping_address, payment_method } = req.body
if (!items?.length) {
return res.status(400).json({ errors: [{ message: 'Cart is empty' }] })
}
try {
const productsService = new ItemsService('products', { schema, accountability: req.accountability })
// Проверить наличие и посчитать итог
let total = 0
const enrichedItems: any[] = []
for (const item of items) {
const product = await productsService.readOne(item.product_id, {
fields: ['id', 'name', 'price', 'stock'],
})
if (product.stock < item.quantity) {
return res.status(409).json({ errors: [{ message: `Insufficient stock for "${product.name}"` }] })
}
total += product.price * item.quantity
enrichedItems.push({ ...item, price: product.price, name: product.name })
}
// Создать заказ
const ordersService = new ItemsService('orders', { schema, accountability: req.accountability })
const order = await ordersService.createOne({
user: req.accountability.user,
items: enrichedItems,
total,
shipping_address,
status: 'pending',
date_created: new Date().toISOString(),
})
// Создать платёжную сессию
const paymentSession = await createPaymentSession(order, total, env)
return res.json({
data: {
orderId: order,
paymentUrl: paymentSession.url,
total,
},
})
} catch (error) {
logger.error('Checkout error:', error)
return res.status(500).json({ errors: [{ message: 'Checkout failed' }] })
}
})
// POST /checkout/webhook/stripe
router.post('/webhook/stripe', async (req, res) => {
const sig = req.headers['stripe-signature'] as string
let event
try {
event = verifyStripeWebhook(req.rawBody, sig, env.STRIPE_WEBHOOK_SECRET)
} catch {
return res.status(400).json({ error: 'Webhook signature invalid' })
}
if (event.type === 'checkout.session.completed') {
const session = event.data.object
const orderId = session.metadata?.orderId
if (orderId) {
const schema = await getSchema()
const ordersService = new services.ItemsService('orders', { schema })
await ordersService.updateOne(Number(orderId), {
status: 'paid',
payment_id: session.payment_intent,
paid_at: new Date().toISOString(),
})
}
}
return res.json({ received: true })
})
// GET /reports/sales
router.get('/reports/sales', async (req, res) => {
// Только для admin
if (!req.accountability?.admin) {
return res.status(403).json({ errors: [{ message: 'Admin access required' }] })
}
const { period = 'week' } = req.query
const schema = await getSchema()
const ordersService = new services.ItemsService('orders', { schema, accountability: req.accountability })
const periodDays: Record<string, number> = {
day: 1,
week: 7,
month: 30,
}
const days = periodDays[period as string] || 7
const since = new Date(Date.now() - days * 86400000).toISOString()
const orders = await ordersService.readByQuery({
filter: {
date_created: { _gte: since },
status: { _in: ['paid', 'shipped', 'delivered'] },
},
fields: ['id', 'total', 'date_created', 'status'],
limit: -1,
})
const totalRevenue = orders.reduce((sum: number, o: any) => sum + (o.total || 0), 0)
return res.json({
data: {
count: orders.length,
revenue: totalRevenue,
avgOrder: orders.length > 0 ? Math.round(totalRevenue / orders.length) : 0,
period,
},
})
})
// GET /search
router.get('/search', async (req, res) => {
const { q, collections = 'articles,products' } = req.query as { q: string; collections: string }
if (!q || q.length < 2) {
return res.json({ data: [] })
}
const schema = await getSchema()
const collectionList = (collections as string).split(',')
const searchMap: Record<string, string[]> = {
articles: ['title', 'excerpt'],
products: ['name', 'description'],
pages: ['title'],
}
const results = await Promise.all(
collectionList
.filter(c => searchMap[c])
.map(async collection => {
const service = new services.ItemsService(collection, { schema, accountability: req.accountability })
const orFilter = searchMap[collection].map(field => ({ [field]: { _icontains: q } }))
const items = await service.readByQuery({
filter: { _or: orFilter },
fields: ['id', ...searchMap[collection]],
limit: 5,
})
return items.map((item: any) => ({ ...item, _collection: collection }))
})
)
return res.json({ data: results.flat() })
})
}
async function createPaymentSession(orderId: number, total: number, env: any) {
// Stripe checkout session
const response = await fetch('https://api.stripe.com/v1/checkout/sessions', {
method: 'POST',
headers: {
Authorization: `Bearer ${env.STRIPE_SECRET_KEY}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
'payment_method_types[]': 'card',
'line_items[0][price_data][currency]': 'rub',
'line_items[0][price_data][unit_amount]': String(Math.round(total * 100)),
'line_items[0][price_data][product_data][name]': `Order #${orderId}`,
'line_items[0][quantity]': '1',
mode: 'payment',
'metadata[orderId]': String(orderId),
success_url: `${env.FRONTEND_URL}/order/${orderId}/success`,
cancel_url: `${env.FRONTEND_URL}/cart`,
}),
})
return response.json()
} ארכיטקטורת הפתרון
הלקוח שולח בקשה -> Directus בודק אימות -> נקודת הקצה המותאמת אישית מעבדת את הלוגיקה באמצעות ItemsService ו-API חיצוני. כל הבקשות מתועדות, ושגיאות מטופלות באופן מרכזי. גישה זו מבטלת את הצורך במיקרוסרוויס נפרד.
כיצד נקודות קצה מותאמות אישית מאיצות פיתוח?
נקודת קצה מותאמת אישית של Directus מפותחת פי 3–5 מהר יותר בהשוואה לכתיבת מיקרוסרוויס נפרד. תוספים מוכנים אינם מציעים גמישות כזו. בפרויקט אחד, יישמנו 10+ נקודות קצה בשבועיים, בעוד שמיקרוסרוויס היה לוקח חודש. גישה זו מפחיתה את תקציב האינטגרציה פי 2–3 ומחזירה את עצמה תוך 2–3 חודשים בזכות זמן פיתוח מופחת.
מה לבחור: נקודת קצה מותאמת אישית או API סטנדרטי?
| תרחיש | נקודת קצה מותאמת אישית | API סטנדרטי |
|---|---|---|
| יצירת רשומה פשוטה | מוגזם | ✅ |
| ולידציה מורכבת עם קריאה חיצונית | ✅ | רק דרך מותאם אישית |
| דוח מצטבר | ✅ | ❌ (רק עם hooks) |
| אינטגרציה עם שער תשלום | ✅ | ❌ |
| חיפוש רב-אוסף | ✅ | ❌ |
מה כלול
- קוד מקור של ההרחבה ב-TypeScript עם הערות
- קובץ package.json מוגדר עבור Directus Extension
- הוראות פריסה (העתקה לתיקיית extensions, הפעלה מחדש)
- אוסף Postman עם בקשות לדוגמה
- טיפול בשגיאות וולידציית קלט
- 30 ימי תמיכה לאחר המסירה
תהליך
- ניתוח דרישות — אתם מתארים את נקודות הקצה הנדרשות, ואנו מבהירים פרטים
- עיצוב — מסכימים על מבנה הנתיבים ופורמט התגובה
- יישום — כתיבת קוד ב-TypeScript, חיבור שירותי Directus
- בדיקות — כיסוי מקרים קריטיים בבדיקות יחידה, בדיקה בסביבה דמוית ייצור
- פריסה — מסירת הבנייה והתיעוד
לוחות זמנים משוערים
| מספר נקודות קצה | לוח זמנים |
|---|---|
| 1–2 פשוטות (ולידציה, אינטגרציה) | 1–2 ימים |
| 3–4 עם APIs חיצוניים (תשלומים, דוחות) | 3–5 ימים |
| 5+ מורכבות עם webhooks | 5–7 ימים |
לוחות זמנים מדויקים מחושבים לאחר בריף. צרו קשר — נבחן את הפרויקט שלכם.
למה לבחור בנו
מעל 4 שנות ניסיון עם Directus: פיתחנו עבור מסחר אלקטרוני, CMS ו-SaaS. אחריות על כל ההרחבות — אנו מתקנים באגים בחינם למשך חודש. קוד שקוף — כל השינויים ב-Git, עם בדיקת קוד חובה. תמיכה לאחר ההשקה — אנו מייעצים ומשפרים לפי הצורך.
הזמינו נקודות קצה מותאמות אישית של Directus במפתח מוכן. קבלו API שתואם בדיוק לתהליכים העסקיים שלכם. להערכה חינמית של הפרויקט שלכם, צרו קשר — נכין הצעה תוך יום עסקים אחד.







