api-dock 0.8.2__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.2 → api_dock-0.9.0}/PKG-INFO +58 -21
  2. {api_dock-0.8.2 → api_dock-0.9.0}/README.md +57 -20
  3. api_dock-0.9.0/api_dock/__init__.py +72 -0
  4. {api_dock-0.8.2 → api_dock-0.9.0}/api_dock/config.py +4 -3
  5. {api_dock-0.8.2 → api_dock-0.9.0}/api_dock/database_config.py +260 -58
  6. {api_dock-0.8.2 → api_dock-0.9.0}/api_dock/example_api_dock_config/databases/example_db.yaml +4 -4
  7. {api_dock-0.8.2 → api_dock-0.9.0}/api_dock/fast_api.py +36 -2
  8. {api_dock-0.8.2 → api_dock-0.9.0}/api_dock/flask_api.py +35 -2
  9. {api_dock-0.8.2 → api_dock-0.9.0}/api_dock/listings.py +28 -11
  10. {api_dock-0.8.2 → api_dock-0.9.0}/api_dock/route_mapper.py +114 -21
  11. {api_dock-0.8.2 → api_dock-0.9.0}/api_dock/sql_builder.py +325 -214
  12. api_dock-0.9.0/api_dock/sql_template_check.py +317 -0
  13. {api_dock-0.8.2 → api_dock-0.9.0}/api_dock.egg-info/PKG-INFO +58 -21
  14. {api_dock-0.8.2 → api_dock-0.9.0}/api_dock.egg-info/SOURCES.txt +5 -0
  15. {api_dock-0.8.2 → api_dock-0.9.0}/pyproject.toml +1 -1
  16. api_dock-0.9.0/tests/test_bound_parameters.py +260 -0
  17. api_dock-0.9.0/tests/test_database_requests.py +335 -0
  18. {api_dock-0.8.2 → api_dock-0.9.0}/tests/test_listings.py +75 -0
  19. {api_dock-0.8.2 → api_dock-0.9.0}/tests/test_proxy_pipeline.py +5 -0
  20. {api_dock-0.8.2 → api_dock-0.9.0}/tests/test_runtime_settings.py +1 -1
  21. {api_dock-0.8.2 → api_dock-0.9.0}/tests/test_schema_unions.py +7 -6
  22. {api_dock-0.8.2 → api_dock-0.9.0}/tests/test_shared_database_config.py +9 -10
  23. {api_dock-0.8.2 → api_dock-0.9.0}/tests/test_sql_builder.py +17 -14
  24. {api_dock-0.8.2 → api_dock-0.9.0}/tests/test_sql_selector.py +10 -5
  25. api_dock-0.9.0/tests/test_sql_template_check.py +147 -0
  26. api_dock-0.9.0/tests/test_startup_check.py +449 -0
  27. api_dock-0.8.2/api_dock/__init__.py +0 -26
  28. {api_dock-0.8.2 → api_dock-0.9.0}/LICENSE.md +0 -0
  29. {api_dock-0.8.2 → api_dock-0.9.0}/api_dock/auth.py +0 -0
  30. {api_dock-0.8.2 → api_dock-0.9.0}/api_dock/cli.py +0 -0
  31. {api_dock-0.8.2 → api_dock-0.9.0}/api_dock/config_discovery.py +0 -0
  32. {api_dock-0.8.2 → api_dock-0.9.0}/api_dock/encryption.py +0 -0
  33. {api_dock-0.8.2 → api_dock-0.9.0}/api_dock/example_api_dock_config/config.yaml +0 -0
  34. {api_dock-0.8.2 → api_dock-0.9.0}/api_dock/example_api_dock_config/databases/config.yaml +0 -0
  35. {api_dock-0.8.2 → api_dock-0.9.0}/api_dock/example_api_dock_config/remotes/example_remote.yaml +0 -0
  36. {api_dock-0.8.2 → api_dock-0.9.0}/api_dock/storage_auth.py +0 -0
  37. {api_dock-0.8.2 → api_dock-0.9.0}/api_dock/types.py +0 -0
  38. {api_dock-0.8.2 → api_dock-0.9.0}/api_dock.egg-info/dependency_links.txt +0 -0
  39. {api_dock-0.8.2 → api_dock-0.9.0}/api_dock.egg-info/entry_points.txt +0 -0
  40. {api_dock-0.8.2 → api_dock-0.9.0}/api_dock.egg-info/requires.txt +0 -0
  41. {api_dock-0.8.2 → api_dock-0.9.0}/api_dock.egg-info/top_level.txt +0 -0
  42. {api_dock-0.8.2 → api_dock-0.9.0}/setup.cfg +0 -0
  43. {api_dock-0.8.2 → api_dock-0.9.0}/tests/test_inject_cookies.py +0 -0
  44. {api_dock-0.8.2 → 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.2
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
@@ -425,6 +425,22 @@ Database configurations are stored in `api_dock_config/databases/` directory. Ea
425
425
  - **routes**: REST endpoints mapped to SQL queries
426
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)
427
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
+
428
444
  ### Syntax
429
445
 
430
446
  As with the remote-apis, the routes to databases use double-curly-brackets {{}} to reference url variable placeholders.
@@ -727,6 +743,24 @@ Rules:
727
743
  ## URL Query Parameters
728
744
 
729
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
+
730
764
  ### Basic Filtering with `sql`
731
765
 
732
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.
@@ -739,7 +773,7 @@ routes:
739
773
  - age:
740
774
  sql: age = {{age}} # optional — only if ?age= provided
741
775
  - department:
742
- sql: department = '{{department}}'
776
+ sql: department = {{department}}
743
777
  - height:
744
778
  sql: height < {{height}}
745
779
  default: 200 # always included (uses 200 if not in URL)
@@ -755,7 +789,7 @@ GET /db/users
755
789
 
756
790
  ### Repeated Parameters with `multivalue_sql`
757
791
 
758
- 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.
759
793
 
760
794
  Behavior is unchanged when `multivalue_sql` is absent, and when only a single value is passed the normal `sql` template is used.
761
795
 
@@ -768,7 +802,7 @@ routes:
768
802
  sql: "[[detections]].recording_id = {{recording_id}}" # single value
769
803
  multivalue_sql: "[[detections]].recording_id IN {{recording_id}}" # 2+ values
770
804
  - scientific_name:
771
- sql: "[[detections]].scientific_name = '{{scientific_name}}'"
805
+ sql: "[[detections]].scientific_name = {{scientific_name}}"
772
806
  ```
773
807
 
774
808
  ```bash
@@ -794,7 +828,7 @@ routes:
794
828
  query_params:
795
829
  # WHERE clause params
796
830
  - department:
797
- sql: department = '{{department}}'
831
+ sql: department = {{department}}
798
832
  # Post-WHERE params
799
833
  - sort:
800
834
  sql_append: ORDER BY {{sort}} {{sort_direction}}
@@ -907,13 +941,13 @@ routes:
907
941
  query_params:
908
942
  # WHERE clause filters
909
943
  - name:
910
- sql: name ILIKE '%{{name}}%'
944
+ sql: name ILIKE '%' || {{name}} || '%'
911
945
  - age_min:
912
946
  sql: age >= {{age_min}}
913
947
  - age_max:
914
948
  sql: age <= {{age_max}}
915
949
  - department:
916
- sql: department = '{{department}}'
950
+ sql: department = {{department}}
917
951
  # Sorting and pagination (sql_append)
918
952
  - sort:
919
953
  sql_append: ORDER BY {{sort}} {{sort_direction}}
@@ -1161,7 +1195,7 @@ Forwarded cookies are accessible in SQL queries using `{{cookies.cookie_name}}`:
1161
1195
  ```yaml
1162
1196
  routes:
1163
1197
  - route: user/profile
1164
- sql: SELECT * FROM [[users]] WHERE session_id = '{{cookies.session_id}}'
1198
+ sql: SELECT * FROM [[users]] WHERE session_id = {{cookies.session_id}}
1165
1199
  ```
1166
1200
 
1167
1201
  ### Injecting cookies from the server environment
@@ -1504,9 +1538,9 @@ routes:
1504
1538
  sql: SELECT * FROM [[events]]
1505
1539
  query_params:
1506
1540
  - date_from:
1507
- sql: event_date >= '{{date_from}}'
1541
+ sql: event_date >= {{date_from}}
1508
1542
  - event_type:
1509
- sql: type = '{{event_type}}'
1543
+ sql: type = {{event_type}}
1510
1544
  - user_id:
1511
1545
  sql: user_id = {{user_id}}
1512
1546
  required: true
@@ -1582,13 +1616,13 @@ tables:
1582
1616
 
1583
1617
  routes:
1584
1618
  - route: my-activity
1585
- sql: SELECT * FROM [[user_activity]] WHERE user_id = '{{cookies.user_id}}'
1619
+ sql: SELECT * FROM [[user_activity]] WHERE user_id = {{cookies.user_id}}
1586
1620
 
1587
1621
  - route: user-settings
1588
1622
  sql: |
1589
1623
  SELECT * FROM [[user_activity]]
1590
- WHERE user_id = '{{cookies.user_id}}'
1591
- AND session_token = '{{cookies.session_token}}'
1624
+ WHERE user_id = {{cookies.user_id}}
1625
+ AND session_token = {{cookies.session_token}}
1592
1626
  ```
1593
1627
 
1594
1628
  ---
@@ -1615,7 +1649,7 @@ Publishing a GitHub Release is what publishes to PyPI: `.github/workflows/publis
1615
1649
 
1616
1650
  ```bash
1617
1651
  # 0. Start from a clean, up-to-date main
1618
- export VERSION=0.8.2 # the NEW version, no leading "v"
1652
+ export VERSION=0.9.0 # the NEW version, no leading "v"
1619
1653
  git checkout main
1620
1654
  git pull origin main
1621
1655
  git status
@@ -1626,7 +1660,7 @@ git status
1626
1660
  pixi run -e dev pytest -q
1627
1661
 
1628
1662
  # 3. Commit, tag, push (the commit command adds the "v$VERSION: " prefix)
1629
- export COMMIT_MESSAGE='query worker threads, duckdb settings, base_path, proxy host fix'
1663
+ export COMMIT_MESSAGE='bound SQL parameters (fix SQL injection) and startup config checks'
1630
1664
  git add -A
1631
1665
  git commit -m "v$VERSION: $COMMIT_MESSAGE"
1632
1666
  git tag "v$VERSION"
@@ -1638,14 +1672,17 @@ gh release create "v$VERSION" \
1638
1672
  --title "v$VERSION" \
1639
1673
  --notes "$(cat <<'EOF'
1640
1674
  * new features
1641
- - `settings.duckdb`: DuckDB options applied to every database query (`memory_limit`, `threads`, `temp_directory`, or any other DuckDB setting), plus `max_concurrent_queries` to cap how many queries run at once
1642
- - `settings.base_path`: also serve the API under a URL prefix (e.g. `/dock`), for a CDN/proxy path such as CloudFront routing `https://app.example.org/dock/*` to API Dock; unprefixed paths keep working
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
1643
1678
  * bug fixes
1644
- - Database queries now run in worker threads, so one slow query no longer stalls every other request (including health checks on `/`)
1645
- - Remote proxying no longer forwards the client's `Host` (or hop-by-hop) headers upstream; upstream redirects (e.g. trailing-slash 307s) no longer point back at the proxy with the wrong host and path
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
1646
1682
  * cleanup / other improvements
1647
- - README: document `base_path`, `duckdb`, and the previously undocumented `follow_redirects` setting
1648
- - Test suite grew from 227 to 253 tests (`test_runtime_settings.py`)
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
1649
1686
  EOF
1650
1687
  )"
1651
1688
 
@@ -386,6 +386,22 @@ Database configurations are stored in `api_dock_config/databases/` directory. Ea
386
386
  - **routes**: REST endpoints mapped to SQL queries
387
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)
388
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
+
389
405
  ### Syntax
390
406
 
391
407
  As with the remote-apis, the routes to databases use double-curly-brackets {{}} to reference url variable placeholders.
@@ -688,6 +704,24 @@ Rules:
688
704
  ## URL Query Parameters
689
705
 
690
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
+
691
725
  ### Basic Filtering with `sql`
692
726
 
693
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.
@@ -700,7 +734,7 @@ routes:
700
734
  - age:
701
735
  sql: age = {{age}} # optional — only if ?age= provided
702
736
  - department:
703
- sql: department = '{{department}}'
737
+ sql: department = {{department}}
704
738
  - height:
705
739
  sql: height < {{height}}
706
740
  default: 200 # always included (uses 200 if not in URL)
@@ -716,7 +750,7 @@ GET /db/users
716
750
 
717
751
  ### Repeated Parameters with `multivalue_sql`
718
752
 
719
- 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.
720
754
 
721
755
  Behavior is unchanged when `multivalue_sql` is absent, and when only a single value is passed the normal `sql` template is used.
722
756
 
@@ -729,7 +763,7 @@ routes:
729
763
  sql: "[[detections]].recording_id = {{recording_id}}" # single value
730
764
  multivalue_sql: "[[detections]].recording_id IN {{recording_id}}" # 2+ values
731
765
  - scientific_name:
732
- sql: "[[detections]].scientific_name = '{{scientific_name}}'"
766
+ sql: "[[detections]].scientific_name = {{scientific_name}}"
733
767
  ```
734
768
 
735
769
  ```bash
@@ -755,7 +789,7 @@ routes:
755
789
  query_params:
756
790
  # WHERE clause params
757
791
  - department:
758
- sql: department = '{{department}}'
792
+ sql: department = {{department}}
759
793
  # Post-WHERE params
760
794
  - sort:
761
795
  sql_append: ORDER BY {{sort}} {{sort_direction}}
@@ -868,13 +902,13 @@ routes:
868
902
  query_params:
869
903
  # WHERE clause filters
870
904
  - name:
871
- sql: name ILIKE '%{{name}}%'
905
+ sql: name ILIKE '%' || {{name}} || '%'
872
906
  - age_min:
873
907
  sql: age >= {{age_min}}
874
908
  - age_max:
875
909
  sql: age <= {{age_max}}
876
910
  - department:
877
- sql: department = '{{department}}'
911
+ sql: department = {{department}}
878
912
  # Sorting and pagination (sql_append)
879
913
  - sort:
880
914
  sql_append: ORDER BY {{sort}} {{sort_direction}}
@@ -1122,7 +1156,7 @@ Forwarded cookies are accessible in SQL queries using `{{cookies.cookie_name}}`:
1122
1156
  ```yaml
1123
1157
  routes:
1124
1158
  - route: user/profile
1125
- sql: SELECT * FROM [[users]] WHERE session_id = '{{cookies.session_id}}'
1159
+ sql: SELECT * FROM [[users]] WHERE session_id = {{cookies.session_id}}
1126
1160
  ```
1127
1161
 
1128
1162
  ### Injecting cookies from the server environment
@@ -1465,9 +1499,9 @@ routes:
1465
1499
  sql: SELECT * FROM [[events]]
1466
1500
  query_params:
1467
1501
  - date_from:
1468
- sql: event_date >= '{{date_from}}'
1502
+ sql: event_date >= {{date_from}}
1469
1503
  - event_type:
1470
- sql: type = '{{event_type}}'
1504
+ sql: type = {{event_type}}
1471
1505
  - user_id:
1472
1506
  sql: user_id = {{user_id}}
1473
1507
  required: true
@@ -1543,13 +1577,13 @@ tables:
1543
1577
 
1544
1578
  routes:
1545
1579
  - route: my-activity
1546
- sql: SELECT * FROM [[user_activity]] WHERE user_id = '{{cookies.user_id}}'
1580
+ sql: SELECT * FROM [[user_activity]] WHERE user_id = {{cookies.user_id}}
1547
1581
 
1548
1582
  - route: user-settings
1549
1583
  sql: |
1550
1584
  SELECT * FROM [[user_activity]]
1551
- WHERE user_id = '{{cookies.user_id}}'
1552
- AND session_token = '{{cookies.session_token}}'
1585
+ WHERE user_id = {{cookies.user_id}}
1586
+ AND session_token = {{cookies.session_token}}
1553
1587
  ```
1554
1588
 
1555
1589
  ---
@@ -1576,7 +1610,7 @@ Publishing a GitHub Release is what publishes to PyPI: `.github/workflows/publis
1576
1610
 
1577
1611
  ```bash
1578
1612
  # 0. Start from a clean, up-to-date main
1579
- export VERSION=0.8.2 # the NEW version, no leading "v"
1613
+ export VERSION=0.9.0 # the NEW version, no leading "v"
1580
1614
  git checkout main
1581
1615
  git pull origin main
1582
1616
  git status
@@ -1587,7 +1621,7 @@ git status
1587
1621
  pixi run -e dev pytest -q
1588
1622
 
1589
1623
  # 3. Commit, tag, push (the commit command adds the "v$VERSION: " prefix)
1590
- export COMMIT_MESSAGE='query worker threads, duckdb settings, base_path, proxy host fix'
1624
+ export COMMIT_MESSAGE='bound SQL parameters (fix SQL injection) and startup config checks'
1591
1625
  git add -A
1592
1626
  git commit -m "v$VERSION: $COMMIT_MESSAGE"
1593
1627
  git tag "v$VERSION"
@@ -1599,14 +1633,17 @@ gh release create "v$VERSION" \
1599
1633
  --title "v$VERSION" \
1600
1634
  --notes "$(cat <<'EOF'
1601
1635
  * new features
1602
- - `settings.duckdb`: DuckDB options applied to every database query (`memory_limit`, `threads`, `temp_directory`, or any other DuckDB setting), plus `max_concurrent_queries` to cap how many queries run at once
1603
- - `settings.base_path`: also serve the API under a URL prefix (e.g. `/dock`), for a CDN/proxy path such as CloudFront routing `https://app.example.org/dock/*` to API Dock; unprefixed paths keep working
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
1604
1639
  * bug fixes
1605
- - Database queries now run in worker threads, so one slow query no longer stalls every other request (including health checks on `/`)
1606
- - Remote proxying no longer forwards the client's `Host` (or hop-by-hop) headers upstream; upstream redirects (e.g. trailing-slash 307s) no longer point back at the proxy with the wrong host and path
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
1607
1643
  * cleanup / other improvements
1608
- - README: document `base_path`, `duckdb`, and the previously undocumented `follow_redirects` setting
1609
- - Test suite grew from 227 to 253 tests (`test_runtime_settings.py`)
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
1610
1647
  EOF
1611
1648
  )"
1612
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