Marsad

3C. Integrate a Hello Image Job

Connect the Hello Next.js page and Flask blueprint through Redis, an RQ worker, and a generated PNG.

Integrate a Hello Image-Generation Job

Expected time: 90–150 minutes

Outcome: A developer enters text in the Next.js page, receives a job ID, watches an RQ job progress, and sees the PNG generated by the Model Server worker.

This tutorial extends the files created in 3A. Build Hello Marsad and 3B. Build Hello Model Server. Do not clean up those exercises until the final group on this page.

The image is deterministic and generated with Pillow, which is already a Model Server dependency. No external model or provider credential is required; the purpose is to learn Marsad's integration boundaries.

Browser
  → Next.js page
  → Next.js API route
  → authenticated Model Server route
  → Redis tasks queue
  → RQ worker
  → shared-data/generated/tutorial/<job-id>.png
  → Next.js image proxy
  → Browser

Each group has a visible checkpoint. Do not continue until your output matches the checkpoint.

Starting Checkpoint

Start the Web App, Model Server, Redis, and the worker that consumes tasks. Then confirm:

  1. http://localhost:3511/tutorial/hello-world displays the page from 3A.
  2. http://localhost:3531/tutorial/hello returns the response from 3B:
{
  "message": "Hello, Marsad!",
  "service": "model-server"
}

If either output is missing, return to its tutorial before continuing.


Group 1: Connect the Two Hello Worlds

The browser will call a Next.js route. That route will call Flask from the server, keeping the Model Server address out of browser code.

Step 1. Create the Next.js proxy

Create main/src/app/api/tutorial/model-hello/route.ts:

import {NextResponse} from 'next/server';


export const runtime = 'nodejs';


function getModelServerOrigin() {
    const configuredUrl = process.env.MODEL_API_URL;

    if (!configuredUrl) {
        throw new Error('MODEL_API_URL is not configured.');
    }

    return configuredUrl.replace(/\/api\/?$/, '').replace(/\/$/, '');
}


export async function GET() {
    try {
        const response = await fetch(`${getModelServerOrigin()}/tutorial/hello`, {
            cache: 'no-store',
        });
        const modelServer = await response.json();

        if (!response.ok) {
            return NextResponse.json(
                {
                    success: false,
                    message: 'The Model Server rejected the request.',
                    modelServer,
                },
                {status: response.status},
            );
        }

        return NextResponse.json({
            success: true,
            web_app: 'connected',
            model_server: modelServer,
        });
    } catch (error) {
        return NextResponse.json(
            {
                success: false,
                message:
                    error instanceof Error
                        ? error.message
                        : 'The Model Server request failed.',
            },
            {status: 502},
        );
    }
}

MODEL_API_URL normally ends in /api; the temporary 3B endpoint does not. This first proxy deliberately derives the Model Server origin. Group 4 will move the tutorial blueprint into the authenticated /api namespace.

Step 2. Call the proxy

Open http://localhost:3511/api/tutorial/model-hello.

Expected output

{
  "success": true,
  "web_app": "connected",
  "model_server": {
    "message": "Hello, Marsad!",
    "service": "model-server"
  }
}

If you receive 502, check the Model Server terminal, port 3531, and MODEL_API_URL.

In main/src/app/(landing)/tutorial/hello-world/page.tsx, import Link:

import Link from 'next/link';

Add this link below the existing supporting text:

<Link
    href='/api/tutorial/model-hello'
    className='rounded-md bg-primary px-4 py-2 text-primary-foreground'
>
    Check Model Server
</Link>

Open the Hello page and select Check Model Server.

Group 1 checkpoint

The link opens the JSON response containing both web_app: "connected" and the Model Server greeting. Explain why the browser called port 3511, not port 3531, before continuing.


Group 2: Generate a PNG Without Redis

Prove that image generation works before putting it behind a queue.

Step 4. Create the pure image task

Create model-server/src/models/hello_image_task.py:

from pathlib import Path

from PIL import Image, ImageDraw, ImageFont


IMAGE_SIZE = (1200, 630)
DEFAULT_OUTPUT_ROOT = Path("shared-data/generated/tutorial")


def generate_hello_image(
    job_id: str,
    text: str,
    output_root: str | Path = DEFAULT_OUTPUT_ROOT,
):
    output_dir = Path(output_root)
    output_dir.mkdir(parents=True, exist_ok=True)
    output_path = output_dir / f"{job_id}.png"

    image = Image.new("RGB", IMAGE_SIZE, color="#f8fafc")
    draw = ImageDraw.Draw(image)
    font = ImageFont.load_default(size=64)

    text_box = draw.textbbox((0, 0), text, font=font)
    text_width = text_box[2] - text_box[0]
    text_height = text_box[3] - text_box[1]
    position = (
        (IMAGE_SIZE[0] - text_width) / 2,
        (IMAGE_SIZE[1] - text_height) / 2,
    )

    draw.text(position, text, fill="#0f172a", font=font)
    image.save(output_path, format="PNG")

    return {
        "image_path": output_path.as_posix(),
        "width": IMAGE_SIZE[0],
        "height": IMAGE_SIZE[1],
    }

The function receives a server-generated job ID and writes only below a caller-controlled output root. The HTTP request will never supply a filename or directory.

Step 5. Test the function in isolation

Create model-server/tests/test_hello_image_task.py:

from PIL import Image

from src.models.hello_image_task import generate_hello_image


def test_generate_hello_image_writes_png(tmp_path):
    result = generate_hello_image(
        job_id="hello-preview",
        text="Hello, Marsad!",
        output_root=tmp_path,
    )

    image_path = tmp_path / "hello-preview.png"

    assert image_path.exists()
    assert result["image_path"] == image_path.as_posix()

    with Image.open(image_path) as image:
        assert image.format == "PNG"
        assert image.size == (1200, 630)

From model-server/, run:

pytest tests/test_hello_image_task.py

Expected output

tests/test_hello_image_task.py .                                  [100%]
1 passed

Step 6. Create a visible preview

From model-server/, run:

python -c "from src.models.hello_image_task import generate_hello_image; print(generate_hello_image('preview', 'Hello, Marsad!'))"

Expected output

The command prints a result containing:

shared-data/generated/tutorial/preview.png

Open that file. It should be a 1200×630 image with “Hello, Marsad!” centered on it.

Group 2 checkpoint

The focused test passes and you can open the generated preview PNG. Do not continue if only the Python function succeeds but no readable image exists.


Group 3: Turn the Function Into an RQ Job

Now add Redis, the tasks queue, job status, and an image response.

Step 7. Extend the Flask blueprint

Replace model-server/src/routes/hello.py with:

from pathlib import Path
from uuid import uuid4

from flask import Blueprint, jsonify, request, send_file
from rq.exceptions import NoSuchJobError
from rq.job import Job, JobStatus

from src.database.redis_client import redis_client
from src.models.hello_image_task import generate_hello_image
from src.queues.config import task_queue


hello_bp = Blueprint("hello", __name__, url_prefix="/tutorial")
OUTPUT_ROOT = Path("shared-data/generated/tutorial")


@hello_bp.get("/hello")
def hello():
    return jsonify(
        {
            "message": "Hello, Marsad!",
            "service": "model-server",
        }
    )


@hello_bp.post("/hello-image")
def enqueue_hello_image():
    payload = request.get_json(silent=True) or {}
    text = payload.get("text")

    if not isinstance(text, str) or not 1 <= len(text.strip()) <= 80:
        return jsonify(
            {
                "success": False,
                "message": "Text must contain between 1 and 80 characters.",
            }
        ), 422

    job_id = f"hello-image-{uuid4().hex}"
    task_queue.enqueue(
        generate_hello_image,
        job_id,
        text.strip(),
        job_id=job_id,
        job_timeout=60,
    )

    return jsonify(
        {
            "success": True,
            "job_id": job_id,
            "status": "queued",
        }
    ), 202


@hello_bp.get("/hello-image/<job_id>")
def get_hello_image_job(job_id):
    try:
        job = Job.fetch(job_id, connection=redis_client)
    except NoSuchJobError:
        return jsonify(
            {
                "success": False,
                "message": "Tutorial job not found.",
            }
        ), 404

    status_map = {
        JobStatus.QUEUED: "queued",
        JobStatus.STARTED: "processing",
        JobStatus.FINISHED: "completed",
        JobStatus.FAILED: "failed",
        JobStatus.DEFERRED: "queued",
        JobStatus.SCHEDULED: "queued",
        JobStatus.STOPPED: "failed",
        JobStatus.CANCELED: "failed",
    }
    status = status_map.get(job.get_status(refresh=True), "queued")
    response = {
        "success": status != "failed",
        "job_id": job_id,
        "status": status,
    }

    if status == "completed":
        response["image_url"] = f"/tutorial/hello-image/{job_id}/image"
    elif status == "failed":
        response["message"] = "Image generation failed. Check the worker log."

    return jsonify(response)


@hello_bp.get("/hello-image/<job_id>/image")
def get_hello_image(job_id):
    try:
        job = Job.fetch(job_id, connection=redis_client)
    except NoSuchJobError:
        return jsonify(
            {
                "success": False,
                "message": "Tutorial job not found.",
            }
        ), 404

    if job.get_status(refresh=True) != JobStatus.FINISHED:
        return jsonify(
            {
                "success": False,
                "message": "The tutorial image is not ready.",
            }
        ), 409

    result = job.return_value(refresh=True) or {}
    image_value = result.get("image_path")

    if not isinstance(image_value, str):
        return jsonify(
            {
                "success": False,
                "message": "The job did not produce an image path.",
            }
        ), 500

    allowed_root = OUTPUT_ROOT.resolve()
    image_path = Path(image_value).resolve()

    try:
        image_path.relative_to(allowed_root)
    except ValueError:
        return jsonify(
            {
                "success": False,
                "message": "The generated image path is invalid.",
            }
        ), 500

    if not image_path.is_file():
        return jsonify(
            {
                "success": False,
                "message": "The generated image is missing.",
            }
        ), 404

    return send_file(image_path, mimetype="image/png")

Restart the Model Server after changing the blueprint.

Step 8. Test enqueueing without a worker

Extend model-server/tests/test_hello_route.py:

from src.routes import hello as hello_routes


def test_enqueue_hello_image_returns_job_id(monkeypatch):
    app = Flask(__name__)
    app.register_blueprint(hello_bp)
    enqueue_calls = []

    monkeypatch.setattr(
        hello_routes.task_queue,
        "enqueue",
        lambda *args, **kwargs: enqueue_calls.append((args, kwargs)),
    )

    with app.test_client() as client:
        response = client.post(
            "/tutorial/hello-image",
            json={"text": "Hello from pytest!"},
        )

    assert response.status_code == 202
    data = response.get_json()
    assert data["status"] == "queued"
    assert data["job_id"].startswith("hello-image-")
    assert enqueue_calls

    args, kwargs = enqueue_calls[0]
    assert args[0] is hello_routes.generate_hello_image
    assert args[1] == data["job_id"]
    assert args[2] == "Hello from pytest!"
    assert kwargs["job_id"] == data["job_id"]

Keep the imports already present in the 3B test. Run:

pytest tests/test_hello_route.py

Expected output

tests/test_hello_route.py ..                                      [100%]
2 passed

No worker or Redis connection is used: monkeypatch replaces the queue operation and lets the test inspect what would have been enqueued.

Step 9. Enqueue the real job

In PowerShell:

$body = @{ text = "Hello from an RQ job!" } | ConvertTo-Json
$job = Invoke-RestMethod -Method Post -Uri http://localhost:3531/tutorial/hello-image -ContentType "application/json" -Body $body
$job

On macOS or Linux:

curl -X POST http://localhost:3531/tutorial/hello-image \
  -H "Content-Type: application/json" \
  -d '{"text":"Hello from an RQ job!"}'

Expected output

{
  "success": true,
  "job_id": "hello-image-...",
  "status": "queued"
}

Keep the returned job ID.

Step 10. Observe the worker and file

The worker consuming tasks should report that it received and completed generate_hello_image. A matching PNG should appear beneath:

model-server/shared-data/generated/tutorial/

Because model-server/shared-data points to the common data directory, the same file should be visible beneath main/shared-data/generated/tutorial/.

Step 11. Check status

In PowerShell:

Invoke-RestMethod "http://localhost:3531/tutorial/hello-image/$($job.job_id)"

On macOS or Linux, replace the example job ID:

curl http://localhost:3531/tutorial/hello-image/hello-image-your-job-id

Expected output after completion

{
  "success": true,
  "job_id": "hello-image-...",
  "status": "completed",
  "image_url": "/tutorial/hello-image/hello-image-.../image"
}

Open the full image URL on port 3531. The generated PNG should render in the browser.

Step 12. Prove that the work is asynchronous

Stop only the worker that consumes tasks; leave Redis and the Model Server running. Enqueue a second job and check its status.

Expected output

{
  "success": true,
  "job_id": "hello-image-...",
  "status": "queued"
}

No matching PNG should exist yet. Restart the worker, check the same job ID again, and confirm that it becomes completed.

Group 3 checkpoint

One request returned 202, the worker generated the image afterward, the same job ID reported queued and then completed, and the image URL rendered the PNG.


Group 4: Protect the Model Server Boundary

The browser-facing integration must not leave the tutorial job endpoints public.

Step 13. Move the blueprint into /api

In model-server/src/routes/hello.py, change:

hello_bp = Blueprint("hello", __name__, url_prefix="/tutorial")

to:

hello_bp = Blueprint("hello", __name__, url_prefix="/api/tutorial")

Update the completed job response:

response["image_url"] = f"/api/tutorial/hello-image/{job_id}/image"

Update both URLs in tests/test_hello_route.py from /tutorial/... to /api/tutorial/..., then run the test and confirm that both cases still pass.

Step 14. Apply bearer authentication

In model-server/app.py, ensure hello_bp remains imported and registered. Add it to the protect_blueprints list:

protect_blueprints(app, [
    bluesky_bp, youtube_bp, reddit_bp,
    telegram_bp, tanbih_bp, marsad_bp,
    hello_bp,
], exempt_endpoints={"rq_dashboard.health"})

Restart the Model Server.

Step 15. Confirm direct access is rejected

Open:

http://localhost:3531/api/tutorial/hello

Expected output

401 Unauthorized

Do not paste the bearer token into a browser URL or commit it in tutorial code.

Step 16. Update the first Next.js proxy

Replace the origin helper and fetch call in main/src/app/api/tutorial/model-hello/route.ts with the configured /api base and bearer header:

function getModelServerConfig() {
    const baseUrl = process.env.MODEL_API_URL?.replace(/\/$/, '');
    const token = process.env.MODEL_API_TOKEN;

    if (!baseUrl || !token) {
        throw new Error('Model Server configuration is incomplete.');
    }

    return {baseUrl, token};
}

Inside GET, call:

const {baseUrl, token} = getModelServerConfig();
const response = await fetch(`${baseUrl}/tutorial/hello`, {
    cache: 'no-store',
    headers: {
        Authorization: `Bearer ${token}`,
    },
});

Open http://localhost:3511/api/tutorial/model-hello again.

Expected output

The Next.js URL still returns web_app: "connected" and the Model Server greeting, while the direct Model Server URL returns 401.

Group 4 checkpoint

The browser can reach the protected greeting only through Next.js. Explain where MODEL_API_TOKEN is read and why it never appears in the Client Component.


Group 5: Complete the Next.js Job Integration

Add server-side proxy routes, then evolve the 3A page into an interactive job interface.

Step 17. Add a shared Model Server helper

Create main/src/app/api/tutorial/_lib/model-server.ts:

export function getTutorialModelServerConfig() {
    const baseUrl = process.env.MODEL_API_URL?.replace(/\/$/, '');
    const token = process.env.MODEL_API_TOKEN;

    if (!baseUrl || !token) {
        throw new Error('Model Server configuration is incomplete.');
    }

    return {
        baseUrl,
        headers: {
            Authorization: `Bearer ${token}`,
        },
    };
}

Step 18. Add the enqueue proxy

Create main/src/app/api/tutorial/hello-image/route.ts:

import {NextResponse} from 'next/server';

import {getTutorialModelServerConfig} from '@/app/api/tutorial/_lib/model-server';


export const runtime = 'nodejs';


export async function POST(request: Request) {
    try {
        const payload = await request.json().catch(() => null);
        const text =
            typeof payload?.text === 'string'
                ? payload.text.trim()
                : '';

        if (!text || text.length > 80) {
            return NextResponse.json(
                {
                    success: false,
                    message: 'Text must contain between 1 and 80 characters.',
                },
                {status: 422},
            );
        }

        const {baseUrl, headers} = getTutorialModelServerConfig();
        const response = await fetch(`${baseUrl}/tutorial/hello-image`, {
            method: 'POST',
            headers: {
                ...headers,
                'Content-Type': 'application/json',
            },
            body: JSON.stringify({text}),
            cache: 'no-store',
        });

        return new NextResponse(await response.text(), {
            status: response.status,
            headers: {
                'Content-Type':
                    response.headers.get('content-type') ??
                    'application/json',
            },
        });
    } catch (error) {
        return NextResponse.json(
            {
                success: false,
                message:
                    error instanceof Error
                        ? error.message
                        : 'Could not enqueue the tutorial job.',
            },
            {status: 502},
        );
    }
}

Step 19. Add the status proxy

Create main/src/app/api/tutorial/hello-image/[jobId]/route.ts:

import {NextResponse} from 'next/server';

import {getTutorialModelServerConfig} from '@/app/api/tutorial/_lib/model-server';


export const runtime = 'nodejs';


export async function GET(
    _request: Request,
    context: {params: Promise<{jobId: string}>},
) {
    const {jobId} = await context.params;

    if (!/^hello-image-[a-f0-9]{32}$/.test(jobId)) {
        return NextResponse.json(
            {success: false, message: 'Invalid tutorial job ID.'},
            {status: 400},
        );
    }

    try {
        const {baseUrl, headers} = getTutorialModelServerConfig();
        const response = await fetch(
            `${baseUrl}/tutorial/hello-image/${jobId}`,
            {
                headers,
                cache: 'no-store',
            },
        );

        return new NextResponse(await response.text(), {
            status: response.status,
            headers: {
                'Content-Type':
                    response.headers.get('content-type') ??
                    'application/json',
            },
        });
    } catch {
        return NextResponse.json(
            {
                success: false,
                message: 'Could not read the tutorial job.',
            },
            {status: 502},
        );
    }
}

Step 20. Add the image proxy

Create main/src/app/api/tutorial/hello-image/[jobId]/image/route.ts:

import {NextResponse} from 'next/server';

import {getTutorialModelServerConfig} from '@/app/api/tutorial/_lib/model-server';


export const runtime = 'nodejs';


export async function GET(
    _request: Request,
    context: {params: Promise<{jobId: string}>},
) {
    const {jobId} = await context.params;

    if (!/^hello-image-[a-f0-9]{32}$/.test(jobId)) {
        return NextResponse.json(
            {success: false, message: 'Invalid tutorial job ID.'},
            {status: 400},
        );
    }

    try {
        const {baseUrl, headers} = getTutorialModelServerConfig();
        const response = await fetch(
            `${baseUrl}/tutorial/hello-image/${jobId}/image`,
            {
                headers,
                cache: 'no-store',
            },
        );

        if (!response.ok) {
            return new NextResponse(await response.text(), {
                status: response.status,
                headers: {
                    'Content-Type':
                        response.headers.get('content-type') ??
                        'application/json',
                },
            });
        }

        return new NextResponse(await response.arrayBuffer(), {
            status: 200,
            headers: {
                'Content-Type': 'image/png',
                'Cache-Control': 'no-store',
            },
        });
    } catch {
        return NextResponse.json(
            {
                success: false,
                message: 'Could not load the tutorial image.',
            },
            {status: 502},
        );
    }
}

Step 21. Test the enqueue proxy before building UI

In PowerShell:

$body = @{ text = "Hello from Next.js!" } | ConvertTo-Json
Invoke-RestMethod -Method Post -Uri http://localhost:3511/api/tutorial/hello-image -ContentType "application/json" -Body $body

On macOS or Linux:

curl -X POST http://localhost:3511/api/tutorial/hello-image \
  -H "Content-Type: application/json" \
  -d '{"text":"Hello from Next.js!"}'

Expected output

{
  "success": true,
  "job_id": "hello-image-...",
  "status": "queued"
}

The request went to port 3511; Next.js attached the credential and forwarded it to port 3531.

Step 22. Create the Client Component

Create main/src/app/(landing)/tutorial/hello-world/_components/hello-image-generator.tsx:

'use client';

import Image from 'next/image';
import {FormEvent, useEffect, useState} from 'react';


type TutorialStatus = 'idle' | 'queued' | 'processing' | 'completed' | 'failed';

type JobResponse = {
    success: boolean;
    job_id?: string;
    status?: Exclude<TutorialStatus, 'idle'>;
    message?: string;
};


export function HelloImageGenerator() {
    const [text, setText] = useState('Hello, Marsad!');
    const [jobId, setJobId] = useState<string | null>(null);
    const [status, setStatus] = useState<TutorialStatus>('idle');
    const [error, setError] = useState<string | null>(null);

    useEffect(() => {
        if (!jobId || !['queued', 'processing'].includes(status)) {
            return;
        }

        const timer = window.setInterval(async () => {
            try {
                const response = await fetch(
                    `/api/tutorial/hello-image/${jobId}`,
                    {cache: 'no-store'},
                );
                const data = (await response.json()) as JobResponse;

                if (!response.ok || !data.status) {
                    setStatus('failed');
                    setError(data.message ?? 'Could not read the job status.');
                    return;
                }

                setStatus(data.status);

                if (data.status === 'failed') {
                    setError(data.message ?? 'Image generation failed.');
                }
            } catch {
                setStatus('failed');
                setError('Could not reach the job status endpoint.');
            }
        }, 1000);

        return () => window.clearInterval(timer);
    }, [jobId, status]);

    async function handleSubmit(event: FormEvent<HTMLFormElement>) {
        event.preventDefault();
        setError(null);
        setJobId(null);
        setStatus('queued');

        try {
            const response = await fetch('/api/tutorial/hello-image', {
                method: 'POST',
                headers: {'Content-Type': 'application/json'},
                body: JSON.stringify({text}),
            });
            const data = (await response.json()) as JobResponse;

            if (!response.ok || !data.job_id) {
                setStatus('failed');
                setError(data.message ?? 'Could not enqueue the image job.');
                return;
            }

            setJobId(data.job_id);
            setStatus(data.status ?? 'queued');
        } catch {
            setStatus('failed');
            setError('Could not reach the Web App API.');
        }
    }

    return (
        <div className='mt-8 w-full max-w-2xl rounded-xl border p-6 text-left'>
            <form className='space-y-4' onSubmit={handleSubmit}>
                <div className='space-y-2'>
                    <label className='text-sm font-medium' htmlFor='hello-text'>
                        Image text
                    </label>
                    <input
                        id='hello-text'
                        value={text}
                        maxLength={80}
                        onChange={(event) => setText(event.target.value)}
                        className='w-full rounded-md border bg-background px-3 py-2'
                    />
                </div>

                <button
                    type='submit'
                    disabled={!text.trim() || ['queued', 'processing'].includes(status)}
                    className='rounded-md bg-primary px-4 py-2 text-primary-foreground disabled:opacity-50'
                >
                    Generate image
                </button>
            </form>

            <div className='mt-6 space-y-2' aria-live='polite'>
                <p>Status: {status}</p>
                {jobId ? <p className='break-all text-sm'>Job: {jobId}</p> : null}
                {error ? <p className='text-sm text-destructive'>{error}</p> : null}
            </div>

            {status === 'completed' && jobId ? (
                <Image
                    src={`/api/tutorial/hello-image/${jobId}/image`}
                    alt={`Generated image containing: ${text}`}
                    width={1200}
                    height={630}
                    unoptimized
                    className='mt-6 h-auto w-full rounded-lg border'
                />
            ) : null}
        </div>
    );
}

Step 23. Add it to the Server Component page

In main/src/app/(landing)/tutorial/hello-world/page.tsx, import:

import {HelloImageGenerator} from './_components/hello-image-generator';

Render it below the existing Hello Marsad text:

<HelloImageGenerator />

Keep page.tsx as a Server Component. The page retains its metadata export; only the interactive generator needs 'use client'.

Step 24. Run the complete flow

Open http://localhost:3511/tutorial/hello-world, enter a greeting, and select Generate image.

Expected output

The page visibly progresses through:

Status: queued
Job: hello-image-...

then:

Status: processing

and finally:

Status: completed

The generated PNG appears below the status.

Group 5 checkpoint

Generate a new image while watching the browser Network panel, the Model Server terminal, and the worker terminal. Show your onboarding partner the same job ID at each boundary.


Group 6: Exercise Failure Paths

Step 25. Reject invalid text

Submit an empty value directly to the Next.js API or temporarily remove the browser's disabled guard.

Expected output

{
  "success": false,
  "message": "Text must contain between 1 and 80 characters."
}

The response status is 422, and no job ID is created.

Step 26. Observe a stopped worker

Stop only the tasks worker and submit a valid job.

Expected output

The page remains at queued; it must not claim that the image completed. Restart the worker and confirm that the same job progresses to completed.

Step 27. Confirm authentication

Call the Model Server job endpoint directly without a bearer header:

POST http://localhost:3531/api/tutorial/hello-image

Expected output

401 Unauthorized

Submitting through the Next.js page still succeeds because the Next.js server attaches MODEL_API_TOKEN.

Step 28. Run focused validation

From model-server/:

pytest tests/test_hello_route.py tests/test_hello_image_task.py

From main/:

npx tsc --noEmit

Group 6 checkpoint

Valid work completes, invalid text returns 422, direct unauthenticated access returns 401, and a stopped worker leaves the job honestly queued until the worker returns.


Group 7: Explain and Clean Up

Before removing anything, explain:

  1. Why the browser calls Next.js rather than the Model Server.
  2. Why enqueue returns 202 instead of waiting for the PNG.
  3. How the job ID connects the HTTP request, Redis, worker, status endpoint, and output.
  4. Why the browser cannot provide an output path.
  5. Why a Client Component is needed only for the interactive generator.
  6. How this polling tutorial differs from Marsad's persistent jobs and authenticated completion callbacks.

Then remove only the tutorial artifacts:

Web App

  • src/app/(landing)/tutorial/hello-world/
  • src/app/api/tutorial/model-hello/
  • src/app/api/tutorial/hello-image/
  • src/app/api/tutorial/_lib/model-server.ts if the _lib folder contains nothing else

Model Server

  • src/routes/hello.py
  • src/models/hello_image_task.py
  • tests/test_hello_route.py
  • tests/test_hello_image_task.py
  • the hello_bp import, registration, and protection entry in app.py

Generated data

  • only the PNG files created beneath shared-data/generated/tutorial/

Do not recursively remove shared-data/generated/, src/routes/, src/models/, tests/, or the parent Next.js tutorial folders if they contain unrelated work.

Run git status --short in the Marsad repository root. No Hello tutorial source file or registration change should remain.

Final Checkpoint

You have completed 3C when you can draw the full request path, identify the owner of every state transition, reproduce both success and failure, and return the working tree to its pre-tutorial state.

Next: trace a production analysis workflow →

On this page

Integrate a Hello Image-Generation JobStarting CheckpointGroup 1: Connect the Two Hello WorldsStep 1. Create the Next.js proxyStep 2. Call the proxyExpected outputStep 3. Link the result from the Hello pageGroup 1 checkpointGroup 2: Generate a PNG Without RedisStep 4. Create the pure image taskStep 5. Test the function in isolationExpected outputStep 6. Create a visible previewExpected outputGroup 2 checkpointGroup 3: Turn the Function Into an RQ JobStep 7. Extend the Flask blueprintStep 8. Test enqueueing without a workerExpected outputStep 9. Enqueue the real jobExpected outputStep 10. Observe the worker and fileStep 11. Check statusExpected output after completionStep 12. Prove that the work is asynchronousExpected outputGroup 3 checkpointGroup 4: Protect the Model Server BoundaryStep 13. Move the blueprint into /apiStep 14. Apply bearer authenticationStep 15. Confirm direct access is rejectedExpected outputStep 16. Update the first Next.js proxyExpected outputGroup 4 checkpointGroup 5: Complete the Next.js Job IntegrationStep 17. Add a shared Model Server helperStep 18. Add the enqueue proxyStep 19. Add the status proxyStep 20. Add the image proxyStep 21. Test the enqueue proxy before building UIExpected outputStep 22. Create the Client ComponentStep 23. Add it to the Server Component pageStep 24. Run the complete flowExpected outputGroup 5 checkpointGroup 6: Exercise Failure PathsStep 25. Reject invalid textExpected outputStep 26. Observe a stopped workerExpected outputStep 27. Confirm authenticationExpected outputStep 28. Run focused validationGroup 6 checkpointGroup 7: Explain and Clean UpWeb AppModel ServerGenerated dataFinal Checkpoint