api-dock 0.8.1__tar.gz → 0.9.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. {api_dock-0.8.1 → api_dock-0.9.0}/PKG-INFO +74 -26
  2. {api_dock-0.8.1 → api_dock-0.9.0}/README.md +73 -25
  3. api_dock-0.9.0/api_dock/__init__.py +72 -0
  4. {api_dock-0.8.1 → api_dock-0.9.0}/api_dock/config.py +4 -3
  5. {api_dock-0.8.1 → api_dock-0.9.0}/api_dock/database_config.py +260 -58
  6. {api_dock-0.8.1 → api_dock-0.9.0}/api_dock/example_api_dock_config/config.yaml +5 -0
  7. {api_dock-0.8.1 → api_dock-0.9.0}/api_dock/example_api_dock_config/databases/example_db.yaml +4 -4
  8. {api_dock-0.8.1 → api_dock-0.9.0}/api_dock/fast_api.py +68 -5
  9. {api_dock-0.8.1 → api_dock-0.9.0}/api_dock/flask_api.py +60 -4
  10. {api_dock-0.8.1 → api_dock-0.9.0}/api_dock/listings.py +28 -11
  11. {api_dock-0.8.1 → api_dock-0.9.0}/api_dock/route_mapper.py +302 -45
  12. {api_dock-0.8.1 → api_dock-0.9.0}/api_dock/sql_builder.py +325 -214
  13. api_dock-0.9.0/api_dock/sql_template_check.py +317 -0
  14. {api_dock-0.8.1 → api_dock-0.9.0}/api_dock.egg-info/PKG-INFO +74 -26
  15. {api_dock-0.8.1 → api_dock-0.9.0}/api_dock.egg-info/SOURCES.txt +6 -0
  16. {api_dock-0.8.1 → api_dock-0.9.0}/pyproject.toml +1 -1
  17. api_dock-0.9.0/tests/test_bound_parameters.py +260 -0
  18. api_dock-0.9.0/tests/test_database_requests.py +335 -0
  19. {api_dock-0.8.1 → api_dock-0.9.0}/tests/test_listings.py +75 -0
  20. {api_dock-0.8.1 → api_dock-0.9.0}/tests/test_proxy_pipeline.py +5 -0
  21. api_dock-0.9.0/tests/test_runtime_settings.py +249 -0
  22. {api_dock-0.8.1 → api_dock-0.9.0}/tests/test_schema_unions.py +7 -6
  23. {api_dock-0.8.1 → api_dock-0.9.0}/tests/test_shared_database_config.py +9 -10
  24. {api_dock-0.8.1 → api_dock-0.9.0}/tests/test_sql_builder.py +17 -14
  25. {api_dock-0.8.1 → api_dock-0.9.0}/tests/test_sql_selector.py +10 -5
  26. api_dock-0.9.0/tests/test_sql_template_check.py +147 -0
  27. api_dock-0.9.0/tests/test_startup_check.py +449 -0
  28. api_dock-0.8.1/api_dock/__init__.py +0 -26
  29. {api_dock-0.8.1 → api_dock-0.9.0}/LICENSE.md +0 -0
  30. {api_dock-0.8.1 → api_dock-0.9.0}/api_dock/auth.py +0 -0
  31. {api_dock-0.8.1 → api_dock-0.9.0}/api_dock/cli.py +0 -0
  32. {api_dock-0.8.1 → api_dock-0.9.0}/api_dock/config_discovery.py +0 -0
  33. {api_dock-0.8.1 → api_dock-0.9.0}/api_dock/encryption.py +0 -0
  34. {api_dock-0.8.1 → api_dock-0.9.0}/api_dock/example_api_dock_config/databases/config.yaml +0 -0
  35. {api_dock-0.8.1 → api_dock-0.9.0}/api_dock/example_api_dock_config/remotes/example_remote.yaml +0 -0
  36. {api_dock-0.8.1 → api_dock-0.9.0}/api_dock/storage_auth.py +0 -0
  37. {api_dock-0.8.1 → api_dock-0.9.0}/api_dock/types.py +0 -0
  38. {api_dock-0.8.1 → api_dock-0.9.0}/api_dock.egg-info/dependency_links.txt +0 -0
  39. {api_dock-0.8.1 → api_dock-0.9.0}/api_dock.egg-info/entry_points.txt +0 -0
  40. {api_dock-0.8.1 → api_dock-0.9.0}/api_dock.egg-info/requires.txt +0 -0
  41. {api_dock-0.8.1 → api_dock-0.9.0}/api_dock.egg-info/top_level.txt +0 -0
  42. {api_dock-0.8.1 → api_dock-0.9.0}/setup.cfg +0 -0
  43. {api_dock-0.8.1 → api_dock-0.9.0}/tests/test_inject_cookies.py +0 -0
  44. {api_dock-0.8.1 → api_dock-0.9.0}/tests/test_types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: api_dock
3
- Version: 0.8.1
3
+ Version: 0.9.0
4
4
  Summary: A flexible API gateway that allows you to proxy requests to multiple remote APIs and Databases
5
5
  Author-email: Brookie Guzder-Williams <bguzder-williams@berkeley.edu>
6
6
  License-Expression: BSD-3-Clause
@@ -219,19 +219,33 @@ remotes:
219
219
  settings:
220
220
  add_trailing_slash: true # Auto-add trailing slash to paths (default: true)
221
221
  follow_protocol_downgrades: false # Allow HTTPS->HTTP redirects (default: false)
222
+ follow_redirects: true # Follow remote redirects (default: true)
222
223
  timeout: 10 # Upstream request timeout in seconds (default: 10)
224
+ base_path: /dock # Also serve the API under this prefix (default: none)
225
+ duckdb: # Options for database queries (default: none)
226
+ memory_limit: 700MB
227
+ threads: 2
228
+ max_concurrent_queries: 2
223
229
  ```
224
230
 
225
- ### HTTP behavior Settings
231
+ ### Settings
226
232
 
227
- The optional `settings` section controls HTTP behavior:
233
+ The optional `settings` section controls HTTP and query behavior:
228
234
 
229
235
  - **`add_trailing_slash`** (default: `true`): Automatically append a trailing slash to all proxied paths. This prevents 307/301 redirects from remote APIs that require trailing slashes (e.g., `/projects` → `/projects/`). Set to `false` to disable this behavior.
230
236
 
231
237
  - **`follow_protocol_downgrades`** (default: `false`): Control how HTTP redirects are handled. When `false` (recommended), HTTPS→HTTP redirects are blocked for security. When `true`, allows following redirects that downgrade from HTTPS to HTTP (not recommended for production).
232
238
 
239
+ - **`follow_redirects`** (default: `true`): Whether redirects from a remote are followed by API Dock (`true`) or passed through to the client with their `Location` header (`false`). Set it to `false` when a remote answers with redirects the client should follow itself, such as presigned S3 URLs for large files.
240
+
233
241
  - **`timeout`** (default: `10`): Upstream request timeout in seconds, applied to both the streaming and buffered proxy paths. Raise it for slow upstreams (e.g. large aggregation queries) that would otherwise return a 502 on timeout. Set to `null` or `false` to disable the timeout entirely (not recommended — a stalled upstream can hold the connection open indefinitely).
234
242
 
243
+ - **`base_path`** (default: none): An extra URL prefix the API is also served under, e.g. `/dock`. Use it when a proxy or CDN forwards a path on another domain without stripping it (say CloudFront routes `https://app.example.org/dock/*` to API Dock): `/dock/birdnet/latest/detections/` is then handled as `/birdnet/latest/detections/`. Paths without the prefix keep working, so direct calls and health checks are unaffected.
244
+
245
+ - **`duckdb`** (default: none): Options for the DuckDB connection each database query runs on. Every key except `max_concurrent_queries` is applied as `SET <key> = <value>`, so any [DuckDB setting](https://duckdb.org/docs/configuration/overview) works; the useful ones on small servers are `memory_limit` (DuckDB spills to disk or fails the query instead of exceeding it), `threads`, and `temp_directory`. `max_concurrent_queries` caps how many database queries run at once in the process; further queries wait their turn. Memory limits apply per query, so on a small instance set `memory_limit × max_concurrent_queries` below the instance's memory.
246
+
247
+ Database queries run in worker threads, so a slow query doesn't hold up other requests (including health checks on `/`).
248
+
235
249
  ### Catalog Endpoints (`expose`)
236
250
 
237
251
  The optional `expose` section adds read-only endpoints that list the models and versions of your configured databases, remotes, or both ("sources"). Listings are **opt-in** — with no `expose` key nothing is added.
@@ -411,6 +425,22 @@ Database configurations are stored in `api_dock_config/databases/` directory. Ea
411
425
  - **routes**: REST endpoints mapped to SQL queries
412
426
  - **schema** (optional): the shared schema (from `databases/config.yaml`) this config's `[[table]]` references fall back to. See [Shared Tables and Schemas](#shared-tables-and-schemas-databasesconfigyaml)
413
427
 
428
+ ### Startup checks
429
+
430
+ When API Dock starts, it checks every database and version listed in the main config, from config files and from the shared `databases/config.yaml` (`slugs`), exactly as requests will see them: merged with the main config and with the shared `routes`/`query_params` that apply to that version (after `include`/`exclude`). If anything is wrong it refuses to start and names the database, version, route and template, for example:
431
+
432
+ ```
433
+ ValueError: Database 'owl' version '4.0', route '/detections/{{id}}/overlaps': sql: Table 'all_modelz.detections' not found in database configuration
434
+ ```
435
+
436
+ It checks that:
437
+ - the shared `databases/config.yaml` is valid (`slugs`, `schema_groups`, and the types of its sections)
438
+ - every route and query param has a valid shape
439
+ - no `{{variable}}` is inside a quoted string (other than a string that is exactly one variable, `'{{x}}'`), a quoted identifier, or a SQL comment, and no template ends inside a comment (see [How values reach the database](#how-values-reach-the-database))
440
+ - every `[[table]]`, `[[schema.table]]`, `[[*.table]]` and `[[group.table]]` reference resolves, unions are only used after `FROM`/`JOIN`, and `source_columns` is valid
441
+
442
+ Database and remote config files are read from the folder that holds the main config file.
443
+
414
444
  ### Syntax
415
445
 
416
446
  As with the remote-apis, the routes to databases use double-curly-brackets {{}} to reference url variable placeholders.
@@ -713,6 +743,24 @@ Rules:
713
743
  ## URL Query Parameters
714
744
 
715
745
 
746
+ ### How values reach the database
747
+
748
+ api_dock does not paste request values into SQL. Each `{{variable}}` in `sql`, `multivalue_sql`, conditional `sql` and `queries:` becomes a placeholder, and its value (from the path, query string, a `default`, or a cookie) is sent to DuckDB separately. DuckDB converts the value to the column's type, so number, date and boolean filters work as written. A value that is not a valid number, date or boolean for its column, such as `?age=25 OR true`, is rejected with an error instead of being run as SQL.
749
+
750
+ Because the value is sent separately, **write variables without quotes**:
751
+
752
+ | Write | Not |
753
+ |---|---|
754
+ | `department = {{department}}` | `department = '{{department}}'` |
755
+ | `UPPER(name) = UPPER({{name}})` | `UPPER(name) = UPPER('{{name}}')` |
756
+ | `name ILIKE '%' \|\| {{name}} \|\| '%'` | `name ILIKE '%{{name}}%'` |
757
+
758
+ For compatibility with 0.8.x and earlier configs, a string that is exactly one variable (`'{{department}}'`) is read as `{{department}}`. Any other quoted variable, like `'%{{name}}%'`, would be read as literal text, so api_dock refuses to start and prints the route with the fixed form (rewrite it with `||` as in the last row above). A variable can't be used as a column name in double quotes (`"{{column}}"`) either. A variable inside a SQL comment (`-- {{x}}` or `/* {{x}} */`) would be sent with nothing in the query to use it, so api_dock refuses to start and asks you to remove it. A template also can't end inside a comment, because api_dock adds WHERE conditions and `sql_append` clauses after it on the same line: put the comment on its own line or use `/* */`.
759
+
760
+ `sql_append` works differently. Its values are column names, `ASC`/`DESC` or numbers, which a database can't accept as separate values, so they are written into the SQL text. Each one must contain only letters, digits, spaces and `_ . , ( ) -`, and must not contain `--`. The same applies to the `sql_append` of a [conditional SQL selection](#conditional-sql-selection) branch.
761
+
762
+ The `# SQL:` comments in the examples below show values in place so the queries are easier to read.
763
+
716
764
  ### Basic Filtering with `sql`
717
765
 
718
766
  Use `sql` to add WHERE clause fragments. Each fragment is joined with `AND`. Optional by default — only included if the parameter is in the URL.
@@ -725,7 +773,7 @@ routes:
725
773
  - age:
726
774
  sql: age = {{age}} # optional — only if ?age= provided
727
775
  - department:
728
- sql: department = '{{department}}'
776
+ sql: department = {{department}}
729
777
  - height:
730
778
  sql: height < {{height}}
731
779
  default: 200 # always included (uses 200 if not in URL)
@@ -741,7 +789,7 @@ GET /db/users
741
789
 
742
790
  ### Repeated Parameters with `multivalue_sql`
743
791
 
744
- A query parameter key can appear more than once in the URL (e.g. `?recording_id=1&recording_id=4`). Add a `multivalue_sql` template alongside `sql` to handle this: when **more than one** value is passed for the key, `multivalue_sql` is used instead of `sql`, and `{{param}}` expands to a parenthesized, quote-escaped SQL value list suitable for an `IN` clause.
792
+ A query parameter key can appear more than once in the URL (e.g. `?recording_id=1&recording_id=4`). Add a `multivalue_sql` template alongside `sql` to handle this: when **more than one** value is passed for the key, `multivalue_sql` is used instead of `sql`, and `{{param}}` expands to a parenthesized list with one value per URL entry, suitable for an `IN` clause. Each value is sent to the database separately, like any other variable.
745
793
 
746
794
  Behavior is unchanged when `multivalue_sql` is absent, and when only a single value is passed the normal `sql` template is used.
747
795
 
@@ -754,7 +802,7 @@ routes:
754
802
  sql: "[[detections]].recording_id = {{recording_id}}" # single value
755
803
  multivalue_sql: "[[detections]].recording_id IN {{recording_id}}" # 2+ values
756
804
  - scientific_name:
757
- sql: "[[detections]].scientific_name = '{{scientific_name}}'"
805
+ sql: "[[detections]].scientific_name = {{scientific_name}}"
758
806
  ```
759
807
 
760
808
  ```bash
@@ -780,7 +828,7 @@ routes:
780
828
  query_params:
781
829
  # WHERE clause params
782
830
  - department:
783
- sql: department = '{{department}}'
831
+ sql: department = {{department}}
784
832
  # Post-WHERE params
785
833
  - sort:
786
834
  sql_append: ORDER BY {{sort}} {{sort_direction}}
@@ -893,13 +941,13 @@ routes:
893
941
  query_params:
894
942
  # WHERE clause filters
895
943
  - name:
896
- sql: name ILIKE '%{{name}}%'
944
+ sql: name ILIKE '%' || {{name}} || '%'
897
945
  - age_min:
898
946
  sql: age >= {{age_min}}
899
947
  - age_max:
900
948
  sql: age <= {{age_max}}
901
949
  - department:
902
- sql: department = '{{department}}'
950
+ sql: department = {{department}}
903
951
  # Sorting and pagination (sql_append)
904
952
  - sort:
905
953
  sql_append: ORDER BY {{sort}} {{sort_direction}}
@@ -1147,7 +1195,7 @@ Forwarded cookies are accessible in SQL queries using `{{cookies.cookie_name}}`:
1147
1195
  ```yaml
1148
1196
  routes:
1149
1197
  - route: user/profile
1150
- sql: SELECT * FROM [[users]] WHERE session_id = '{{cookies.session_id}}'
1198
+ sql: SELECT * FROM [[users]] WHERE session_id = {{cookies.session_id}}
1151
1199
  ```
1152
1200
 
1153
1201
  ### Injecting cookies from the server environment
@@ -1490,9 +1538,9 @@ routes:
1490
1538
  sql: SELECT * FROM [[events]]
1491
1539
  query_params:
1492
1540
  - date_from:
1493
- sql: event_date >= '{{date_from}}'
1541
+ sql: event_date >= {{date_from}}
1494
1542
  - event_type:
1495
- sql: type = '{{event_type}}'
1543
+ sql: type = {{event_type}}
1496
1544
  - user_id:
1497
1545
  sql: user_id = {{user_id}}
1498
1546
  required: true
@@ -1568,13 +1616,13 @@ tables:
1568
1616
 
1569
1617
  routes:
1570
1618
  - route: my-activity
1571
- sql: SELECT * FROM [[user_activity]] WHERE user_id = '{{cookies.user_id}}'
1619
+ sql: SELECT * FROM [[user_activity]] WHERE user_id = {{cookies.user_id}}
1572
1620
 
1573
1621
  - route: user-settings
1574
1622
  sql: |
1575
1623
  SELECT * FROM [[user_activity]]
1576
- WHERE user_id = '{{cookies.user_id}}'
1577
- AND session_token = '{{cookies.session_token}}'
1624
+ WHERE user_id = {{cookies.user_id}}
1625
+ AND session_token = {{cookies.session_token}}
1578
1626
  ```
1579
1627
 
1580
1628
  ---
@@ -1601,7 +1649,7 @@ Publishing a GitHub Release is what publishes to PyPI: `.github/workflows/publis
1601
1649
 
1602
1650
  ```bash
1603
1651
  # 0. Start from a clean, up-to-date main
1604
- export VERSION=0.8.1 # the NEW version, no leading "v"
1652
+ export VERSION=0.9.0 # the NEW version, no leading "v"
1605
1653
  git checkout main
1606
1654
  git pull origin main
1607
1655
  git status
@@ -1612,7 +1660,7 @@ git status
1612
1660
  pixi run -e dev pytest -q
1613
1661
 
1614
1662
  # 3. Commit, tag, push (the commit command adds the "v$VERSION: " prefix)
1615
- export COMMIT_MESSAGE='cross-schema unions, schema groups, source columns'
1663
+ export COMMIT_MESSAGE='bound SQL parameters (fix SQL injection) and startup config checks'
1616
1664
  git add -A
1617
1665
  git commit -m "v$VERSION: $COMMIT_MESSAGE"
1618
1666
  git tag "v$VERSION"
@@ -1624,17 +1672,17 @@ gh release create "v$VERSION" \
1624
1672
  --title "v$VERSION" \
1625
1673
  --notes "$(cat <<'EOF'
1626
1674
  * new features
1627
- - Cross-schema unions: `[[*.table]]` reads a table from every shared schema that has it, and `[[*!.table]]` does the same minus the current database/version's schema
1628
- - Named `schema_groups` in `databases/config.yaml`, used as `[[group.table]]` / `[[group!.table]]` (validated: known schemas only, no group/schema name clashes)
1629
- - Route `source_columns` adds where each union row came from (`schema`, `name`, `version`), with default names `schema_name`/`name`/`version` or your own; nothing is added by default
1630
- - `{{self.schema}}`, `{{self.name}}`, and `{{self.version}}` placeholders for the database/version being queried
1631
- - Together these support an "overlaps" route shared by every database/version (every detection overlapping a given one across all schemas, except that detection itself); shared query params such as `confidence`, `sort`, and `limit` apply to its rows
1675
+ - Request values are sent to DuckDB as bound parameters, separately from the SQL, so they can never run as SQL. Write variables without quotes (`UPPER(name) = UPPER({{name}})`); a string that is exactly one variable (`'{{name}}'`) still works, and patterns like `'%{{name}}%'` become `'%' || {{name}} || '%'`
1676
+ - Startup checks: API Dock refuses to start, naming the database, version, route and template, if a config has a quoted or commented variable, a template ending in a comment, a malformed route, an invalid shared `databases/config.yaml`, a `[[table]]` / `[[schema.table]]` / union reference that doesn't resolve, or invalid `source_columns`. Each version is checked as requests see it (shared routes/query_params, include/exclude, slugs)
1677
+ - Database and remote configs are read from the folder that holds the main config file
1632
1678
  * bug fixes
1633
- - A `!` union that removes every member returns no rows (with the right columns) instead of failing, and only the schemas a union actually reads get views and storage credentials
1679
+ - SQL injection: query/path/cookie values were pasted into SQL and only quoted when the SQL fragment happened to contain words like SELECT/WHERE/AND/OR, so filters like `confidence >= {{confidence}}` accepted raw SQL (`?confidence=0 OR 1=1`, `UNION SELECT ... read_csv(...)`)
1680
+ - `response:` bodies are filled in as plain text (they no longer get SQL quotes)
1681
+ - Importing api_dock (or running `api-dock --help`) no longer builds an app from the current directory's config; the default `app`/`fastapi_app`/`flask_app` are built on first use
1634
1682
  * cleanup / other improvements
1635
- - Added `SqlContext`; `build_sql_query()` / `build_sql_query_with_tables()` accept an optional `context`
1636
- - README: new "Querying across schemas" section with an overlaps example; example `databases/config.yaml` shows `schema_groups` and a union route
1637
- - Test suite grew from 198 to 227 tests (`test_schema_unions.py`, including a real DuckDB end-to-end overlaps test)
1683
+ - Removed unused `build_sql_query_legacy` / `_substitute_parameters`; `build_sql_query` returns `(sql, values)` and `build_sql_query_with_tables` returns `(sql, values, tables)`
1684
+ - New `sql_template_check` module and `check_table_references`; README sections "How values reach the database" and "Startup checks"
1685
+ - Test suite grew from 253 to 373 tests
1638
1686
  EOF
1639
1687
  )"
1640
1688
 
@@ -180,19 +180,33 @@ remotes:
180
180
  settings:
181
181
  add_trailing_slash: true # Auto-add trailing slash to paths (default: true)
182
182
  follow_protocol_downgrades: false # Allow HTTPS->HTTP redirects (default: false)
183
+ follow_redirects: true # Follow remote redirects (default: true)
183
184
  timeout: 10 # Upstream request timeout in seconds (default: 10)
185
+ base_path: /dock # Also serve the API under this prefix (default: none)
186
+ duckdb: # Options for database queries (default: none)
187
+ memory_limit: 700MB
188
+ threads: 2
189
+ max_concurrent_queries: 2
184
190
  ```
185
191
 
186
- ### HTTP behavior Settings
192
+ ### Settings
187
193
 
188
- The optional `settings` section controls HTTP behavior:
194
+ The optional `settings` section controls HTTP and query behavior:
189
195
 
190
196
  - **`add_trailing_slash`** (default: `true`): Automatically append a trailing slash to all proxied paths. This prevents 307/301 redirects from remote APIs that require trailing slashes (e.g., `/projects` → `/projects/`). Set to `false` to disable this behavior.
191
197
 
192
198
  - **`follow_protocol_downgrades`** (default: `false`): Control how HTTP redirects are handled. When `false` (recommended), HTTPS→HTTP redirects are blocked for security. When `true`, allows following redirects that downgrade from HTTPS to HTTP (not recommended for production).
193
199
 
200
+ - **`follow_redirects`** (default: `true`): Whether redirects from a remote are followed by API Dock (`true`) or passed through to the client with their `Location` header (`false`). Set it to `false` when a remote answers with redirects the client should follow itself, such as presigned S3 URLs for large files.
201
+
194
202
  - **`timeout`** (default: `10`): Upstream request timeout in seconds, applied to both the streaming and buffered proxy paths. Raise it for slow upstreams (e.g. large aggregation queries) that would otherwise return a 502 on timeout. Set to `null` or `false` to disable the timeout entirely (not recommended — a stalled upstream can hold the connection open indefinitely).
195
203
 
204
+ - **`base_path`** (default: none): An extra URL prefix the API is also served under, e.g. `/dock`. Use it when a proxy or CDN forwards a path on another domain without stripping it (say CloudFront routes `https://app.example.org/dock/*` to API Dock): `/dock/birdnet/latest/detections/` is then handled as `/birdnet/latest/detections/`. Paths without the prefix keep working, so direct calls and health checks are unaffected.
205
+
206
+ - **`duckdb`** (default: none): Options for the DuckDB connection each database query runs on. Every key except `max_concurrent_queries` is applied as `SET <key> = <value>`, so any [DuckDB setting](https://duckdb.org/docs/configuration/overview) works; the useful ones on small servers are `memory_limit` (DuckDB spills to disk or fails the query instead of exceeding it), `threads`, and `temp_directory`. `max_concurrent_queries` caps how many database queries run at once in the process; further queries wait their turn. Memory limits apply per query, so on a small instance set `memory_limit × max_concurrent_queries` below the instance's memory.
207
+
208
+ Database queries run in worker threads, so a slow query doesn't hold up other requests (including health checks on `/`).
209
+
196
210
  ### Catalog Endpoints (`expose`)
197
211
 
198
212
  The optional `expose` section adds read-only endpoints that list the models and versions of your configured databases, remotes, or both ("sources"). Listings are **opt-in** — with no `expose` key nothing is added.
@@ -372,6 +386,22 @@ Database configurations are stored in `api_dock_config/databases/` directory. Ea
372
386
  - **routes**: REST endpoints mapped to SQL queries
373
387
  - **schema** (optional): the shared schema (from `databases/config.yaml`) this config's `[[table]]` references fall back to. See [Shared Tables and Schemas](#shared-tables-and-schemas-databasesconfigyaml)
374
388
 
389
+ ### Startup checks
390
+
391
+ When API Dock starts, it checks every database and version listed in the main config, from config files and from the shared `databases/config.yaml` (`slugs`), exactly as requests will see them: merged with the main config and with the shared `routes`/`query_params` that apply to that version (after `include`/`exclude`). If anything is wrong it refuses to start and names the database, version, route and template, for example:
392
+
393
+ ```
394
+ ValueError: Database 'owl' version '4.0', route '/detections/{{id}}/overlaps': sql: Table 'all_modelz.detections' not found in database configuration
395
+ ```
396
+
397
+ It checks that:
398
+ - the shared `databases/config.yaml` is valid (`slugs`, `schema_groups`, and the types of its sections)
399
+ - every route and query param has a valid shape
400
+ - no `{{variable}}` is inside a quoted string (other than a string that is exactly one variable, `'{{x}}'`), a quoted identifier, or a SQL comment, and no template ends inside a comment (see [How values reach the database](#how-values-reach-the-database))
401
+ - every `[[table]]`, `[[schema.table]]`, `[[*.table]]` and `[[group.table]]` reference resolves, unions are only used after `FROM`/`JOIN`, and `source_columns` is valid
402
+
403
+ Database and remote config files are read from the folder that holds the main config file.
404
+
375
405
  ### Syntax
376
406
 
377
407
  As with the remote-apis, the routes to databases use double-curly-brackets {{}} to reference url variable placeholders.
@@ -674,6 +704,24 @@ Rules:
674
704
  ## URL Query Parameters
675
705
 
676
706
 
707
+ ### How values reach the database
708
+
709
+ api_dock does not paste request values into SQL. Each `{{variable}}` in `sql`, `multivalue_sql`, conditional `sql` and `queries:` becomes a placeholder, and its value (from the path, query string, a `default`, or a cookie) is sent to DuckDB separately. DuckDB converts the value to the column's type, so number, date and boolean filters work as written. A value that is not a valid number, date or boolean for its column, such as `?age=25 OR true`, is rejected with an error instead of being run as SQL.
710
+
711
+ Because the value is sent separately, **write variables without quotes**:
712
+
713
+ | Write | Not |
714
+ |---|---|
715
+ | `department = {{department}}` | `department = '{{department}}'` |
716
+ | `UPPER(name) = UPPER({{name}})` | `UPPER(name) = UPPER('{{name}}')` |
717
+ | `name ILIKE '%' \|\| {{name}} \|\| '%'` | `name ILIKE '%{{name}}%'` |
718
+
719
+ For compatibility with 0.8.x and earlier configs, a string that is exactly one variable (`'{{department}}'`) is read as `{{department}}`. Any other quoted variable, like `'%{{name}}%'`, would be read as literal text, so api_dock refuses to start and prints the route with the fixed form (rewrite it with `||` as in the last row above). A variable can't be used as a column name in double quotes (`"{{column}}"`) either. A variable inside a SQL comment (`-- {{x}}` or `/* {{x}} */`) would be sent with nothing in the query to use it, so api_dock refuses to start and asks you to remove it. A template also can't end inside a comment, because api_dock adds WHERE conditions and `sql_append` clauses after it on the same line: put the comment on its own line or use `/* */`.
720
+
721
+ `sql_append` works differently. Its values are column names, `ASC`/`DESC` or numbers, which a database can't accept as separate values, so they are written into the SQL text. Each one must contain only letters, digits, spaces and `_ . , ( ) -`, and must not contain `--`. The same applies to the `sql_append` of a [conditional SQL selection](#conditional-sql-selection) branch.
722
+
723
+ The `# SQL:` comments in the examples below show values in place so the queries are easier to read.
724
+
677
725
  ### Basic Filtering with `sql`
678
726
 
679
727
  Use `sql` to add WHERE clause fragments. Each fragment is joined with `AND`. Optional by default — only included if the parameter is in the URL.
@@ -686,7 +734,7 @@ routes:
686
734
  - age:
687
735
  sql: age = {{age}} # optional — only if ?age= provided
688
736
  - department:
689
- sql: department = '{{department}}'
737
+ sql: department = {{department}}
690
738
  - height:
691
739
  sql: height < {{height}}
692
740
  default: 200 # always included (uses 200 if not in URL)
@@ -702,7 +750,7 @@ GET /db/users
702
750
 
703
751
  ### Repeated Parameters with `multivalue_sql`
704
752
 
705
- A query parameter key can appear more than once in the URL (e.g. `?recording_id=1&recording_id=4`). Add a `multivalue_sql` template alongside `sql` to handle this: when **more than one** value is passed for the key, `multivalue_sql` is used instead of `sql`, and `{{param}}` expands to a parenthesized, quote-escaped SQL value list suitable for an `IN` clause.
753
+ A query parameter key can appear more than once in the URL (e.g. `?recording_id=1&recording_id=4`). Add a `multivalue_sql` template alongside `sql` to handle this: when **more than one** value is passed for the key, `multivalue_sql` is used instead of `sql`, and `{{param}}` expands to a parenthesized list with one value per URL entry, suitable for an `IN` clause. Each value is sent to the database separately, like any other variable.
706
754
 
707
755
  Behavior is unchanged when `multivalue_sql` is absent, and when only a single value is passed the normal `sql` template is used.
708
756
 
@@ -715,7 +763,7 @@ routes:
715
763
  sql: "[[detections]].recording_id = {{recording_id}}" # single value
716
764
  multivalue_sql: "[[detections]].recording_id IN {{recording_id}}" # 2+ values
717
765
  - scientific_name:
718
- sql: "[[detections]].scientific_name = '{{scientific_name}}'"
766
+ sql: "[[detections]].scientific_name = {{scientific_name}}"
719
767
  ```
720
768
 
721
769
  ```bash
@@ -741,7 +789,7 @@ routes:
741
789
  query_params:
742
790
  # WHERE clause params
743
791
  - department:
744
- sql: department = '{{department}}'
792
+ sql: department = {{department}}
745
793
  # Post-WHERE params
746
794
  - sort:
747
795
  sql_append: ORDER BY {{sort}} {{sort_direction}}
@@ -854,13 +902,13 @@ routes:
854
902
  query_params:
855
903
  # WHERE clause filters
856
904
  - name:
857
- sql: name ILIKE '%{{name}}%'
905
+ sql: name ILIKE '%' || {{name}} || '%'
858
906
  - age_min:
859
907
  sql: age >= {{age_min}}
860
908
  - age_max:
861
909
  sql: age <= {{age_max}}
862
910
  - department:
863
- sql: department = '{{department}}'
911
+ sql: department = {{department}}
864
912
  # Sorting and pagination (sql_append)
865
913
  - sort:
866
914
  sql_append: ORDER BY {{sort}} {{sort_direction}}
@@ -1108,7 +1156,7 @@ Forwarded cookies are accessible in SQL queries using `{{cookies.cookie_name}}`:
1108
1156
  ```yaml
1109
1157
  routes:
1110
1158
  - route: user/profile
1111
- sql: SELECT * FROM [[users]] WHERE session_id = '{{cookies.session_id}}'
1159
+ sql: SELECT * FROM [[users]] WHERE session_id = {{cookies.session_id}}
1112
1160
  ```
1113
1161
 
1114
1162
  ### Injecting cookies from the server environment
@@ -1451,9 +1499,9 @@ routes:
1451
1499
  sql: SELECT * FROM [[events]]
1452
1500
  query_params:
1453
1501
  - date_from:
1454
- sql: event_date >= '{{date_from}}'
1502
+ sql: event_date >= {{date_from}}
1455
1503
  - event_type:
1456
- sql: type = '{{event_type}}'
1504
+ sql: type = {{event_type}}
1457
1505
  - user_id:
1458
1506
  sql: user_id = {{user_id}}
1459
1507
  required: true
@@ -1529,13 +1577,13 @@ tables:
1529
1577
 
1530
1578
  routes:
1531
1579
  - route: my-activity
1532
- sql: SELECT * FROM [[user_activity]] WHERE user_id = '{{cookies.user_id}}'
1580
+ sql: SELECT * FROM [[user_activity]] WHERE user_id = {{cookies.user_id}}
1533
1581
 
1534
1582
  - route: user-settings
1535
1583
  sql: |
1536
1584
  SELECT * FROM [[user_activity]]
1537
- WHERE user_id = '{{cookies.user_id}}'
1538
- AND session_token = '{{cookies.session_token}}'
1585
+ WHERE user_id = {{cookies.user_id}}
1586
+ AND session_token = {{cookies.session_token}}
1539
1587
  ```
1540
1588
 
1541
1589
  ---
@@ -1562,7 +1610,7 @@ Publishing a GitHub Release is what publishes to PyPI: `.github/workflows/publis
1562
1610
 
1563
1611
  ```bash
1564
1612
  # 0. Start from a clean, up-to-date main
1565
- export VERSION=0.8.1 # the NEW version, no leading "v"
1613
+ export VERSION=0.9.0 # the NEW version, no leading "v"
1566
1614
  git checkout main
1567
1615
  git pull origin main
1568
1616
  git status
@@ -1573,7 +1621,7 @@ git status
1573
1621
  pixi run -e dev pytest -q
1574
1622
 
1575
1623
  # 3. Commit, tag, push (the commit command adds the "v$VERSION: " prefix)
1576
- export COMMIT_MESSAGE='cross-schema unions, schema groups, source columns'
1624
+ export COMMIT_MESSAGE='bound SQL parameters (fix SQL injection) and startup config checks'
1577
1625
  git add -A
1578
1626
  git commit -m "v$VERSION: $COMMIT_MESSAGE"
1579
1627
  git tag "v$VERSION"
@@ -1585,17 +1633,17 @@ gh release create "v$VERSION" \
1585
1633
  --title "v$VERSION" \
1586
1634
  --notes "$(cat <<'EOF'
1587
1635
  * new features
1588
- - Cross-schema unions: `[[*.table]]` reads a table from every shared schema that has it, and `[[*!.table]]` does the same minus the current database/version's schema
1589
- - Named `schema_groups` in `databases/config.yaml`, used as `[[group.table]]` / `[[group!.table]]` (validated: known schemas only, no group/schema name clashes)
1590
- - Route `source_columns` adds where each union row came from (`schema`, `name`, `version`), with default names `schema_name`/`name`/`version` or your own; nothing is added by default
1591
- - `{{self.schema}}`, `{{self.name}}`, and `{{self.version}}` placeholders for the database/version being queried
1592
- - Together these support an "overlaps" route shared by every database/version (every detection overlapping a given one across all schemas, except that detection itself); shared query params such as `confidence`, `sort`, and `limit` apply to its rows
1636
+ - Request values are sent to DuckDB as bound parameters, separately from the SQL, so they can never run as SQL. Write variables without quotes (`UPPER(name) = UPPER({{name}})`); a string that is exactly one variable (`'{{name}}'`) still works, and patterns like `'%{{name}}%'` become `'%' || {{name}} || '%'`
1637
+ - Startup checks: API Dock refuses to start, naming the database, version, route and template, if a config has a quoted or commented variable, a template ending in a comment, a malformed route, an invalid shared `databases/config.yaml`, a `[[table]]` / `[[schema.table]]` / union reference that doesn't resolve, or invalid `source_columns`. Each version is checked as requests see it (shared routes/query_params, include/exclude, slugs)
1638
+ - Database and remote configs are read from the folder that holds the main config file
1593
1639
  * bug fixes
1594
- - A `!` union that removes every member returns no rows (with the right columns) instead of failing, and only the schemas a union actually reads get views and storage credentials
1640
+ - SQL injection: query/path/cookie values were pasted into SQL and only quoted when the SQL fragment happened to contain words like SELECT/WHERE/AND/OR, so filters like `confidence >= {{confidence}}` accepted raw SQL (`?confidence=0 OR 1=1`, `UNION SELECT ... read_csv(...)`)
1641
+ - `response:` bodies are filled in as plain text (they no longer get SQL quotes)
1642
+ - Importing api_dock (or running `api-dock --help`) no longer builds an app from the current directory's config; the default `app`/`fastapi_app`/`flask_app` are built on first use
1595
1643
  * cleanup / other improvements
1596
- - Added `SqlContext`; `build_sql_query()` / `build_sql_query_with_tables()` accept an optional `context`
1597
- - README: new "Querying across schemas" section with an overlaps example; example `databases/config.yaml` shows `schema_groups` and a union route
1598
- - Test suite grew from 198 to 227 tests (`test_schema_unions.py`, including a real DuckDB end-to-end overlaps test)
1644
+ - Removed unused `build_sql_query_legacy` / `_substitute_parameters`; `build_sql_query` returns `(sql, values)` and `build_sql_query_with_tables` returns `(sql, values, tables)`
1645
+ - New `sql_template_check` module and `check_table_references`; README sections "How values reach the database" and "Startup checks"
1646
+ - Test suite grew from 253 to 373 tests
1599
1647
  EOF
1600
1648
  )"
1601
1649
 
@@ -0,0 +1,72 @@
1
+ """
2
+
3
+ API Dock Core Module
4
+
5
+ Core functionality for API Dock wrapper.
6
+
7
+ License: BSD 3-Clause
8
+
9
+ """
10
+ #
11
+ # IMPORTS
12
+ #
13
+ from typing import Any
14
+
15
+ from api_dock.config import (
16
+ filter_cookies_by_config,
17
+ find_remote_config,
18
+ get_authentication_config,
19
+ get_cookies_config,
20
+ get_remote_names,
21
+ load_main_config,
22
+ merge_inherited_config,
23
+ validate_authentication_config,
24
+ validate_cookies_config,
25
+ )
26
+ from api_dock.fast_api import create_app as create_fastapi_app
27
+ from api_dock.flask_api import create_app as create_flask_app
28
+ from api_dock.route_mapper import RouteMapper
29
+
30
+
31
+ #
32
+ # CONSTANTS
33
+ #
34
+ # For backward compatibility, default to FastAPI
35
+ create_app = create_fastapi_app
36
+
37
+ __all__ = [
38
+ "app", "create_app",
39
+ "fastapi_app", "create_fastapi_app",
40
+ "flask_app", "create_flask_app",
41
+ "load_main_config", "find_remote_config", "get_remote_names",
42
+ "RouteMapper"
43
+ ]
44
+
45
+
46
+ #
47
+ # PUBLIC
48
+ #
49
+ def __getattr__(name: str) -> Any:
50
+ """Return the default apps on first use instead of building them at import.
51
+
52
+ ``api_dock.app`` / ``api_dock.fastapi_app`` (FastAPI) and
53
+ ``api_dock.flask_app`` are built from the default config the first time
54
+ they are accessed (see ``fast_api.__getattr__``), so importing api_dock no
55
+ longer requires a valid config in the current directory.
56
+
57
+ Args:
58
+ name: The attribute being looked up.
59
+
60
+ Returns:
61
+ The requested default app.
62
+
63
+ Raises:
64
+ AttributeError: For any other missing attribute.
65
+ """
66
+ if name in ("app", "fastapi_app"):
67
+ from api_dock import fast_api
68
+ return fast_api.app
69
+ if name == "flask_app":
70
+ from api_dock import flask_api
71
+ return flask_api.app
72
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -553,7 +553,7 @@ def resolve_latest_version(versions: List[str]) -> Optional[str]:
553
553
  return sorted_versions[0]
554
554
 
555
555
 
556
- def is_route_allowed(route: str, config: Dict[str, Any], remote_name: Optional[str] = None, version: Optional[str] = None, method: Optional[str] = None) -> bool:
556
+ def is_route_allowed(route: str, config: Dict[str, Any], remote_name: Optional[str] = None, version: Optional[str] = None, method: Optional[str] = None, config_dir: Optional[str] = None) -> bool:
557
557
  """Check if a route is allowed based on configuration restrictions.
558
558
 
559
559
  Args:
@@ -562,6 +562,8 @@ def is_route_allowed(route: str, config: Dict[str, Any], remote_name: Optional[s
562
562
  remote_name: Name of the remote API (for remote-specific restrictions).
563
563
  version: Version string for versioned remotes.
564
564
  method: HTTP method (e.g., "GET", "POST", "DELETE").
565
+ config_dir: Directory holding the remote config files. If omitted, it
566
+ is guessed from a few common locations.
565
567
 
566
568
  Returns:
567
569
  True if route is allowed, False otherwise.
@@ -581,9 +583,8 @@ def is_route_allowed(route: str, config: Dict[str, Any], remote_name: Optional[s
581
583
  try:
582
584
  # Try to infer config_dir by checking which directory exists
583
585
  # This is a heuristic to support both default and custom config locations
584
- config_dir = None
585
586
  remotes = config.get("remotes", [])
586
- if remotes:
587
+ if config_dir is None and remotes:
587
588
  first_remote = remotes[0] if isinstance(remotes[0], str) else remotes[0].get("name") if isinstance(remotes[0], dict) else None
588
589
  if first_remote:
589
590
  # Try common locations