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.
- {api_dock-0.8.2 → api_dock-0.9.0}/PKG-INFO +58 -21
- {api_dock-0.8.2 → api_dock-0.9.0}/README.md +57 -20
- api_dock-0.9.0/api_dock/__init__.py +72 -0
- {api_dock-0.8.2 → api_dock-0.9.0}/api_dock/config.py +4 -3
- {api_dock-0.8.2 → api_dock-0.9.0}/api_dock/database_config.py +260 -58
- {api_dock-0.8.2 → api_dock-0.9.0}/api_dock/example_api_dock_config/databases/example_db.yaml +4 -4
- {api_dock-0.8.2 → api_dock-0.9.0}/api_dock/fast_api.py +36 -2
- {api_dock-0.8.2 → api_dock-0.9.0}/api_dock/flask_api.py +35 -2
- {api_dock-0.8.2 → api_dock-0.9.0}/api_dock/listings.py +28 -11
- {api_dock-0.8.2 → api_dock-0.9.0}/api_dock/route_mapper.py +114 -21
- {api_dock-0.8.2 → api_dock-0.9.0}/api_dock/sql_builder.py +325 -214
- api_dock-0.9.0/api_dock/sql_template_check.py +317 -0
- {api_dock-0.8.2 → api_dock-0.9.0}/api_dock.egg-info/PKG-INFO +58 -21
- {api_dock-0.8.2 → api_dock-0.9.0}/api_dock.egg-info/SOURCES.txt +5 -0
- {api_dock-0.8.2 → api_dock-0.9.0}/pyproject.toml +1 -1
- api_dock-0.9.0/tests/test_bound_parameters.py +260 -0
- api_dock-0.9.0/tests/test_database_requests.py +335 -0
- {api_dock-0.8.2 → api_dock-0.9.0}/tests/test_listings.py +75 -0
- {api_dock-0.8.2 → api_dock-0.9.0}/tests/test_proxy_pipeline.py +5 -0
- {api_dock-0.8.2 → api_dock-0.9.0}/tests/test_runtime_settings.py +1 -1
- {api_dock-0.8.2 → api_dock-0.9.0}/tests/test_schema_unions.py +7 -6
- {api_dock-0.8.2 → api_dock-0.9.0}/tests/test_shared_database_config.py +9 -10
- {api_dock-0.8.2 → api_dock-0.9.0}/tests/test_sql_builder.py +17 -14
- {api_dock-0.8.2 → api_dock-0.9.0}/tests/test_sql_selector.py +10 -5
- api_dock-0.9.0/tests/test_sql_template_check.py +147 -0
- api_dock-0.9.0/tests/test_startup_check.py +449 -0
- api_dock-0.8.2/api_dock/__init__.py +0 -26
- {api_dock-0.8.2 → api_dock-0.9.0}/LICENSE.md +0 -0
- {api_dock-0.8.2 → api_dock-0.9.0}/api_dock/auth.py +0 -0
- {api_dock-0.8.2 → api_dock-0.9.0}/api_dock/cli.py +0 -0
- {api_dock-0.8.2 → api_dock-0.9.0}/api_dock/config_discovery.py +0 -0
- {api_dock-0.8.2 → api_dock-0.9.0}/api_dock/encryption.py +0 -0
- {api_dock-0.8.2 → api_dock-0.9.0}/api_dock/example_api_dock_config/config.yaml +0 -0
- {api_dock-0.8.2 → api_dock-0.9.0}/api_dock/example_api_dock_config/databases/config.yaml +0 -0
- {api_dock-0.8.2 → api_dock-0.9.0}/api_dock/example_api_dock_config/remotes/example_remote.yaml +0 -0
- {api_dock-0.8.2 → api_dock-0.9.0}/api_dock/storage_auth.py +0 -0
- {api_dock-0.8.2 → api_dock-0.9.0}/api_dock/types.py +0 -0
- {api_dock-0.8.2 → api_dock-0.9.0}/api_dock.egg-info/dependency_links.txt +0 -0
- {api_dock-0.8.2 → api_dock-0.9.0}/api_dock.egg-info/entry_points.txt +0 -0
- {api_dock-0.8.2 → api_dock-0.9.0}/api_dock.egg-info/requires.txt +0 -0
- {api_dock-0.8.2 → api_dock-0.9.0}/api_dock.egg-info/top_level.txt +0 -0
- {api_dock-0.8.2 → api_dock-0.9.0}/setup.cfg +0 -0
- {api_dock-0.8.2 → api_dock-0.9.0}/tests/test_inject_cookies.py +0 -0
- {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.
|
|
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 =
|
|
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
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 >=
|
|
1541
|
+
sql: event_date >= {{date_from}}
|
|
1508
1542
|
- event_type:
|
|
1509
|
-
sql: 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 =
|
|
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 =
|
|
1591
|
-
AND 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.
|
|
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='
|
|
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
|
-
-
|
|
1642
|
-
-
|
|
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
|
-
-
|
|
1645
|
-
-
|
|
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
|
-
-
|
|
1648
|
-
-
|
|
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 =
|
|
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
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 >=
|
|
1502
|
+
sql: event_date >= {{date_from}}
|
|
1469
1503
|
- event_type:
|
|
1470
|
-
sql: 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 =
|
|
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 =
|
|
1552
|
-
AND 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.
|
|
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='
|
|
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
|
-
-
|
|
1603
|
-
-
|
|
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
|
-
-
|
|
1606
|
-
-
|
|
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
|
-
-
|
|
1609
|
-
-
|
|
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
|