mirror of
https://github.com/Comfy-Org/ComfyUI.git
synced 2026-08-24 18:10:27 +08:00
feat(assets): add job_ids filter to GET /api/assets (#13998)
* feat(assets): add job_ids filter to GET /api/assets Mirrors the existing cloud `job_ids` query param on the local Python server: clients can pass a comma-separated list (or repeated query params) of UUIDs to filter assets by their associated job. The `AssetReference.job_id` column already exists, so no migration is needed — this just plumbs the filter through schema → service → query. Marks the parameter as available in both runtimes by dropping the `[cloud-only]` description prefix and the `x-runtime: [cloud]` tag from the OpenAPI spec, per the OSS field-drift convention (absent runtime tag = populated by both local and cloud). * fix(assets): tighten job_ids — array schema, max_length, narrow except From cursor-reviews on the parent commit: - OpenAPI: declare job_ids as `type: array, items: string format: uuid` with `style: form, explode: true` so it matches the documented contract (and matches sibling include_tags/exclude_tags shape). Description now states both accepted shapes explicitly. - Schema: cap `job_ids` at 500 entries (max_length on the Pydantic field) so a client can't splice an unbounded list into the IN clauses. - Schema: drop `AttributeError` from the except — `raw` only contains `str` items by construction, so `uuid.UUID(<str>)` raises `ValueError` exclusively; the second clause was dead code. * fix(assets): tighten job_ids validator + add schema-level tests Aligns with the parallel hardening from draft PR #13848 (now closed as a duplicate). The validator now: - Raises ValueError on non-string list items (was: silently dropped). - Raises ValueError on non-string / non-list top-level values like dict or int (was: silently passed through to Pydantic's downstream coercion). Adds tests-unit/assets_test/queries/test_list_assets_query.py covering the validator end-to-end: CSV canonicalization, dedup order, default empty, invalid UUID, non-string list item, non-string non-list value, and the max_length=500 boundary. * feat(prompt): enforce canonical UUID prompt_id at job creation POST /prompt previously accepted any client-supplied prompt_id verbatim, str()-coercing even non-strings, and minting the literal job id "None" for an explicit JSON null. The new GET /api/assets job_ids filter matches stored job ids as canonical UUIDs exactly, so a non-UUID id minted a job whose assets could never be filtered. - validate_job_id (comfy_execution/jobs.py): requires a string in the canonical lowercase hyphenated UUID form; raises ValueError otherwise, including parseable-but-non-canonical spellings (uppercase, braced, URN, bare hex), which would otherwise be silently rewritten and then miss every exact-match lookup downstream (history keys, websocket correlation, /interrupt, the assets job_ids filter). - POST /prompt: absent or null prompt_id means the server mints uuid4; invalid means 400 invalid_prompt_id on the standard error envelope. - openapi.yaml: document the request-side prompt_id (format uuid, nullable) on PromptRequest. - tests: unit matrix for validate_job_id; integration tests against the booted server covering rejection, acceptance, and null handling. --------- Co-authored-by: guill <jacob.e.segal@gmail.com>
This commit is contained in:
@@ -158,6 +158,56 @@ class TestListReferencesPage:
|
||||
refs, _, _ = list_references_page(session, sort="name", order="asc")
|
||||
assert refs[0].name == "large"
|
||||
|
||||
def test_job_ids_filter(self, session: Session):
|
||||
asset = _make_asset(session, "hash1")
|
||||
job_a = str(uuid.uuid4())
|
||||
job_b = str(uuid.uuid4())
|
||||
ref_a = _make_reference(session, asset, name="from_job_a")
|
||||
ref_a.job_id = job_a
|
||||
ref_b = _make_reference(session, asset, name="from_job_b")
|
||||
ref_b.job_id = job_b
|
||||
_make_reference(session, asset, name="no_job")
|
||||
session.commit()
|
||||
|
||||
# Single job filter
|
||||
refs, _, total = list_references_page(session, job_ids=[job_a])
|
||||
assert total == 1
|
||||
assert refs[0].name == "from_job_a"
|
||||
|
||||
# Multi-job filter (IN)
|
||||
refs, _, total = list_references_page(session, job_ids=[job_a, job_b])
|
||||
names = sorted(r.name for r in refs)
|
||||
assert total == 2
|
||||
assert names == ["from_job_a", "from_job_b"]
|
||||
|
||||
# Unknown job id matches nothing
|
||||
refs, _, total = list_references_page(session, job_ids=[str(uuid.uuid4())])
|
||||
assert total == 0
|
||||
assert refs == []
|
||||
|
||||
# Empty/None means no filter -> all three references
|
||||
refs, _, total = list_references_page(session, job_ids=[])
|
||||
assert total == 3
|
||||
refs, _, total = list_references_page(session, job_ids=None)
|
||||
assert total == 3
|
||||
|
||||
def test_job_ids_combined_with_other_filters(self, session: Session):
|
||||
asset = _make_asset(session, "hash1")
|
||||
job_a = str(uuid.uuid4())
|
||||
ref_match = _make_reference(session, asset, name="match.bin")
|
||||
ref_match.job_id = job_a
|
||||
ref_wrong_name = _make_reference(session, asset, name="other.bin")
|
||||
ref_wrong_name.job_id = job_a
|
||||
ref_wrong_job = _make_reference(session, asset, name="match.bin")
|
||||
ref_wrong_job.job_id = str(uuid.uuid4())
|
||||
session.commit()
|
||||
|
||||
refs, _, total = list_references_page(
|
||||
session, job_ids=[job_a], name_contains="match"
|
||||
)
|
||||
assert total == 1
|
||||
assert refs[0].id == ref_match.id
|
||||
|
||||
|
||||
class TestFetchReferenceAssetAndTags:
|
||||
def test_returns_none_for_nonexistent(self, session: Session):
|
||||
|
||||
60
tests-unit/assets_test/queries/test_list_assets_query.py
Normal file
60
tests-unit/assets_test/queries/test_list_assets_query.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""Schema-level unit tests for ListAssetsQuery (no DB required)."""
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.assets.api.schemas_in import ListAssetsQuery
|
||||
|
||||
|
||||
class TestJobIdsValidator:
|
||||
def test_csv_string_parses_and_canonicalizes(self):
|
||||
a = "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE"
|
||||
b = "11111111-2222-3333-4444-555555555555"
|
||||
q = ListAssetsQuery.model_validate({"job_ids": f"{a},{b}"})
|
||||
# Canonicalized to lowercase
|
||||
assert q.job_ids == [a.lower(), b]
|
||||
|
||||
def test_repeated_query_params_as_list(self):
|
||||
a = "11111111-1111-1111-1111-111111111111"
|
||||
b = "22222222-2222-2222-2222-222222222222"
|
||||
q = ListAssetsQuery.model_validate({"job_ids": [a, b]})
|
||||
assert q.job_ids == [a, b]
|
||||
|
||||
def test_dedup_preserves_first_seen_order(self):
|
||||
a = "11111111-1111-1111-1111-111111111111"
|
||||
b = "22222222-2222-2222-2222-222222222222"
|
||||
q = ListAssetsQuery.model_validate({"job_ids": [a, b, a]})
|
||||
assert q.job_ids == [a, b]
|
||||
|
||||
def test_default_empty(self):
|
||||
q = ListAssetsQuery.model_validate({})
|
||||
assert q.job_ids == []
|
||||
|
||||
def test_invalid_uuid_rejected(self):
|
||||
with pytest.raises(ValidationError) as exc:
|
||||
ListAssetsQuery.model_validate({"job_ids": "not-a-uuid"})
|
||||
assert "must be UUIDs" in str(exc.value)
|
||||
|
||||
def test_non_string_list_item_rejected(self):
|
||||
with pytest.raises(ValidationError) as exc:
|
||||
ListAssetsQuery.model_validate(
|
||||
{"job_ids": ["11111111-1111-1111-1111-111111111111", 42]}
|
||||
)
|
||||
assert "must be strings" in str(exc.value)
|
||||
|
||||
def test_non_string_non_list_value_rejected(self):
|
||||
with pytest.raises(ValidationError) as exc:
|
||||
ListAssetsQuery.model_validate({"job_ids": {"bad": "shape"}})
|
||||
assert "must be a string or list of strings" in str(exc.value)
|
||||
|
||||
def test_max_length_enforced(self):
|
||||
too_many = [str(uuid.uuid4()) for _ in range(501)]
|
||||
with pytest.raises(ValidationError) as exc:
|
||||
ListAssetsQuery.model_validate({"job_ids": too_many})
|
||||
assert exc.value.errors()[0]["type"] == "too_long"
|
||||
|
||||
def test_max_length_boundary_accepted(self):
|
||||
at_cap = [str(uuid.uuid4()) for _ in range(500)]
|
||||
q = ListAssetsQuery.model_validate({"job_ids": at_cap})
|
||||
assert len(q.job_ids) == 500
|
||||
69
tests-unit/assets_test/test_prompt_id_enforcement.py
Normal file
69
tests-unit/assets_test/test_prompt_id_enforcement.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""POST /prompt enforces canonical-UUID job ids at creation time.
|
||||
|
||||
Lives in assets_test because it uses this suite's booted-server fixture and
|
||||
because the invariant exists for the assets pipeline: the GET /api/assets
|
||||
``job_ids`` filter matches stored job ids exactly, so a job minted with a
|
||||
non-canonical id would produce assets the filter can never find.
|
||||
|
||||
The prompt bodies here are intentionally invalid workflows — prompt_id
|
||||
validation happens before workflow validation, so a rejected id returns
|
||||
``invalid_prompt_id`` while an accepted id falls through to the ordinary
|
||||
workflow-validation error (proving it cleared the id check).
|
||||
"""
|
||||
import requests
|
||||
|
||||
|
||||
def _post_prompt(http: requests.Session, api_base: str, body: dict) -> requests.Response:
|
||||
return http.post(api_base + "/prompt", json=body, timeout=30)
|
||||
|
||||
|
||||
def _error_type(r: requests.Response) -> str:
|
||||
return r.json()["error"]["type"]
|
||||
|
||||
|
||||
def test_non_uuid_prompt_id_rejected(http: requests.Session, api_base: str):
|
||||
r = _post_prompt(http, api_base, {"prompt": {}, "prompt_id": "not-a-uuid"})
|
||||
assert r.status_code == 400, r.text
|
||||
assert _error_type(r) == "invalid_prompt_id"
|
||||
|
||||
|
||||
def test_non_string_prompt_id_rejected(http: requests.Session, api_base: str):
|
||||
# Previously str()-coerced (123 became the job id "123"); must now be a 400,
|
||||
# not a 500 from uuid.UUID choking on a non-string.
|
||||
r = _post_prompt(http, api_base, {"prompt": {}, "prompt_id": 123})
|
||||
assert r.status_code == 400, r.text
|
||||
assert _error_type(r) == "invalid_prompt_id"
|
||||
|
||||
|
||||
def test_non_canonical_uuid_rejected(http: requests.Session, api_base: str):
|
||||
# Parseable as a UUID, but not the canonical lowercase form: rejected
|
||||
# loudly rather than silently rewritten (downstream lookups match the
|
||||
# stored id exactly).
|
||||
r = _post_prompt(
|
||||
http,
|
||||
api_base,
|
||||
{"prompt": {}, "prompt_id": "AAAAAAAA-BBBB-4CCC-8DDD-EEEEEEEEEEEE"},
|
||||
)
|
||||
assert r.status_code == 400, r.text
|
||||
assert _error_type(r) == "invalid_prompt_id"
|
||||
|
||||
|
||||
def test_canonical_uuid_accepted(http: requests.Session, api_base: str):
|
||||
# The id clears validation; the empty workflow then fails ordinary prompt
|
||||
# validation, proving the request got past the id check.
|
||||
r = _post_prompt(
|
||||
http,
|
||||
api_base,
|
||||
{"prompt": {}, "prompt_id": "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"},
|
||||
)
|
||||
assert r.status_code == 400, r.text
|
||||
assert _error_type(r) != "invalid_prompt_id"
|
||||
|
||||
|
||||
def test_null_prompt_id_not_rejected(http: requests.Session, api_base: str):
|
||||
# Explicit null means "server generates" and must not be rejected as an
|
||||
# invalid id. (The minted id itself is not observable here because the
|
||||
# workflow is invalid; unit tests cover validate_job_id directly.)
|
||||
r = _post_prompt(http, api_base, {"prompt": {}, "prompt_id": None})
|
||||
assert r.status_code == 400, r.text
|
||||
assert _error_type(r) != "invalid_prompt_id"
|
||||
Reference in New Issue
Block a user