Content Type
The contentType option of procedure sets how the client types the request body, how the server parses it, and which Content-Type headers the server accepts. When contentType is set, a request without a matching Content-Type header gets a 415 Unsupported Media Type error.
How each content type family maps to the client’s body type and to what req.vovk.body() returns:
| Content Type | Client body type | req.vovk.body() return type |
|---|---|---|
application/json, *+json | TBody | Blob | Parsed JSON object |
multipart/form-data | TBody | FormData | Blob | Parsed form object |
application/x-www-form-urlencoded | TBody | URLSearchParams | FormData | Blob | Parsed form object |
text/*, text-like application types, *+xml, *+text, *+yaml, *+json-seq | string | Blob | string |
Everything else (image/*, video/*, application/octet-stream, etc.) | File | ArrayBuffer | Uint8Array | Blob | File |
The standard Request methods, such as req.json(), req.text(), req.blob() and req.formData(), work as usual. contentType only affects the typed req.vovk.body() helper.
Wildcard patterns such as 'video/*', 'image/*' or '*/*' match any subtype of their family.
JSON (default)
Without contentType, or with 'application/json', the body has the type the schema infers and is parsed as JSON.
import { z } from 'zod';
import { procedure, post, prefix } from 'vovk';
@prefix('users')
export default class UserController {
@post()
static createUser = procedure({
body: z.object({
email: z.string().email(),
name: z.string(),
}),
}).handle(async (req) => {
const { email, name } = await req.vovk.body(); // { email: string; name: string }
// ...
});
}Form Data
To accept form data, set contentType to 'multipart/form-data'.
import { z } from 'zod';
import { procedure, post, prefix } from 'vovk';
@prefix('users')
export default class UserController {
@post()
static createUser = procedure({
contentType: 'multipart/form-data',
body: z.object({
email: z.string().email(),
name: z.string().min(2).max(100),
}),
}).handle(async (req) => {
const { email, name } = await req.vovk.body(); // { email: string; name: string }
// or use req.formData() for the raw FormData instance
// ...
});
}The RPC method’s body type is TBody | FormData | Blob, where TBody comes from the validation schema. The client validates an object as an object, then converts it to FormData. It leaves out null and undefined fields, and sends an array as one entry per item, a Date as an ISO string and any other object as JSON. When the procedure also takes application/json, an object is sent as JSON, unless a field holds a file.
import { UserRPC } from '@/client';
const formData = new FormData();
formData.append('email', 'user@example.com');
formData.append('name', 'John Doe');
await UserRPC.createUser({
body: formData,
});On the server, req.vovk.body() parses the form data into a plain object (see req.vovk Interface).
If a key can have one or more values, use a union of the value type and an array of it: FormData doesn’t tell one value from several.
import { z } from 'zod';
import { procedure, post } from 'vovk';
export default class UserController {
@post()
static createUser = procedure({
contentType: 'multipart/form-data',
body: z.object({
tags: z.union([z.array(z.string()), z.string()]),
}),
}).handle(async (req) => {
const { tags } = await req.vovk.body(); // string | string[]
// ...
});
}The same goes for files:
import { z } from 'zod';
import { procedure, post } from 'vovk';
export default class UserController {
@post()
static uploadFiles = procedure({
contentType: 'multipart/form-data',
body: z.object({
files: z.union([z.array(z.file()), z.file()]),
}),
}).handle(async (req) => {
const { files } = await req.vovk.body(); // File | File[]
// ...
});
}Client-side validation doesn’t fully support the OpenAPI format: "binary", and it doesn’t check file size, type and so on.
URL-Encoded Form Data
Set contentType to 'application/x-www-form-urlencoded' to accept URL-encoded forms. The server parses the body as it parses multipart/form-data, and the client body type also takes URLSearchParams. When the procedure doesn’t also take multipart/form-data, the client sends an object body, or a FormData without files, as URLSearchParams.
import { z } from 'zod';
import { procedure, post } from 'vovk';
export default class UserController {
@post()
static submitForm = procedure({
contentType: 'application/x-www-form-urlencoded',
body: z.object({
username: z.string(),
password: z.string(),
}),
}).handle(async (req) => {
const { username, password } = await req.vovk.body();
// ...
});
}Text
Text content types are parsed as a string: text/*, known text-like application types such as application/xml or application/yaml, and the suffixes *+xml, *+text, *+yaml and *+json-seq.
The client sends a string body as the text type the procedure declares. A string body to a procedure that takes JSON, such as one with body: z.string(), or to a handler that declares no content type, is sent as a JSON value.
import { procedure, post } from 'vovk';
export default class UserController {
@post()
static importXml = procedure({
contentType: 'application/xml',
}).handle(async (req) => {
const xmlString = await req.vovk.body(); // string
// ...
});
}Binary / File Uploads
Any other content type, such as application/octet-stream, image/*, video/* or application/pdf, is parsed into a File on the server. On the client, body takes File | ArrayBuffer | Uint8Array | Blob.
Bytes without a type, such as an ArrayBuffer, a Uint8Array or a Blob with an empty type, are sent as the first declared type that isn’t JSON, a form or a wildcard, such as image/png. If there is none, they are sent as a wildcard other than */*, such as image/*. Without either, they are sent as application/octet-stream to a procedure that takes any type (*/*), and otherwise as the JSON or URL-encoded type it declares. A File or a typed Blob is sent as its own type. If the procedure declares application/octet-stream but not that type, the TypeScript client sends it as application/octet-stream. The server refuses any type the procedure doesn’t declare; a wildcard such as image/* or */* takes a family or any type.
The server names the File after the request’s Content-Disposition header, reading filename* before filename, and calls it file without one. The TypeScript client sends that header for a File body. The name is client input, as is the name of a file in form data: never use it as a path.
import { procedure, post } from 'vovk';
export default class UserController {
@post()
static uploadImage = procedure({
contentType: 'image/*',
}).handle(async (req) => {
const file = await req.vovk.body(); // File
// or use req.blob() for raw Blob access
// ...
});
}import { UserRPC } from '@/client';
const file = document.querySelector<HTMLInputElement>('input[type="file"]')!.files![0];
await UserRPC.uploadImage({
body: file,
});Multiple Content Types
contentType takes an array to allow several content types. The body type is then the union of their types.
import { z } from 'zod';
import { procedure, post } from 'vovk';
export default class UserController {
@post()
static importData = procedure({
contentType: ['application/json', 'text/csv'],
body: z.union([z.object({ items: z.array(z.string()) }), z.string()]),
}).handle(async (req) => {
const body = await req.vovk.body(); // parsed object or string, depending on the request
// ...
});
}