ארכיטקטורת Filtering ב-API: מתחביר URL ועד אינדקסים ואבטחה

מאת צוות מדיה דיל · 05.08.2026 · API Engineering · 8 דק׳

מנגנון סינון ב-API טוב מאזן בין גמישות ללקוח לבין הגנה על ביצועי מסד הנתונים ואבטחת המערכת - איזון שדורש תכנון מפורש ולא אילתור.

מפתח פרונטאנד פותח כרטיס תמיכה: מסך הדוחות שלו "קופא" כשלקוח מנסה לסנן לפי שלושה שדות במקביל - טווח תאריכים, סטטוס וקטגוריה. בבדיקה מתברר שה-API בנה לכל שילוב סינון שאילתת SQL דינמית שמורכבת מהדבקת מחרוזות ישירה, ושאילתה עם שלושה תנאים יחד פשוט לא מנצלת אף אינדקס קיים - היא סורקת את הטבלה כולה. זו נקודת כניסה טובה להבנה שארכיטקטורת Filtering ב-API היא לא רק שאלה של "איזה תחביר URL נוח יותר", אלא החלטת עיצוב שמשפיעה על ביצועים, אבטחה ותחזוקתיות בו-זמנית.

הבעיה: איזון בין גמישות ללקוח לבין שליטה בשרת

הפיתוי הראשון בבניית מנגנון סינון הוא לתת ללקוח כמה שיותר חופש - לאפשר לו לסנן לפי כל שדה, בכל אופרטור, בכל שילוב. זה מה שהלקוחות מבקשים, אבל זה גם המתכון לבעיה מהתרחיש שפתחנו איתו: שילובי סינון בלתי צפויים שאף אחד לא תכנן עבורם אינדקס, וברגע גרוע יותר - וקטור להתקפת הזרקת SQL אם המימוש לא נזהר. הגישה הנכונה מתחילה מהכיוון ההפוך: להגדיר מראש אילו שדות ניתנים לסינון, אילו אופרטורים נתמכים על כל שדה, ולבנות את שכבת התרגום מפרמטרי הבקשה לשאילתה בצורה שמונעת מהלקוח מבנה חופשי לגמרי.

תחביר URL - בין קריאוּת לביטוי עוצמה

יש כמה גישות מקובלות לביטוי סינון ב-URL, וכל אחת מתאימה לרמת מורכבות אחרת:

  • פרמטרים שטוחים - ?status=active&category=books. פשוט וקריא, אבל תומך רק ב-AND ובאופרטור שוויון. מספיק לרוב ה-API-ים הפשוטים.
  • אופרטורים מוטמעים בשם השדה - ?price[gte]=100&price[lte]=500. מאפשר טווחים ואי-שוויונים בלי לסבך את התחביר יתר על המידה, ונפוץ מאוד ב-API-ים בסגנון REST בוגר.
  • שפת סינון מלאה (JSON או RSQL) - ?filter=(status=='active' and price=gt=100). עוצמתי ביותר, תומך בקבוצות ולוגיקת OR מורכבת, אבל דורש Parser ייעודי בצד השרת ומעלה משמעותית את משטח האבטחה.

הכלל המנחה הוא לבחור את התחביר הפשוט ביותר שעדיין עונה על צרכי הלקוחות בפועל - קפיצה ישירה לשפת סינון מלאה כשמספיקים פרמטרים שטוחים היא over-engineering שמוסיף סיכון אבטחה ותחזוקה בלי תמורה אמיתית.

שכבת ה-Whitelist - הגנה שלא ניתנת לפשרה

לב הארכיטקטורה הבטוחה הוא רשימה מפורשת (Whitelist) של שדות סינון מותרים, שממופה בצד השרת לעמודת מסד נתונים אמיתית ולסוג האופרטורים המותרים עליה. בשום מקרה אסור לקחת שם שדה שהגיע מהלקוח ולהזריק אותו ישירות לתוך מחרוזת SQL:

const FILTERABLE_FIELDS = {
  status:   { column: 'status',   ops: ['eq', 'in'] },
  category: { column: 'category_id', ops: ['eq', 'in'] },
  price:    { column: 'price',    ops: ['eq', 'gte', 'lte', 'between'] },
};

function buildWhereClause(filters) {
  const clauses = [];
  const params = [];
  for (const [field, condition] of Object.entries(filters)) {
    const def = FILTERABLE_FIELDS[field];
    if (!def) throw new BadRequestError(`שדה סינון לא נתמך: ${field}`);
    // בניית תנאי פרמטרי בלבד, ללא הדבקת מחרוזת
  }
  return { clauses, params };
}

מעבר להגנה מפני הזרקה, ה-Whitelist הזו היא גם תיעוד חי - היא מגדירה במפורש מה ה-API באמת תומך בו, ומאפשרת לצוות לדעת בדיוק אילו שילובי סינון עלולים להתרחש בפרודקשן, ולתכנן אינדקסים בהתאם.

אינדקסים לשילובי סינון - התכנון שקל לפספס

אינדקס בודד על עמודה אחת לא עוזר הרבה כשלקוחות מסננים לפי כמה שדות במקביל. הפתרון הוא אינדקסים מרוכבים (Composite Index) שבנויים לפי שילובי הסינון הנפוצים בפועל - ולא לפי כל שילוב תיאורטי אפשרי, כי כל אינדקס נוסף מאט כתיבות ותופס מקום אחסון. הדרך הנכונה להחליט אילו אינדקסים לבנות היא לנתח לוגים אמיתיים של שאילתות סינון (או לפחות לשאול את המוצר אילו שילובים נפוצים ב-UI), ולא לנחש. עבור שדות עם קרדינליות נמוכה (כמו סטטוס עם שלושה ערכים אפשריים), אינדקס לפעמים כלל לא משתלם - מסד הנתונים עצמו יעדיף סריקה מלאה, וזה תקין.

סינון לפי טווחים ותאריכים - מקרה מיוחד שדורש תשומת לב

סינון לפי טווח תאריכים נראה פשוט אבל מסתיר כמה מלכודות. הראשונה היא אזורי זמן - אם הלקוח שולח תאריך בלי אזור זמן מפורש, יש להחליט ולתעד בבירור מה ברירת המחדל (UTC כמעט תמיד הבחירה הבטוחה), אחרת לקוחות באזורי זמן שונים יקבלו תוצאות לא עקביות סביב חצות. השנייה היא גבולות כוללים או לא-כוללים (gte לעומת gt) - חוסר עקביות בין endpoints שונים לגבי אילו גבולות כלולים גורם לבאגים עדינים בצד הלקוח, כמו רשומה שמופיעה או נעלמת בהתאם לאיזה endpoint משתמשים בו. השלישית היא ביצועים - טווח פתוח מדי (כמו "כל מה שנוצר אחרי 2020") על טבלה גדולה יכול להחזיר כמות עצומה של תוצאות, ולכן משתלב טוב עם מגבלת Pagination הדוקה כדי למנוע תשובות ענק.

ולידציה של קלט - מעבר להזרקת SQL

גם בלי סיכון הזרקה, קלט סינון לא מוולד יכול לגרום לבעיות משמעותיות. ערך לא תקין לשדה תאריך (למשל מחרוזת שאינה תאריך כלל), רשימת in ריקה שגורמת לתנאי SQL שגוי, או טווח הפוך (gte גדול מ-lte) - כל אלה צריכים להתגלות בשכבת ולידציה מפורשת לפני שהם מגיעים לשאילתה, ולהחזיר שגיאת 400 עם הודעה שמסבירה בדיוק מה שגוי. הימנעות מהשקעה בשכבה הזו גורמת לתופעה מוכרת: שאילתות שנכשלות בשקט ומחזירות רשימה ריקה במקום שגיאה, מה שגורם למפתחי הלקוח לחשוב שאין נתונים כשבפועל הבקשה שלהם פשוט הייתה שגויה. שכבת ולידציה טובה גם מתעדת את עצמה - כלי כמו Zod או Joi ב-Node.js, או Pydantic בפייתון, מאפשרים להגדיר את חוקי הסינון פעם אחת ולהשתמש בהם גם לוולידציה וגם כתיעוד אוטומטי (OpenAPI Schema).

Full-text Search - מתי סינון רגיל לא מספיק

סינון לפי שוויון או טווח לא פותר את הצורך של חיפוש חופשי בטקסט (למשל "מצא לקוחות שהשם שלהם מכיל את המחרוזת הזו"). ניסיון לממש את זה עם LIKE '%text%' על עמודת טקסט רגילה עובד בקנה מידה קטן אבל לא סקיילבילי - הביטוי הזה לא יכול לנצל אינדקס B-Tree רגיל בגלל התו הכללי (Wildcard) מלפנים. הפתרון הנכון הוא מנוע חיפוש טקסט ייעודי - Full-text Search מובנה במסד הנתונים (כמו GIN Index ב-PostgreSQL) לכמויות בינוניות, או מנוע חיצוני כמו Elasticsearch/Meilisearch כשצריך יכולות מתקדמות כמו דירוג רלוונטיות, טיפול בשגיאות כתיב או חיפוש רב-לשוני. חשוב להפריד בבירור בין "סינון מדויק" (שממומש מול מסד הנתונים הראשי) לבין "חיפוש חופשי" (שיכול לחיות בשכבה נפרדת) - ערבוב בין השניים באותו endpoint מקשה על אופטימיזציה של כל אחד מהם בנפרד.

Trade-offs - עוצמת ביטוי מול ביצועים ואבטחה

ככל שמתירים ללקוח יותר גמישות בסינון (יותר שדות, יותר אופרטורים, לוגיקת OR מורכבת), כך גדל משטח האבטחה, גדל הסיכון לשאילתות איטיות שלא נתמכות באינדקס, וגדלה מורכבות הקוד שצריך לתרגם ולוולד את הבקשה. הפתרון המעשי הוא לא לבטל גמישות אלא לתחום אותה - למשל להגביל את מספר תנאי הסינון בבקשה בודדת (5-10 תנאים מספיקים כמעט תמיד), להגביל את גודל רשימות ב-in (לא 10,000 ערכים בבת אחת), ולהחזיר שגיאה ברורה עם קוד 400 כשהבקשה חורגת מהמגבלות האלה, במקום לתת לה לרוץ ולהאט את כל המערכת עבור כולם. שיקול נוסף שכדאי לשקלל הוא זמן תגובה מקסימלי (Query Timeout) ברמת מסד הנתונים לכל שאילתת סינון - הגנה אחרונה שמבטיחה ששאילתה חריגה שאף אחד לא צפה אותה מראש לא תחזיק חיבור פתוח לנצח ותרעיב שאר הבקשות ממאגר החיבורים (Connection Pool).

סינון בממשקי GraphQL לעומת REST

ב-GraphQL, סינון בדרך כלל ממומש כארגומנטים טיפוסיים על השדה עצמו בסכמה (למשל orders(filter: OrderFilterInput)), מה שנותן ולידציה חינם ברמת הסכמה - לקוח שמנסה לשלוח אופרטור לא נתמך מקבל שגיאה עוד לפני שהקוד שלכם רץ בכלל. זה יתרון משמעותי לעומת REST, שבו הולידציה חייבת להיות מפורשת בקוד. מצד שני, סכמת GraphQL טיפוסית פחות גמישה לשינויים מהירים - הוספת אופרטור סינון חדש דורשת שינוי סכמה ופריסה, בעוד ב-REST אפשר לפעמים להוסיף תמיכה באופרטור חדש בלי לשנות את חוזה ה-API הפורמלי. הבחירה בין השניים תלויה בקצב השינויים הצפוי של דרישות הסינון ובמידת הנוחות של הצוות עם כלי הסכמה של GraphQL.

טעויות נפוצות בפרודקשן

הטעות המסוכנת ביותר היא כמובן הזרקת שמות עמודות או ערכים ישירות לתוך שאילתת SQL גולמית - גם כשנדמה שהקלט "בטוח" כי הוא מגיע מ-Dropdown בממשק, כי אין שום ערובה שהבקשה בפועל הגיעה מאותו ממשק ולא נבנתה ידנית. הטעות השנייה היא סינון שמתבצע אחרי משיכת הנתונים לזיכרון (Filter in Application) במקום במסד הנתונים - זה עובד יפה בבדיקות עם כמה עשרות רשומות ומתפוצץ מיד כשהטבלה גדלה, כי המערכת עדיין מושכת את כל הנתונים לפני הסינון. השלישית היא חוסר עקביות בין שמות פרמטרים לשמות שדות במסד הנתונים - אם ה-API מקבל customer_name אבל העמודה נקראת full_name, וזה לא ממופה בבירור בשכבת ה-Whitelist, זה יוצר בלבול ובאגים כשמישהו מוסיף שדה סינון חדש בלי להבין את המיפוי הקיים.

מתי כדאי להשקיע בשכבת סינון מלאה ומתי לא

עבור API פנימי עם מספר קטן של צרכנים ידועים, מספיק לרוב פרמטרים שטוחים בסיסיים - השקעה בשפת סינון מלאה שם היא בזבוז זמן פיתוח. אבל ברגע שה-API משרת לקוחות חיצוניים או ממשק דוחות עצמאי (Self-service Reporting) שבו משתמשי קצה בוחרים בעצמם אילו שדות לסנן, השקעה בארכיטקטורת סינון מסודרת - עם Whitelist, אינדקסים מתוכננים ומגבלות ברורות - היא לא מותרות אלא תנאי הכרחי ליציבות המערכת. נקודת החלטה טובה היא לשאול האם צוות המוצר צפוי להוסיף שדות סינון חדשים בתדירות גבוהה - אם כן, שווה להשקיע מראש בתשתית גנרית שתומכת בהוספת שדה בלי כתיבת קוד חדש לכל שדה.

סיכום

סינון ב-API הוא אחד המקומות שבהם הפער בין "עובד בדמו" לבין "עומד בפרודקשן" הוא הגדול ביותר. תכנון נכון מתחיל מהגדרת Whitelist מפורשת של שדות ואופרטורים מותרים, ממשיך באינדקסים שמתאימים לשילובי סינון אמיתיים ולא תיאורטיים, ומסתיים בהגבלות ברורות שמונעות משילוב יחיד לפגוע בכל המערכת. הצוותים שאנחנו פוגשים במדיה דיל שסבלו מבעיות ביצועים קשות בשכבת הסינון כמעט תמיד דילגו על אחד משלושת השלבים האלה - ובדרך כלל דווקא על שלב תכנון האינדקסים, כי הוא הכי פחות נראה לעין בשלב הפיתוח הראשוני. עצה מעשית לסיום: לפני שמוסיפים שדה סינון חדש לפרודקשן, שווה לשאול לא רק "האם זה נחוץ למוצר" אלא גם "איזה אינדקס יתמוך בזה, ומה קורה אם לקוח ישלב אותו עם שני שדות סינון אחרים בו-זמנית" - השאלה השנייה היא זו שנוטים לדלג עליה, וזו בדיוק זו שחוזרת לנשוך בפרודקשן.

תגיות: API Filtering · SQL Injection · Composite Index · REST API · GraphQL · Query Parameters · Whitelist

← חזרה לבלוג · צור קשר