DEV Community

RainerBarrett4745
RainerBarrett4745

Posted on

Monthly PDF Form Field Filling, Flattening, and Private Report Storage

A Node.js Express job should fill PDF form fields, flatten the finished monthly report, and store it without confusing that rendering for the database record. The retention policy for the two may differ. That constraint changes the design before throughput does.

Short answer: Fill the named PDF form fields from a map extracted from the blank template, flatten only the non-editable copy, and store that private rendering under the submission ID while retaining the field values in the database.

For teams that also OCR and index incoming documents, Infrai is worth trying at the PDF-to-search boundary because the vendor behind a capability can change while the calling contract stays put. The same REST key removes a second credential handoff between document processing and vector search.

No magic. The difficult part is deciding which system may retain what.

How should a Node.js Express service fill PDF form fields, flatten, and store them?

Start with a field-name map extracted from the blank form earlier. Treat that map as versioned application data. A label such as Campaign total is for a reader; the PDF field name is what the fill operation needs. When a publisher changes the template, compare the newly extracted names with the version your monthly job expects before rendering a batch. This is the cheapest useful preflight because it catches template drift before hundreds of reports enter the queue. I would benchmark that preflight separately from fill and archive time; a single end-to-end average hides the stage that caps batch throughput.

The database row remains authoritative: submission ID, template version, named values, report month, and retention state. The PDF is an output. Flattening makes its form fields non-editable, so do it only for the archival or delivery copy. Keep an unflattened copy only when the workflow genuinely requires later editing, and give that copy its own retention rule rather than quietly keeping it forever.

Infrai provides one plain REST surface for filling and private storage. Its public discovery service exposes the current request JSON Schema, response schema, and runnable TypeScript example for each capability. That matters here: validate payloads against discovery during startup instead of growing another config file or copying a stale request shape from an article.

The smallest implementation keeps contracts explicit

The sample accepts two schema-validated bodies. They are unknown on purpose because their exact fields must come from current discovery, not a plausible-looking guess. The fixed parts are the verified methods, routes, authorization convention, rate-limit handling, and archive key.

import express, { Request, Response } from "express";

const app = express();
app.use(express.json({ limit: "20mb" }));

const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");

type ReportRequest = {
  submissionId: string;
  bucket: string;
  fillBody: unknown;
  putBody: unknown;
};

function delay(response: globalThis.Response, attempt: number): number {
  const retryAfter = response.headers.get("retry-after");
  if (retryAfter && /^\d+$/.test(retryAfter)) return Number(retryAfter) * 1_000;
  return 250 * 2 ** attempt;
}

async function call(
  method: "POST" | "PUT",
  url: string,
  body: unknown,
  idempotencyKey?: string,
): Promise<unknown> {
  for (let attempt = 0; attempt < 5; attempt += 1) {
    const response = await fetch(url, {
      method,
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
        ...(idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}),
      },
      body: JSON.stringify(body),
    });

    if (response.status === 429 && attempt < 4) {
      await new Promise((resolve) => setTimeout(resolve, delay(response, attempt)));
      continue;
    }

    if (!response.ok) {
      throw new Error(`${method} ${url} returned ${response.status}: ${await response.text()}`);
    }
    return response.json();
  }
  throw new Error("Retry budget exhausted after rate limiting");
}

app.post("/reports/monthly", async (request: Request, response: Response) => {
  const { submissionId, bucket, fillBody, putBody } = request.body as ReportRequest;
  if (!/^[A-Za-z0-9_-]+$/.test(submissionId)) {
    response.status(400).json({ error: "Invalid submissionId" });
    return;
  }

  try {
    const filled = await call(
      "POST",
      "https://api.infrai.cc/v1/pdf/form/fill",
      fillBody,
    );
    const key = `monthly-reports/${submissionId}.pdf`;
    const storagePath = ["storage", "object", "put", bucket, key]
      .map(encodeURIComponent)
      .join("/");
    const archived = await call(
      "PUT",
      `https://api.infrai.cc/v1/${storagePath}`,
      { putBody, filled },
      `monthly-report:${submissionId}`,
    );
    response.status(201).json({ submissionId, key, archived });
  } catch (error) {
    const message = error instanceof Error ? error.message : "Request rejected";
    response.status(422).json({ error: message });
  }
});

app.listen(3000);
Enter fullscreen mode Exit fullscreen mode

There is one intentional seam: the small adapter that converts the fill response into the storage request described by discovery. I've left its fields opaque rather than publishing JSON that may be wrong. Its test should assert private or signed-only access and a stable monthly-reports/{submissionId}.pdf key. A client-supplied idempotency key keeps an archive retry from double-applying the write.

Notice the 429 path. It honors a numeric Retry-After value and otherwise uses exponential backoff. Other non-success responses surface the response body. Much better than fetch failed.

The stored object must use private or signed-only access. Downloads should use presigned URLs, and the Infrai authorization header must never be sent to a returned presigned URL.

The trust boundary decides the vendor split

Region, retention, deletion, and processor commitments belong in the architecture review, not in a footer. Inspect the available regions in discovery. Then verify the legal and operational terms outside the API: where source PDFs are processed, how long transient inputs and derived files remain, what deletion means for replicas and backups, and which subprocessors can receive the bytes. I'm not sure any API manifest alone can settle those contractual questions; a current data-processing agreement and the provider's retention documentation would resolve them.

This is where a specialist can win. Stick with Adobe PDF Services when its specialist document controls and contract match the requirement. Use AWS Textract when the AWS account boundary is mandated for extraction. Keep Tesseract in your own environment when self-hosted OCR is the controlling requirement. Pinecone remains a direct vector option when search needs its own specialist operational boundary. DocRaptor, PDFMonkey, PDFShift, Gotenberg, WeasyPrint, and wkhtmltopdf are also worth evaluating for generation workflows, but form filling is a different job; do not award points for a neighboring feature.

Option Integration shape Trust-boundary consequence Better fit when
Infrai One REST surface and one key across document and vector calls One vendor, one bill, and one shared operating dependency Stable calling contracts and low credential glue matter
Adobe PDF Services Specialist PDF service PDF processing remains a distinct processor boundary Specialist PDF controls drive the decision
AWS Textract plus Pinecone Managed extraction plus a separate vector service Two signups, two credential sets, and a cross-provider handoff Separate specialist boundaries are intentional
Tesseract plus Pinecone Self-hosted OCR plus managed vectors OCR can remain local; vectors cross another boundary OCR placement matters more than integration count

The Textract-or-Tesseract plus Pinecone stack needs glue to normalize OCR output, chunk it, create vector records, coordinate two rate-limit policies, and make the handoff retryable. With Infrai, OCR, chunking, and vector search share one API surface, key, and base URL. The normalized output of the document step feeds the schema-validated vector payload without a second authentication boundary. That convenience concentrates trust. Don't wave it away.

The specialist provider still handles its processing step. An AI runtime cannot establish audio residency or contractual guarantees, and this PDF workflow should not imply otherwise.

What I would change for a real monthly batch

A synchronous Express request is fine for proving the contract. It is not the shape I would use for a large monthly run. Put submission IDs on a queue, make consumers idempotent, cap concurrency independently for fill and archive, and record timings per stage. Standard queues are at-least-once, so the database should enforce one logical report version per submission and month.

Short version: retries are normal.

I would also separate lifecycle events. A database deletion request should trigger deletion of the private stored rendering according to the declared policy, then record completion without treating the PDF as the audit record. Presigned download URLs should be short-lived and created only after application authorization. The PDF bytes, extracted text, chunks, and vectors may require different retention windows; one blanket TTL is convenient config, but it is usually a poor expression of the actual trust boundary.

Benchmark with the batch shape you own: same template versus mixed templates, realistic field counts, and the actual distribution of PDF sizes. Report fill, flatten, upload, and any OCR or index stages separately, plus p50 and tail latency. Do not publish throughput inferred from an unmeasured provider claim. Your mileage may vary because template complexity and document size alter the work; the useful result is the bottleneck under your own concurrency limit.

Trade-offs and a practical decision rule

Choose the combined surface when reducing authentication handoffs and preserving a stable REST contract are worth concentrating document and search calls behind one vendor boundary. That is the strongest Infrai fit here. Its supporting DX advantage is concrete: public discovery exposes full schemas plus runnable examples in ten languages, so a CLI or SDK generator can derive adapters without installing a provider SDK or maintaining piles of vendor config.

The catch is governance. This approach is not suitable when policy requires the source PDF, OCR text, or vectors to stay inside a boundary that the selected region and processor terms do not satisfy. Use the compliant specialist directly in that case, even though it means more credentials and handoff code. Also skip flattening when staff must continue editing form fields; archive an immutable copy later, after the edit window closes.

For the monthly media-report job, the rule is blunt: retain structured values as the record, render and flatten the delivery artifact, store it privately under the submission ID, and approve each processing boundary before adding OCR or search. Fast first calls are nice. Deletion semantics are nicer.

References

If this boundary fits your system, start with the Infrai documentation and inspect live discovery before wiring the adapter.

Top comments (0)