@codybrom/denim
Version:
A Deno/TypeScript library for the Threads API
167 lines (148 loc) • 5.37 kB
text/typescript
import type { ThreadsPostRequest } from "../types.ts";
/**
* Validates the ThreadsPostRequest object to ensure correct usage of media-specific properties.
*
* @param request - The ThreadsPostRequest object to validate
* @throws Will throw an error if the request contains invalid combinations of media type and properties
*
* @example
* ```typescript
* const request: ThreadsPostRequest = {
* userId: "123456",
* accessToken: "your_access_token",
* mediaType: "IMAGE",
* imageUrl: "https://example.com/image.jpg"
* };
* await validateRequest(request); // This will not throw an error
* ```
*/
export async function validateRequest(
request: ThreadsPostRequest,
): Promise<void> {
// Check for invalid combinations first
if (request.mediaType !== "IMAGE" && request.imageUrl) {
throw new Error("imageUrl can only be used with IMAGE media type");
}
if (request.mediaType !== "VIDEO" && request.videoUrl) {
throw new Error("videoUrl can only be used with VIDEO media type");
}
if (request.mediaType !== "TEXT" && request.linkAttachment) {
throw new Error("linkAttachment can only be used with TEXT media type");
}
if (request.mediaType !== "CAROUSEL" && request.children) {
throw new Error("children can only be used with CAROUSEL media type");
}
// Poll attachment can only be used with TEXT posts
if (request.pollAttachment && request.mediaType !== "TEXT") {
throw new Error("pollAttachment can only be used with TEXT media type");
}
// GIF attachment can only be used with TEXT posts
if (request.gifAttachment && request.mediaType !== "TEXT") {
throw new Error("gifAttachment can only be used with TEXT media type");
}
// Ghost posts can only be TEXT and cannot be replies
if (request.isGhostPost) {
if (request.mediaType !== "TEXT") {
throw new Error("isGhostPost can only be used with TEXT media type");
}
if (request.replyToId) {
throw new Error("isGhostPost cannot be used together with replyToId");
}
}
// Text attachment can only be used with TEXT posts and not with polls
if (request.textAttachment) {
if (request.mediaType !== "TEXT") {
throw new Error("textAttachment can only be used with TEXT media type");
}
if (request.pollAttachment) {
throw new Error(
"textAttachment cannot be used together with pollAttachment",
);
}
}
// Text entities limited to 10
if (request.textEntities && request.textEntities.length > 10) {
throw new Error("textEntities cannot have more than 10 entries");
}
// If combinations are valid, do media-specific validations
if (request.mediaType === "IMAGE" && request.imageUrl) {
await validateImageSpecs(request.imageUrl);
}
if (request.mediaType === "VIDEO" && request.videoUrl) {
await validateVideoSpecs(request.videoUrl);
}
if (request.mediaType === "TEXT" && request.linkAttachment) {
validateLinkUrl(request.linkAttachment);
}
if (
request.mediaType === "CAROUSEL" &&
(!request.children || request.children.length < 2)
) {
throw new Error("CAROUSEL media type requires at least 2 children");
}
}
async function validateImageSpecs(imageUrl: string): Promise<void> {
let response: Response;
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
response = await fetch(imageUrl, {
method: "HEAD",
signal: controller.signal,
});
clearTimeout(timeoutId);
} catch (_error) {
// HEAD not supported or network error — skip validation
return;
}
if (response.status === 405) return;
if (!response.ok) {
throw new Error(`Failed to fetch image: ${response.statusText}`);
}
const contentType = response.headers.get("content-type")?.split(";")[0]
.trim();
if (!contentType || !["image/jpeg", "image/png"].includes(contentType)) {
throw new Error("Image format must be JPEG or PNG");
}
const contentLength = response.headers.get("content-length");
if (contentLength && parseInt(contentLength, 10) > 8 * 1024 * 1024) {
throw new Error("Image file size must not exceed 8 MB");
}
}
async function validateVideoSpecs(videoUrl: string): Promise<void> {
let response: Response;
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
response = await fetch(videoUrl, {
method: "HEAD",
signal: controller.signal,
});
clearTimeout(timeoutId);
} catch (_error) {
// HEAD not supported or network error — skip validation
return;
}
if (response.status === 405) return;
if (!response.ok) {
throw new Error(`Failed to fetch video: ${response.statusText}`);
}
const contentType = response.headers.get("content-type")?.split(";")[0]
.trim();
if (!contentType || !["video/quicktime", "video/mp4"].includes(contentType)) {
throw new Error("Video format must be MOV or MP4");
}
const contentLength = response.headers.get("content-length");
if (contentLength && parseInt(contentLength, 10) > 1024 * 1024 * 1024) {
throw new Error("Video file size must not exceed 1 GB");
}
}
function validateLinkUrl(url: string): void {
// Pattern to ensure URL starts with http:// or https:// and has a valid domain
const urlPattern = /^https?:\/\/[\w.-]+\.[a-zA-Z]{2,}/;
if (!urlPattern.test(url)) {
throw new Error(
"Invalid URL format for linkAttachment. URL must start with http:// or https:// and contain a valid domain",
);
}
}