Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
11 changes: 11 additions & 0 deletions .changeset/submit-without-schema.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@formrelay/core": minor
"@formrelay/vue": minor
"@formrelay/nuxt": minor
---

Enable form submission without fetching a schema. `publicKey` is now optional on `createForm` — when omitted, `submit()` constructs the URL from `formId` and the API base URL.

New optional `botProtection` and `honeypotField` options on `createForm`, `useFormRelay`, and the `<FormRelay>` component allow SDK-managed bot protection and honeypot without a schema fetch.

Users who handle bot protection and honeypot entirely manually can omit these options and include the fields directly in form values.
61 changes: 61 additions & 0 deletions packages/core/src/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,4 +122,65 @@ describe("createForm", () => {
expect.any(Object),
);
});

test("submit without publicKey uses constructed URL", async () => {
const client = createMockHttpClient();
const form = createForm("01abc", {
httpClient: client,
});

await form.submit({ email: "test@example.com" });

expect(client.get).not.toHaveBeenCalled();
expect(client.post).toHaveBeenCalledWith(
"https://formrelay.app/api/v1/form/01abc",
{ email: "test@example.com" },
{ headers: {} },
);
});

test("submit without schema uses botProtection and honeypotField options", async () => {
const client = createMockHttpClient();
const form = createForm("01abc", {
httpClient: client,
botProtection: { type: "turnstile", siteKey: "0x-key" },
honeypotField: "_hp_phone",
});

await form.submit({ email: "test@example.com" }, { botToken: "token-123" });

expect(client.get).not.toHaveBeenCalled();
expect(client.post).toHaveBeenCalledWith(
"https://formrelay.app/api/v1/form/01abc",
{
email: "test@example.com",
_hp_phone: "",
"cf-turnstile-response": "token-123",
},
{ headers: {} },
);
});

test("schema values take precedence over options", async () => {
const client = createMockHttpClient();
const form = createForm("01abc", {
publicKey: "pk_fr_test",
httpClient: client,
honeypotField: "_hp_custom",
});

await form.getSchema();
await form.submit({ email: "test@example.com" });

// Schema has honeypotField: "_hp_phone", options have "_hp_custom"
// Schema should win
expect(client.post).toHaveBeenCalledWith(
"https://formrelay.app/api/v1/form/01abc",
{
email: "test@example.com",
_hp_phone: "",
},
expect.any(Object),
);
});
});
32 changes: 25 additions & 7 deletions packages/core/src/client.ts
Original file line number Diff line number Diff line change
@@ -1,38 +1,56 @@
import type { HttpAdapter } from "./http/types";
import type { FormSchema, SubmitOptions } from "./types";
import type { SubmitResult } from "./submit";
import type { BotProtection, FormSchema, SubmitOptions } from "./types";
import type { SubmitConfig, SubmitResult } from "./submit";
import { createFetchAdapter } from "./http/fetch";
import { createSchemaFetcher } from "./schema";
import { submitForm } from "./submit";
import { API_BASE_URL } from "./constants";

export interface FormClientOptions {
publicKey: string;
publicKey?: string;
httpClient?: HttpAdapter;
botProtection?: BotProtection;
honeypotField?: string;
}

export interface FormClient {
getSchema(): Promise<FormSchema>;
submit(data: Record<string, unknown>, options?: SubmitOptions): Promise<SubmitResult>;
}

export function createForm(formId: string, options: FormClientOptions): FormClient {
export function createForm(formId: string, options: FormClientOptions = {}): FormClient {
const httpClient = options.httpClient ?? createFetchAdapter();
const fetchSchema = createSchemaFetcher(formId, API_BASE_URL, options.publicKey, httpClient);

const fetchSchema = options.publicKey
? createSchemaFetcher(formId, API_BASE_URL, options.publicKey, httpClient)
: null;

let cachedSchema: FormSchema | null = null;

function getSubmitConfig(): FormSchema | SubmitConfig {
if (cachedSchema) return cachedSchema;

return {
submitUrl: `${API_BASE_URL}/api/v1/form/${formId}`,
honeypotField: options.honeypotField,
botProtection: options.botProtection,
};
}

return {
async getSchema() {
if (!fetchSchema) {
throw new Error("Cannot fetch schema without a publicKey");
}
cachedSchema = await fetchSchema();
return cachedSchema;
},

async submit(data: Record<string, unknown>, submitOptions?: SubmitOptions) {
if (!cachedSchema) {
if (!cachedSchema && fetchSchema) {
cachedSchema = await fetchSchema();
}
return submitForm(data, cachedSchema, httpClient, submitOptions);
return submitForm(data, getSubmitConfig(), httpClient, submitOptions);
},
};
}
2 changes: 1 addition & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export type {
JsonSchema,
SubmitOptions,
} from "./types";
export type { SubmitResult } from "./submit";
export type { SubmitConfig, SubmitResult } from "./submit";
export type { ErrorOptions } from "./errors";
export {
BotProtectionError,
Expand Down
53 changes: 53 additions & 0 deletions packages/core/src/submit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,4 +286,57 @@ describe("submitForm", () => {
submitForm({ email: "john@example.com" }, schema, client, { botToken: "token" }),
).rejects.toThrow('Unknown bot protection type "unknown_type"');
});

test("submits to explicit URL without a schema", async () => {
const client = createMockHttpClient({
status: 200,
headers: { get: () => null },
json: () => Promise.resolve({ message: "Form submitted successfully." }),
});

const result = await submitForm(
{ email: "john@example.com" },
{ submitUrl: "https://formrelay.app/api/v1/form/01abc" },
client,
);

expect(client.post).toHaveBeenCalledWith(
"https://formrelay.app/api/v1/form/01abc",
{ email: "john@example.com" },
{ headers: {} },
);
expect(result).toEqual({
success: true,
message: "Form submitted successfully.",
});
});

test("submits without schema using honeypotField and botProtection options", async () => {
const client = createMockHttpClient({
status: 200,
headers: { get: () => null },
json: () => Promise.resolve({ message: "OK" }),
});

await submitForm(
{ email: "john@example.com" },
{
submitUrl: "https://formrelay.app/api/v1/form/01abc",
honeypotField: "_hp_phone",
botProtection: { type: "turnstile", siteKey: "0x-key" },
},
client,
{ botToken: "turnstile-token" },
);

expect(client.post).toHaveBeenCalledWith(
"https://formrelay.app/api/v1/form/01abc",
{
email: "john@example.com",
_hp_phone: "",
"cf-turnstile-response": "turnstile-token",
},
{ headers: {} },
);
});
});
31 changes: 21 additions & 10 deletions packages/core/src/submit.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { HttpAdapter } from "./http/types";
import { parseJsonSafe } from "./http/parse-json";
import type { FormSchema, SubmitOptions } from "./types";
import type { BotProtection, FormSchema, SubmitOptions } from "./types";
import { FormRelayError, parseErrorResponse } from "./errors";

const BOT_TOKEN_FIELDS: Record<string, string> = {
Expand All @@ -13,15 +13,25 @@ export type SubmitResult =
| { success: true; message: string }
| { success: false; error: FormRelayError };

export interface SubmitConfig {
submitUrl: string;
honeypotField?: string | null;
botProtection?: BotProtection | null;
}

export async function submitForm(
data: Record<string, unknown>,
schema: FormSchema,
schemaOrConfig: FormSchema | SubmitConfig,
httpClient: HttpAdapter,
options?: SubmitOptions,
): Promise<SubmitResult> {
const body = buildRequestBody(data, schema, options);
const submitUrl = schemaOrConfig.submitUrl;
const honeypotField = schemaOrConfig.honeypotField ?? null;
const botProtection = schemaOrConfig.botProtection ?? null;

const body = buildRequestBody(data, honeypotField, botProtection, options);

const response = await httpClient.post(schema.submitUrl, body, {
const response = await httpClient.post(submitUrl, body, {
headers: {},
});

Expand Down Expand Up @@ -54,23 +64,24 @@ export async function submitForm(

function buildRequestBody(
data: Record<string, unknown>,
schema: FormSchema,
honeypotField: string | null,
botProtection: BotProtection | null,
options?: SubmitOptions,
): Record<string, unknown> {
const body: Record<string, unknown> = { ...data };

if (schema.honeypotField) {
body[schema.honeypotField] = "";
if (honeypotField) {
body[honeypotField] = "";
}

if (!options?.botToken || !schema.botProtection) {
if (!options?.botToken || !botProtection) {
return body;
}

const tokenField = BOT_TOKEN_FIELDS[schema.botProtection.type];
const tokenField = BOT_TOKEN_FIELDS[botProtection.type];
if (!tokenField) {
throw new Error(
`Unknown bot protection type "${schema.botProtection.type}". ` +
`Unknown bot protection type "${botProtection.type}". ` +
`Supported types: ${Object.keys(BOT_TOKEN_FIELDS).join(", ")}`,
);
}
Expand Down
9 changes: 8 additions & 1 deletion packages/nuxt/src/runtime/components/FormRelay.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { defineComponent, toRef, type PropType, type Ref } from "vue";
import { renderFormRelay } from "@formrelay/vue";
import type { FormRelayError, JsonSchema } from "@formrelay/core";
import type { BotProtection, FormRelayError, JsonSchema } from "@formrelay/core";
import { useFormRelay } from "../composables/useFormRelay";

// No initialSchema prop — the Nuxt composable handles SSR schema
Expand All @@ -14,6 +14,11 @@ export default defineComponent({
type: Object as PropType<Ref<HTMLElement | null>>,
default: undefined,
},
botProtection: {
type: Object as PropType<BotProtection>,
default: undefined,
},
honeypotField: { type: String, default: undefined },
validate: {
type: Function as PropType<
(data: Record<string, unknown>, schema: JsonSchema) => Record<string, string[]>
Expand All @@ -34,6 +39,8 @@ export default defineComponent({
formId: props.formId,
publicKey: props.publicKey,
botProtectionContainer: toRef(props, "botProtectionContainer"),
botProtection: props.botProtection,
honeypotField: props.honeypotField,
validate: props.validate,
onSuccess: props.onSuccess,
onError: props.onError,
Expand Down
2 changes: 2 additions & 0 deletions packages/nuxt/src/runtime/composables/useFormRelay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ export async function useFormRelay(options: Partial<UseFormRelayOptions> & { for
publicKey,
initialSchema: initialSchema?.value ?? undefined,
botProtectionContainer: options.botProtectionContainer,
botProtection: options.botProtection,
honeypotField: options.honeypotField,
validate: options.validate,
onSuccess: options.onSuccess,
onError: options.onError,
Expand Down
27 changes: 27 additions & 0 deletions packages/vue/src/components/FormRelay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -353,4 +353,31 @@ describe("FormRelay", () => {
expect(slotProps.schemaLoading).toBe(false);
expect(slotProps.fields).toEqual([]);
});

test("submits without publicKey when no schema is needed", async () => {
const { createForm } = await import("@formrelay/core");
const mockSubmitFn = vi.fn().mockResolvedValue({ success: true, message: "OK" });
vi.mocked(createForm).mockReturnValueOnce({
getSchema: vi.fn(),
submit: mockSubmitFn,
} as any);

let slotProps: any;

mount(FormRelay, {
props: { formId: "01abc" },
slots: {
default: (props: any) => {
slotProps = props;
return h("div");
},
},
});

slotProps.values.email = "john@example.com";
await slotProps.submit();

expect(mockSubmitFn).toHaveBeenCalled();
expect(slotProps.submitted).toBe(true);
});
});
9 changes: 8 additions & 1 deletion packages/vue/src/components/FormRelay.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { defineComponent, toRef, type PropType, type Ref } from "vue";
import { useFormRelay } from "../composables/useFormRelay";
import { renderFormRelay } from "./renderFormRelay";
import type { FormRelayError, FormSchema, JsonSchema } from "@formrelay/core";
import type { BotProtection, FormRelayError, FormSchema, JsonSchema } from "@formrelay/core";

export default defineComponent({
name: "FormRelay",
Expand All @@ -13,6 +13,11 @@ export default defineComponent({
type: Object as PropType<Ref<HTMLElement | null>>,
default: undefined,
},
botProtection: {
type: Object as PropType<BotProtection>,
default: undefined,
},
honeypotField: { type: String, default: undefined },
validate: {
type: Function as PropType<
(data: Record<string, unknown>, schema: JsonSchema) => Record<string, string[]>
Expand All @@ -34,6 +39,8 @@ export default defineComponent({
publicKey: props.publicKey,
initialSchema: props.initialSchema,
botProtectionContainer: toRef(props, "botProtectionContainer"),
botProtection: props.botProtection,
honeypotField: props.honeypotField,
validate: props.validate,
onSuccess: props.onSuccess,
onError: props.onError,
Expand Down
Loading
Loading