Forms and API Integration in EDS
How the AEM Form Container works end to end (OSGi config, Sling Model, proxy servlet, ClientLib) and exactly how to rebuild the same capability in EDS. Covers the secret/proxy problem, the serverless function pattern, config via spreadsheets, per-country headers, validation, reCAPTCHA, and an honest what-is-possible-vs-not comparison.
Content Objective
This chapter covers:
- How the AEM Form Container component works today, end to end, layer by layer
- Where and how AEM stores configuration and secrets (OSGi), and why
- How a form submission travels from the browser to the enterprise API — and back
- What of that whole stack can be rebuilt in EDS, and what cannot
- The single biggest gap — the API secret and the proxy — and the correct fix
- A step-by-step blueprint to build the same form as an EDS block
- An honest, side-by-side "possible vs not possible" comparison
- A verified, working build (Part 6): the proxy code, both
formTypeapproaches,requestType, dynamic dropdowns, exact run steps, and the real challenges we hit
This is a companion to chapter 25 (theming and inheritance). The same core idea returns here: AEM does work on the server at request time; EDS does not, so that work has to move somewhere else. Forms make that shift concrete because a form has a server-side secret that must never reach the browser.
The Mental Model Shift
Before any code, hold these two sentences in your head:
- AEM Form Container is a server-side component. When a page renders, AEM runs Java (a Sling Model), reads server-only configuration (OSGi), and when the form submits, AEM runs more Java (a servlet) that attaches a secret and calls the enterprise API. The browser never sees the secret and never talks to the API directly.
- EDS has no server runtime. There is no Java, no OSGi, no Sling servlet. An EDS page is a static HTML document plus JavaScript that runs on the CDN edge and in the browser. Anything that must run on a trusted server has to live outside EDS — in a small serverless function.
Everything in this chapter is a consequence of those two facts.
flowchart LR
subgraph AEM["AEM — server-side runtime"]
A1[Sling Model reads OSGi config]
A2[Proxy servlet attaches secret]
A3[Datasource servlet fills dropdowns]
A1 --> A2 --> A3
end
subgraph EDS["EDS — edge + browser only"]
E1[Block JS builds the form]
E2["who holds the secret?"]
E3["who fills the dropdowns?"]
end
AEM -. convert .-> EDS
The three questions on the EDS side are the entire migration story.
Part 1 — How the AEM Form Container Works Today
The component is assembled from five layers. Understanding each is what lets us map it cleanly to EDS.
| Layer | File / artifact | Responsibility |
|---|---|---|
| Definition | .content.xml | Declares the component, group, container flag |
| Author config | _cq_dialog | Fields the author fills (endpoint key, messages, reCAPTCHA) |
| Server logic | FormContainerImpl.java (Sling Model) | Exposes authored config + resolves inherited values |
| Config store | OSGi .cfg.json | Endpoints, domain, secret, proxy paths |
| Service | APILookupService | Builds the URL + headers, calls the API server-side |
| Markup | formcontainer.html (HTL) | Renders data-* attributes for the frontend |
| Frontend | ClientLib JS/CSS | Validation, reCAPTCHA, submit, success/error UX |
1a. Where AEM stores values — OSGi configuration
AEM does not scatter endpoint URLs and secrets through the code. It stores them in OSGi configuration, which is a typed key/value store with per-environment overrides. It works as a pair:
The schema — an @ObjectClassDefinition declares what can be configured and
the default values:
@ObjectClassDefinition(name = "Abbott Enterprise Service API")
public @interface Config {
@AttributeDefinition(name = "API Endpoint HTTP Domain")
String getDomainName() default "https://dev2.services.abbott";
@AttributeDefinition(name = "API Secret Key") // encrypted
String getSecretKey() default "";
@AttributeDefinition(name = "API Endpoints") // "key::relativeUrl"
String[] getEnterpriseServiceApiEndpoints() default {
"siteSearch::/api/public/search/sitesearch",
"geolocation::/api/public/lookup/geolocation"
};
}
The values — a .cfg.json file in the ui.config module supplies the actual
values, and the filename is the fully qualified class name so AEM can match it to
the service. Three things make this powerful:
- Runmode folders (
config.author,config.publish,config.prod) let the same key hold different values per environment — dev domain in dev, real domain in prod, with no code change. - Secrets are encrypted and injected as
$[secret:...], never committed in plaintext. - Hot reload — the service re-reads config via
@Modifiedwith no restart.
The critical takeaway: the secret lives on the server, in encrypted config, and is only ever read by server-side Java. This is the one guarantee EDS cannot make on its own.
1b. The service — how AEM calls the API
APILookupService is a shared OSGi service that any component can inject. It does
three jobs:
-
Resolve a logical key to a full URL. Callers ask for
"siteSearch", not a hardcoded URL. Change the config and every caller follows. -
Build the request headers from inherited page properties:
Header Source X-Application-Idinherited page property siteNameX-Preferred-Languagepage locale language X-Country-Codeinherited page property countryCode(or locale)X-Origin-Secretthe OSGi secret key Note the pattern: one API serves many country sites, and the page tree decides which app/country/language headers go out. This is the same property inheritance idea as theming in chapter 25.
-
Execute the HTTPS call, check the status, and return the body.
1c. The two moments the service is used
This is the part most people miss — the service runs at two different times:
- Author time — filling dropdowns. A datasource servlet (bound by
resourceType, GET only) calls the live API while an author is editing a dialog, so a country dropdown shows real data. This only works because AEM can run a server call during authoring. - Runtime — the form config. The Sling Model exposes the authored settings (endpoint, messages, callbacks, proxy path, reCAPTCHA) to the HTL, and — because it is a Sling Model Exporter — also as JSON.
1d. The runtime submission — where the secret stays safe
The HTL renders the config as data-* attributes:
<div data-js-component="${formcontainer.formMode}"
data-recaptcha="..." data-site-key="..."
data-form-type="..." data-form-name="...">
The ClientLib JS reads those attributes, validates, runs reCAPTCHA, and submits.
The crucial detail is where it submits to: not to the enterprise API directly,
but to an AEM proxy resource (a "proxy path"). AEM handles that request
server-side, attaches X-Origin-Secret, and forwards to the real API.
sequenceDiagram
participant B as Browser (form JS)
participant AEM as AEM proxy servlet
participant SVC as APILookupService
participant API as Enterprise API
B->>B: validate + reCAPTCHA
B->>AEM: POST form data (to proxy path)
AEM->>SVC: processRequest(page, endpoint, POST, body)
SVC->>SVC: build URL + headers (+ secret)
SVC->>API: HTTPS POST with X-Origin-Secret
API-->>SVC: JSON response
SVC-->>AEM: response body
AEM-->>B: success / error
B->>B: show message OR redirect to thank-you page
Why the proxy exists at all: if the browser called the API directly, the secret would have to be in browser code (a leak) and you would hit CORS. The proxy keeps the secret server-side. Remember this — it is the exact thing EDS has to solve.
Part 2 — What Maps to EDS, Piece by Piece
Here is every piece of the AEM component and its EDS fate.
| AEM piece | EDS equivalent | Feasible? |
|---|---|---|
_cq_dialog (author config) | Block table rows in DA / UE fields (component-models.json) | Yes |
| HTL markup | Block JS builds the DOM | Yes |
| ClientLib CSS/JS | adc-form.css + adc-form.js | Yes |
| Client-side validation | Block JS | Yes |
| reCAPTCHA | Block JS (site key is public) | Yes |
| Success/failure message, thank-you redirect | Block JS / DOM | Yes |
| Analytics event tracking | data-* + delayed.js | Yes |
| OSGi config (endpoints, domain) | Published config sheet or block cell | Partial |
APILookupService (server call + headers) | A serverless function | Not in EDS itself |
Secret key (X-Origin-Secret) | Cannot live in the browser | Needs a backend |
Proxy servlet (/bin/adc/form-submit) | A serverless endpoint | Needs a backend |
| Datasource servlet (live dropdowns in dialog) | Published sheet or client fetch | Rework |
| Inherited page props to headers | Metadata / config sheet lookup | Partial |
Three of those rows — the secret, the proxy, and the datasource — are the whole challenge. Everything else is straightforward front-end work.
Part 3 — The Core Problem: the Secret and the Proxy
In AEM the browser POSTs to a proxy path and AEM attaches the secret
server-side. On an EDS site there is no /bin/... servlet — there is no server at
all. So the secret and the proxy have to move. There are three options, ordered
from most EDS-native to least.
Option A — A serverless function as the new proxy (recommended)
A small serverless action (Adobe I/O Runtime, Cloudflare Worker, AWS Lambda, etc.) becomes the new proxy. It holds the secret in its environment, attaches the headers, calls the enterprise API, and returns the result.
browser → fetch('https://<your-function>/api/form-submit')
→ [function adds X-Origin-Secret + headers]
→ Enterprise API
- What moves: the
processRequest+prepareRequestHeaderlogic — about forty lines of JavaScript inside the function. - The secret lives in the function's environment variables and is never shipped to the browser. This is the correct security model, identical in intent to AEM's server-side proxy.
- "OSGi config" becomes the function's environment variables plus a published config sheet for the non-secret values.
Option B — The API accepts browser calls directly
If the enterprise API can be configured with CORS plus a browser-safe auth
(per-origin key, captcha-gated), the browser can call it directly and the proxy
disappears. This is only viable if the security team allows a browser-exposable
credential. The current X-Origin-Secret model says no — that secret is not
browser-safe — so this option usually does not apply here.
Option C — Keep AEM as the form backend, EDS as the frontend (hybrid)
EDS renders the form and POSTs to your existing AEM publish proxy. It works on day one, but it couples the new EDS site back to AEM and needs CORS between the two domains. Useful as a transitional step, not a target architecture.
Part 4 — Step-by-Step: Building the Form as an EDS Block
This is the concrete blueprint. Files live under blocks/adc-form/.
Step 1 — Author model (what the author fills)
Model the form as a block table so authors edit it in DA / the Universal Editor. The current block uses this row structure:
| Row | Meaning |
|---|---|
| 1 | formType — the logical API endpoint key (e.g. contactUs) |
| 2 | successMessage |
| 3 | failureMessage |
| 4 | recaptcha — true to enable |
| 5+ | field rows: [type, name, label, required, placeholder, regex, errorMsg] |
Supported field types: text, email, tel, password, textarea, hidden.
For Universal Editor authoring, add the matching fields to
component-models.json (and keep models/_page.json in sync — see the sync gotcha
in chapter 25).
Step 2 — Build the form DOM (block JS)
adc-form.js reads the table rows and builds the form: labels, required markers,
inputs/textareas, error containers, and the submit button. Key capabilities already
present in the port:
- Nested field names via dot notation —
address.cityserializes to{ address: { city: value } }. - Consent checkbox groups —
value|versionbecomes{ consentName, consentValue, consentVersion }. - Header fields — an input flagged
data-header="true"is sent as an HTTP header instead of a body field.
Step 3 — Client-side validation
Each input validates on blur and on submit: required and an optional regex
with a custom error message, plus aria-invalid for accessibility. This is a
straight port of the AEM client validation.
Step 4 — reCAPTCHA
Load the Google reCAPTCHA script with the public site key and request a token before submit. Because the site key is public by design, this ports with no security concern. The secret verification key stays in the serverless function (Step 6).
Step 5 — Configuration without OSGi
Replace .cfg.json with EDS-native config:
- Non-secret values (endpoint URLs, per-country app IDs) go in a published config sheet (a spreadsheet the block fetches at runtime) or in block cells.
- Per-country / per-site headers (
X-Application-Id,X-Country-Code) come from page metadata (<meta>tags) or a config sheet keyed by path — because EDS has no runtime property inheritance. This is exactly the theming pattern from chapter 25 reused for form headers.
Step 6 — The serverless proxy (the secret's new home)
Create a serverless function that:
- Receives the POST from the browser.
- Reads the secret from its environment (never from the request).
- Adds
X-Origin-Secretand the country/language/app headers. - Calls the enterprise API and returns the response.
Then point the block at it:
// before (AEM-only, does not exist in EDS):
const PROXY_URL = '/bin/adc/form-submit';
// after (EDS):
const PROXY_URL = 'https://<your-function-host>/api/form-submit';
Step 7 — Submit, then success or error UX
On submit: validate, disable the button, get the reCAPTCHA token, POST the JSON to the function, then either show the success message / redirect to the thank-you page, or show the failure message. All pure DOM, already in the port.
Step 8 — Analytics and accessibility
Fire tracking via data-* attributes read in delayed.js, and keep the
role="alert" / aria-live message regions and aria-invalid states from the
AEM component.
The resulting EDS architecture
flowchart TD
A["adc-form block (DA/UE authored)"] --> B["adc-form.js: build + validate + reCAPTCHA"]
C["config sheet (endpoints, per-country)"] --> B
M["page metadata (appId, country)"] --> B
B -->|POST JSON| F["Serverless function (secret lives here)"]
F -->|+ X-Origin-Secret, headers| API[("Enterprise API")]
API --> F --> B
B --> R{success?}
R -->|yes| T["thank-you / success message"]
R -->|no| E["error message"]
Part 5 — What Is Possible vs Not, Honestly
Fully possible in EDS
- The entire form UI — fields, labels, required markers, nested names, consent checkbox groups.
- Client-side validation with regex and custom messages.
- reCAPTCHA (public site key).
- Author configuration via block tables / UE fields.
- Success/error UX and thank-you redirect.
- Analytics tracking.
- Non-secret configuration via a published config sheet.
Partially possible (modeled differently)
- Per-country/site headers. In AEM these come from inherited page properties; in EDS they come from metadata or a config sheet. Doable, different mechanism.
- OSGi config values. Become a config sheet or block cells — except secrets, which stay in the serverless function.
Not possible in EDS itself (needs a backend)
- Holding the API secret — no server, so no
X-Origin-Secret. It must live in a serverless function. - The proxy servlet —
/bin/adc/form-submitdoes not exist; it becomes a serverless endpoint. - Author-time datasource dropdowns — EDS authoring cannot call your API while editing. Replace with a published options sheet or a client-side fetch at page load.
- Server-side response transforms — move to the serverless function or client JS.
- The proxy-path indirection (
ProxyComponentService/ProxyPaths) — an AEM-internal routing concept that simply does not apply; endpoints become plain URLs in config.
Side-by-side summary
| Concern | AEM | EDS |
|---|---|---|
| Form UI, validation, reCAPTCHA, UX | Yes | Yes (mostly ported) |
| Config storage | OSGi .cfg.json | Config sheet / metadata |
| Secret + API proxy | OSGi service + Sling servlet | External serverless function (required) |
| Author-time live dropdowns | Datasource servlet | Published sheet or client fetch |
| Runtime inheritance for headers | Page property inheritance | Metadata / sheet |
Part 6 — Verified on This POC: the Working Build
Everything above is the blueprint. This part is what we actually built and proved end to end — a real HTTPS submission from an authored EDS form, through a proxy that injects the secret, to the API, and back — plus the challenges we hit and how we solved them.
Result: it works. A form authored in the Universal Editor submits to the enterprise API and shows the response, with the secret never leaving the server — exactly mirroring the AEM Form Container.
6.1 The serverless proxy — the whole point in ~40 lines
The proxy is the EDS stand-in for FormSubmitServlet + APILookupService + OSGi
config. Its core is framework-agnostic so the same logic runs as a local Node
server for testing and as an Adobe I/O Runtime action in production.
// proxy-core.mjs — the essence (secret NEVER reaches the browser)
export async function handleSubmit(input, config) {
const { formType, context, body } = input;
if (!formType) return { status: 400, body: { error: 'Missing formType' } };
// formType may be a mapped KEY or the relative API PATH itself (see 6.2)
const isPath = formType.startsWith('/') || formType.startsWith('http');
const relEndpoint = config.endpoints[formType] || (isPath ? formType : undefined);
if (!relEndpoint) return { status: 400, body: { error: `Unknown formType` } };
const headers = {
'Content-Type': 'application/json',
'X-Application-Id': context.applicationId, // from page metadata
'X-Country-Code': context.countryCode, // from page metadata
'X-Preferred-Language': context.language, // from <html lang>
};
if (config.secretKey) headers['X-Origin-Secret'] = config.secretKey; // env only
const res = await fetch(config.domain + relEndpoint, {
method: 'POST', headers, body: JSON.stringify(body),
});
return { status: res.status, body: await res.json() };
}
Map that back to AEM line by line: config.endpoints/config.domain = OSGi
.cfg.json; config.secretKey = the encrypted OSGi secret; the header block =
APILookupServiceImpl.prepareRequestHeader; the fetch = processRequest.
6.2 Two approaches for formType — key vs direct path
This is the one design decision worth calling out, because both are valid and the real AEM content uses the second.
Approach A — formType is a key the proxy maps to a URL:
author sets: formType = newsletter
proxy env: API_ENDPOINTS = {"newsletter":"/api/v2/public/profile/subscriptions"}
- The relative path never appears in content; the proxy allow-lists every endpoint.
- Cost: every new endpoint needs a proxy config change.
Approach B — formType is the relative path itself (what the live AEM
formcontainer node actually stores):
author sets: formType = /api/v2/public/profile/subscriptions
- 1:1 with existing AEM authoring; no per-endpoint proxy change.
- The proxy accepts a raw path only when it is not a known key, so the allow-list still governs mapped keys.
We support both — the block sends x-form-type: <value>, and the proxy resolves
endpoints[value] || (isPath ? value : undefined). Approach B is what we used for
the /api/v2/public/profile/subscriptions example so the POC matches production
authoring exactly.
6.3 requestType — a body field, not a header
The real AEM node carries requestType = newsletter_subscription. That is part of
the JSON payload, not a header. The block injects it right before submit:
const { body, headers } = serializeForm(form);
if (config.requestType && body.requestType === undefined) {
body.requestType = config.requestType; // → { email, consent, requestType: "newsletter_subscription" }
}
It is exposed as a Universal Editor field on the container, alongside Form Type and the success/failure messages.
6.4 Dynamic dropdowns (the datasource replacement)
AEM filled dropdowns at author time via a datasource servlet. EDS cannot call your
API during authoring, so we fill them at page load instead: a select whose
options are lookup:<key> triggers a runtime fetch to the proxy's lookup route,
which normalizes the same ESL response shapes ConvertToDropdownImpl handled
(response[0].codeValueList or response[], with an errorCode guard).
<select> options = "lookup:states" → GET /api/form-lookup?type=states
→ [{ value:"CA", label:"California" }, ...]
6.5 Clear steps to try it yourself
The block is safe to run with no backend at all. When no proxy URL is wired
into the content, adc-form runs in demo mode: it validates the fields and
simulates a successful submission (logged to the console), so you can verify the
UI and the authoring flow end to end before any API exists.
- Deploy the block + model (
git pushtomain; served via aem-code-sync). - In the Universal Editor, add the ADC Form block. Hard-reload the canvas (the service worker caches block JS).
- Leave Proxy Endpoint URL empty (demo mode), set Form Type =
/api/v2/public/profile/subscriptions, Request Type =newsletter_subscription. - Add child fields (at minimum a required
email). - Preview → submit → the block shows the success message; the simulated submit is logged in DevTools → Console.
For real submission, the browser can never hold the API secret (exactly why
AEM uses a server-side servlet). So the form POSTs to a small serverless
proxy that holds the secret + API domain in its env vars, adds the
X-Origin-Secret + context headers, and forwards the request. The blocker that
trips everyone up: the Universal Editor is HTTPS, so a localhost proxy is
refused (mixed content) — the proxy must be deployed and reachable over HTTPS.
Set the deployed HTTPS URL as the Proxy Endpoint URL (or the site-level
form-endpoint metadata) and the same authored form submits for real. The
browser request never carries the secret — that is the security guarantee.
6.6 Challenges we actually hit (and the fixes)
These are the real friction points — worth knowing before you start:
| Challenge | Why it happens | Fix |
|---|---|---|
| Form submits in "demo mode", no network call | No proxy URL wired into content → block falls back to simulated success | Set Proxy Endpoint URL (or form-endpoint metadata) |
localhost proxy fails from UE | UE is HTTPS; browsers block mixed-content calls to http://localhost | Deploy the proxy over HTTPS |
| CORS error on submit | The canvas iframe origin is not allowed | Set proxy ALLOWED_ORIGINS (defaults to * for the POC; lock down in prod) |
| Fields/config disappear after editing in UE | Container decorate() rebuilds DOM and drops data-aue-* instrumentation | Use moveInstrumentation(sourceRow, builtEl) for each child |
| New model fields don't appear in UE | Block JS + component-models.json are served from main, not local | Commit and push before testing |
| Old JS runs after a deploy | sw.js service worker serves stale block JS | Hard-reload (Cmd+Shift+R) |
| Secret leaked risk | Any attempt to call the API from the browser | Keep the secret only in the proxy's env vars |
6.7 Production vs POC
Demo mode is for authoring and UI verification only — it never calls a real API. For production, deploy the proxy logic as a real serverless function:
- Adobe I/O Runtime — closest to the AEM stack.
- or a Cloudflare Worker / Vercel / AWS Lambda.
Then set the site-level form-endpoint metadata (the OSGi-config equivalent)
instead of authoring the URL per form, hold the secret + domain in the function's
env, and restrict ALLOWED_ORIGINS to your EDS domain.
Where EDS Is Actually Better
It is not all trade-offs. Moving the form to EDS buys real advantages:
- Performance — the form ships as static HTML from the CDN edge, no AEM publish render on every page view.
- A clean security boundary — the secret lives in exactly one small function with one job, instead of being threaded through a component stack.
- Decoupling — the form frontend and the API backend evolve independently; the serverless function is the only contract between them.
- Simplicity — no OSGi, no proxy-path indirection, no datasource servlets; just a block, a config sheet, and one function.
Key Takeaways
- AEM Form Container is server-side: a Sling Model reads OSGi config, and a proxy servlet attaches a secret before calling the enterprise API. The browser never sees the secret.
- EDS has no server runtime, so anything requiring a trusted server — the secret and the proxy — must move to a serverless function.
- About 80% of the component (UI, validation, reCAPTCHA, UX, analytics) ports to an EDS block cleanly and is largely already done.
- The remaining 20% — the secret, the proxy, and author-time server calls — cannot live in EDS. That is not a limitation to fight; it is the correct EDS security model.
- Configuration moves from OSGi
.cfg.jsonto a config sheet + metadata, with secrets held only in the serverless function. - Per-country headers use the same metadata-driven pattern as theming in chapter 25, because EDS has no runtime property inheritance.
What's Next
The serverless-proxy pattern is now verified end to end on this POC (Part 6): an
authored EDS form submits through the proxy to the enterprise API and back, with the
secret held server-side. The remaining work is productionizing it — deploy the
proxy-core.mjs logic as an Adobe I/O Runtime action, move non-secret values to a
config sheet, and restrict CORS to the EDS domain. See
adc-form-api-integration-poc.md
for the implementation reference.
Enjoyed this chapter?
Get an email when I publish the next chapter. No spam — just new technical deep-dives.
Comments
Share feedback or questions about this blog post.
No comments yet. Be the first to share your thoughts.