Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
0632482
feat(schedules): add guided cron builder
badry-dev Aug 30, 2026
053c685
fix(schedules): preserve job history on deletion
badry-dev Aug 30, 2026
9e5bc80
fix(endpoints): preserve job history on deletion
badry-dev Aug 30, 2026
6ba4a0e
test: harden destructive database guard
badry-dev Aug 30, 2026
407a923
fix(scheduler): restore active jobs on startup
badry-dev Aug 30, 2026
7b4eaf0
feat(snapshots): add validated parameter defaults
badry-dev Aug 30, 2026
d342b9f
feat(parameters): support explicit null defaults
badry-dev Aug 30, 2026
f4343bd
fix(data): enforce required live parameters
badry-dev Aug 30, 2026
ff6ab59
fix(parameters): accept day-first dates
badry-dev Aug 30, 2026
2eb8ffb
docs: document scheduler and parameter fixes
badry-dev Aug 30, 2026
ec18fe2
style(backend): format touched code
badry-dev Aug 30, 2026
27aab9a
fix(deps): update vulnerable frontend packages
badry-dev Aug 30, 2026
b3ce137
style(frontend): format endpoint wizard changes
badry-dev Aug 30, 2026
396ba74
fix(scheduler): fail startup when job restoration fails
badry-dev Aug 30, 2026
c50a56c
test: validate destructive database target name
badry-dev Aug 30, 2026
479a124
fix(parameters): validate configured defaults
badry-dev Aug 30, 2026
38b87f4
test: clarify non-sensitive schedule credential
badry-dev Aug 30, 2026
231a6fb
docs: fix architecture heading spacing
badry-dev Aug 30, 2026
5bdd430
docs(deployment): prevent overlapping schedulers
badry-dev Aug 30, 2026
352b7db
fix(parameters): clear boolean defaults in wizard
badry-dev Aug 30, 2026
19e2034
fix(endpoints): resolve defaults in SQL preview
badry-dev Aug 30, 2026
0d1edd1
fix(schedules): validate cron syntax and ranges
badry-dev Aug 30, 2026
c4caafb
fix(auth): require authentication for all data endpoints
badry-dev Aug 30, 2026
11c362a
fix(auth): deny orphaned endpoint configurations
badry-dev Aug 30, 2026
fbfa796
fix(types): accept covariant parameter schemas
badry-dev Aug 30, 2026
e0a896a
fix(logging): normalize scheduler context fields
badry-dev Aug 30, 2026
3813514
docs(architecture): clarify scheduler persistence
badry-dev Aug 30, 2026
c7234b1
docs(deployment): nest Kubernetes health probes
badry-dev Aug 30, 2026
6f8da4d
fix(api): normalize auth and validation errors
badry-dev Aug 31, 2026
1d94e75
test: keep schema corruption transactional
badry-dev Aug 31, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ Define connection ─▶ Author SQL (with :bind params) ─▶ Attach auth ─
```

1. **Connect** to your Oracle database with securely stored, encrypted credentials.
2. **Author** a `SELECT` query using named bind parameters (`:param_name`) in a rich SQL editor. The wizard detects parameters as you type and requests temporary sample values before previewing the results.
2. **Author** a `SELECT` query using named bind parameters (`:param_name`) in a rich SQL editor. The wizard detects parameters as you type, requests temporary sample values before previewing, and supports fixed or dynamic date defaults (`today` and `yesterday`). Snapshot endpoints require a default for every bind so scheduled refreshes can execute without request inputs. Scheduler defaults do not make required parameters optional for live HTTP requests; callers must supply required dates as either `YYYY-MM-DD` or `DD-MM-YYYY`.
3. **Secure** the endpoint by attaching a Bearer token, Basic Auth, or API key policy.
4. **Choose** a data strategy: serve results **live** on each request, or from a **scheduled snapshot** cache.
5. **Publish** a versioned endpoint under `/api/v1/data/*` that resolves dynamically — no service restart needed.
Expand All @@ -73,16 +73,16 @@ QueryGateway is organized into five admin modules, all driven from the React adm
| Module | What it does |
|--------|--------------|
| **Connections** | Create, edit, test, and delete Oracle database connections. Credentials are encrypted at rest; pool sizing and timeouts are configurable. Uses `python-oracledb`; thin mode needs no native client, while the backend Docker image includes Oracle Instant Client 19.32 for thick mode. |
| **API Creation Wizard** | A multi-step wizard that turns a parameterized SQL query into a deployable GET endpoint: pick a connection, author SQL with a rich editor, supply preview-only sample values for detected bind parameters, preview sample rows and inferred schema, map/rename output columns, attach an auth method, and select a data strategy. |
| **Authentication** | Manage per-endpoint auth methods — Bearer token (JWT), Basic Auth, and API key. Tokens are issued/verified with `PyJWT`; credentials are hashed with `bcrypt`. When an auth method is attached to an endpoint, middleware enforces it on every request; endpoints with no auth method attached are served publicly. |
| **Scheduling & Snapshots** | Schedule query refreshes with the in-process APScheduler (cron or interval). Run now, pause/resume, and enable/disable jobs. Results are cached as PostgreSQL JSONB snapshots and served with freshness metadata. Schedule definitions are persisted in the app database; the active APScheduler jobs run in-memory and are (re)registered when a schedule is created, updated, or resumed. |
| **API Creation Wizard** | A multi-step wizard that turns a parameterized SQL query into a deployable GET endpoint: pick a connection, author SQL with a rich editor, supply preview-only sample values for detected bind parameters, configure fixed values, explicit SQL `NULL` values for optional binds, or dynamic date defaults, preview sample rows and inferred schema, map/rename output columns, attach an auth method, and select a data strategy. |
| **Authentication** | Manage per-endpoint auth methods — Bearer token (JWT), Basic Auth, and API key. Tokens are issued/verified with `PyJWT`; credentials are hashed with `bcrypt`. Every `/api/v1/data/*` request is authenticated; endpoints without a dedicated method require the platform admin Bearer token. |
| **Scheduling & Snapshots** | Schedule query refreshes with friendly hourly, daily, weekly, or monthly calendar controls, an advanced custom-cron option, or a fixed interval. Run now, pause/resume, and enable/disable jobs. Results are cached as PostgreSQL JSONB snapshots and served with freshness metadata. Schedule definitions are persisted in the app database, active APScheduler jobs are restored on API startup, and deleting a schedule preserves its job-run and snapshot history. Deleting an endpoint removes its schedule and snapshots while retaining orphaned job-run audit records. |
| **Settings & Health** | Configure runtime settings (base URL/port, logging level, query timeouts, CORS/rate-limit inputs) and view a health dashboard covering API, PostgreSQL, Oracle connectivity, scheduler status, and recent job outcomes. |

### Security by Default

- **SQL injection resistant** — user-defined SQL runs only through SQLAlchemy `text()` with named bind parameters. Request values are never concatenated into SQL strings, and bind values are validated through typed schemas before execution.
- **Encrypted credentials** — Oracle connection secrets are encrypted at rest using an environment-provided key.
- **Per-endpoint authentication** — attach a Bearer token, Basic Auth, or API key policy to an endpoint and it is enforced on every request. Endpoints published without an auth method are public, so assign one to any endpoint that should be protected.
- **Mandatory data authentication** — attach a Bearer token, Basic Auth, or API key policy to an endpoint. If no dedicated method is attached, the endpoint requires the platform admin Bearer token; anonymous data access is never allowed.
- **Structured, redacted logging** — `structlog` emits JSON logs with correlation fields (`request_id`, `user`, `endpoint`, `status`, `duration_ms`); credentials and tokens are redacted before emission.

### Two API Surfaces
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"""Preserve job runs when deleting schedules.

Revision ID: b2d18f4a6c73
Revises: a8307fb20816
Create Date: 2026-08-30
"""

from collections.abc import Sequence

import sqlalchemy as sa
from alembic import op

revision: str = "b2d18f4a6c73"
down_revision: str | None = "a8307fb20816"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
op.drop_constraint(
"job_runs_schedule_id_fkey", "job_runs", type_="foreignkey"
)
op.alter_column(
"job_runs",
"schedule_id",
existing_type=sa.UUID(),
nullable=True,
)
op.create_foreign_key(
"job_runs_schedule_id_fkey",
"job_runs",
"schedules",
["schedule_id"],
["id"],
ondelete="SET NULL",
)


def downgrade() -> None:
op.drop_constraint(
"job_runs_schedule_id_fkey", "job_runs", type_="foreignkey"
)
# Schedules deleted after this migration cannot be reconstructed. Remove
# only their orphaned audit rows so the original NOT NULL contract can be
# restored; snapshots remain and their job_run_id becomes NULL.
op.execute("DELETE FROM job_runs WHERE schedule_id IS NULL")
op.alter_column(
"job_runs",
"schedule_id",
existing_type=sa.UUID(),
nullable=False,
)
op.create_foreign_key(
"job_runs_schedule_id_fkey",
"job_runs",
"schedules",
["schedule_id"],
["id"],
ondelete="RESTRICT",
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"""Preserve job runs when deleting endpoints.

Revision ID: c7e91a4f2d60
Revises: b2d18f4a6c73
Create Date: 2026-08-30
"""

from collections.abc import Sequence

import sqlalchemy as sa
from alembic import op

revision: str = "c7e91a4f2d60"
down_revision: str | None = "b2d18f4a6c73"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
op.drop_constraint("job_runs_endpoint_id_fkey", "job_runs", type_="foreignkey")
op.alter_column(
"job_runs",
"endpoint_id",
existing_type=sa.UUID(),
nullable=True,
)
op.create_foreign_key(
"job_runs_endpoint_id_fkey",
"job_runs",
"endpoints",
["endpoint_id"],
["id"],
ondelete="SET NULL",
)


def downgrade() -> None:
op.drop_constraint("job_runs_endpoint_id_fkey", "job_runs", type_="foreignkey")
# Endpoints deleted after this migration cannot be reconstructed. Remove
# only their orphaned audit rows so the original NOT NULL contract can be
# restored; any surviving snapshots already reference other job runs.
op.execute("DELETE FROM job_runs WHERE endpoint_id IS NULL")
op.alter_column(
"job_runs",
"endpoint_id",
existing_type=sa.UUID(),
nullable=False,
)
op.create_foreign_key(
"job_runs_endpoint_id_fkey",
"job_runs",
"endpoints",
["endpoint_id"],
["id"],
ondelete="RESTRICT",
)
2 changes: 1 addition & 1 deletion backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]:
debug=settings.debug,
)
_init_oracle_client()
start_scheduler()
await start_scheduler()
yield
stop_scheduler()
log.info("application_shutdown")
Expand Down
9 changes: 3 additions & 6 deletions backend/app/models/endpoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,9 @@ class ApiEndpoint(UUIDPrimaryKeyMixin, TimestampMixin, Base):
auth_method_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("auth_methods.id", ondelete="SET NULL"), nullable=True
)
# When no auth method is attached, an endpoint is served unauthenticated
# (public). This flag forces that to be an explicit, deliberate choice:
# the admin API rejects an endpoint that has no auth method unless this
# is set to True (see app.schemas.endpoint). Existing rows default to
# False so they keep being served (the data plane logs a warning) but
# any future edit must opt in explicitly.
# Legacy-named compatibility flag. When no endpoint-specific auth method is
# attached, True opts into platform-admin Bearer authentication. The data
# plane never serves anonymous requests.
allow_unauthenticated: Mapped[bool] = mapped_column(
Boolean, default=False, nullable=False, server_default=text("false")
)
Expand Down
12 changes: 5 additions & 7 deletions backend/app/models/job_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,17 +30,15 @@ class JobRun(UUIDPrimaryKeyMixin, Base):

id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)

schedule_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("schedules.id", ondelete="RESTRICT"), nullable=False
schedule_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("schedules.id", ondelete="SET NULL"), nullable=True
)
endpoint_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("endpoints.id", ondelete="RESTRICT"), nullable=False
endpoint_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("endpoints.id", ondelete="SET NULL"), nullable=True
)

started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
finished_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
status: Mapped[JobRunStatus] = mapped_column(
SAEnum(JobRunStatus, name="job_run_status"),
nullable=False,
Expand Down
35 changes: 15 additions & 20 deletions backend/app/routers/endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,18 @@
from app.dependencies import get_db
from app.repositories.connection import ConnectionRepository
from app.repositories.endpoint import EndpointRepository
from app.repositories.schedule import ScheduleRepository
from app.schemas.endpoint import (
EndpointCreate,
EndpointResponse,
EndpointUpdate,
PublicEndpointError,
SnapshotConfigurationError,
SqlPreviewRequest,
SqlPreviewResponse,
)
from app.services.endpoint import EndpointService
from app.services.scheduler import remove_schedule_job

log = structlog.get_logger()

Expand Down Expand Up @@ -72,14 +75,12 @@ async def create_endpoint(
) -> EndpointResponse:
try:
result = await svc.create_endpoint(payload)
except PublicEndpointError as exc:
except (PublicEndpointError, SnapshotConfigurationError) as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)
) from exc
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail=str(exc)
) from exc
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
Comment thread
badry-dev marked this conversation as resolved.
await db.commit()
return result

Expand All @@ -95,9 +96,7 @@ async def get_endpoint(
) -> EndpointResponse:
result = await svc.get_endpoint(endpoint_id)
if result is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Endpoint not found."
)
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Endpoint not found.")
return result


Expand All @@ -114,18 +113,14 @@ async def update_endpoint(
) -> EndpointResponse:
try:
result = await svc.update_endpoint(endpoint_id, payload)
except PublicEndpointError as exc:
except (PublicEndpointError, SnapshotConfigurationError) as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)
) from exc
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail=str(exc)
) from exc
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
if result is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Endpoint not found."
)
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Endpoint not found.")
await db.commit()
return result

Expand All @@ -140,12 +135,14 @@ async def delete_endpoint(
db: AsyncSession = Depends(get_db),
svc: EndpointService = Depends(_service),
) -> None:
schedule = await ScheduleRepository(db).get_by_endpoint_id(endpoint_id)
schedule_id = schedule.id if schedule is not None else None
deleted = await svc.delete_endpoint(endpoint_id)
if not deleted:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Endpoint not found."
)
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Endpoint not found.")
await db.commit()
if schedule_id is not None:
remove_schedule_job(schedule_id)


@router.post(
Expand All @@ -164,6 +161,4 @@ async def preview_sql(
try:
return await svc.preview_sql(payload)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)
) from exc
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
7 changes: 7 additions & 0 deletions backend/app/routers/schedules.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
from app.repositories.job_run import JobRunRepository
from app.repositories.schedule import ScheduleRepository
from app.repositories.snapshot import SnapshotRepository
from app.schemas.endpoint import SnapshotConfigurationError
from app.schemas.schedule import (
JobRunResponse,
ScheduleCreate,
Expand All @@ -38,6 +39,7 @@
SnapshotResponse,
)
from app.services.schedule import ScheduleService
from app.services.scheduler import remove_schedule_job

log = structlog.get_logger()

Expand Down Expand Up @@ -85,6 +87,10 @@ async def create_schedule(
) -> ScheduleResponse:
try:
result = await svc.create_schedule(payload)
except SnapshotConfigurationError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)
) from exc
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail=str(exc)
Expand Down Expand Up @@ -151,6 +157,7 @@ async def delete_schedule(
status_code=status.HTTP_404_NOT_FOUND, detail="Schedule not found."
)
await db.commit()
remove_schedule_job(schedule_id)


# ── Control actions ──────────────────────────────────────────────────────────
Expand Down
Loading