feat(connectors): add the http connector and wire the network gate

TDD step 7: read_http executes the optional http extension point behind
an injectable transport seam (urllib_get is the only socket path; the
suite runs socket-free against a mock). credential_ref resolves from the
environment at run time, fail-fast before any transport call; the secret
rides in the Authorization header only. Explicit single-slash URL join,
CRLF-to-LF normalization, LF line-count cap (missing trailing newline
still counts), and code-fence-marker rejection. materialize_bundle
renders the body as a verbatim fenced block; the §8 gate test asserts
zero transport calls without the opt-in.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QeqhJpYQyghASjiJo5EhGg
This commit is contained in:
Kjell Tore Guttormsen 2026-07-16 20:06:21 +02:00
commit ab1fa4d999
3 changed files with 242 additions and 6 deletions

View file

@ -9,13 +9,22 @@ from __future__ import annotations
import csv
import os
import sqlite3
from collections.abc import Callable
from contextlib import closing
from pathlib import Path
from urllib.error import URLError
from urllib.parse import quote
from urllib.request import Request, urlopen
from .errors import SourceError
from .render import sql_value_to_text
#: The http transport seam: ``(url, credential) -> response body text``.
#: Injecting a canned implementation in tests keeps the suite socket-free
#: (§11); the default ``urllib_get`` is the ONLY path that opens a socket,
#: and it is reached only inside the gated http branch of materialization.
HttpGet = Callable[[str, "str | None"], str]
def safe_resolve(root: Path, relative: str) -> Path:
"""Resolve `relative` against `root`, fail-closed (the OKF path rule).
@ -116,3 +125,73 @@ def read_sql(
except (sqlite3.Error, sqlite3.Warning) as exc:
raise SourceError(f"sql extraction failed for query {query!r}: {exc}") from exc
return header, rows
def urllib_get(url: str, credential: str | None) -> str:
"""The stdlib GET — the only socket path in this library.
Attaches `Authorization: Bearer {credential}` iff a credential was
resolved. Transport/decode failures wrap as SourceError WITHOUT the
secret — the credential rides in the header, never in the URL
(embedded credentials are already rejected at schema validation).
"""
headers = {"Authorization": f"Bearer {credential}"} if credential is not None else {}
request = Request(url, headers=headers, method="GET")
try:
with urlopen(request) as response:
raw: bytes = response.read()
return raw.decode("utf-8")
except (URLError, OSError, UnicodeDecodeError) as exc:
raise SourceError(f"http GET failed for {url!r}: {exc}") from exc
def read_http(
base_url: str,
query: str,
*,
max_rows: int,
credential_ref: str | None = None,
get: HttpGet = urllib_get,
) -> str:
"""Execute an `http`-source extraction: GET `base_url` joined with `query`
via the injectable `get` seam, returning the body for verbatim fenced
rendering (§4/§5).
`credential_ref` names an environment variable holding the secret,
resolved at run time (present-but-unset is fail-fast, before any
transport call; None means no auth) — never read from the manifest,
never logged, never stamped. URL join is explicit (one slash), not
urljoin. The body is CRLF→LF normalized and capped on its LF line count
(a missing trailing newline still counts the last line); exceeding
max_rows is an ERROR (§8). A body line that is a code-fence marker is
rejected — it cannot be safely embedded in a fenced block; fail-fast,
never silent corruption.
"""
if credential_ref is None:
credential: str | None = None
else:
credential = os.environ.get(credential_ref)
if not credential:
raise SourceError(
f"http source credential_ref {credential_ref!r} is not set in the environment "
"(credentials resolve at run time, never from the manifest — §4)"
)
url = base_url.rstrip("/") + "/" + query.lstrip("/")
normalized = get(url, credential).replace("\r\n", "\n").replace("\r", "\n")
for line in normalized.split("\n"):
if line.lstrip(" ").startswith("```"):
raise SourceError(
f"http extraction {query!r} contains a code-fence marker line (```) — "
"cannot be safely embedded in a fenced code block; fail-fast, never "
"silent corruption"
)
newline_count = normalized.count("\n")
line_count = (
newline_count if (normalized == "" or normalized.endswith("\n")) else newline_count + 1
)
if line_count > max_rows:
raise SourceError(
f"http extraction {query!r} exceeds max_rows={max_rows} "
"(error, never silent truncation — spec §8)"
)
return normalized