Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Rework InputFiles #77

Merged
merged 17 commits into from
Nov 23, 2021
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
17 changes: 5 additions & 12 deletions src/core/payload.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,4 @@
import {
InputFile,
inputFileData,
itrToStream,
streamFile,
} from "../platform.deno.ts";
import { InputFile, itrToStream, toRaw } from "../platform.deno.ts";

// === Payload types (JSON vs. form data)
/**
Expand Down Expand Up @@ -171,7 +166,7 @@ async function* filePart(
origin: string,
input: InputFile,
): AsyncIterableIterator<Uint8Array> {
const filename = input.filename ?? `${origin}.${getExt(origin)}`;
const filename = input.filename || `${origin}.${getExt(origin)}`;
if (filename.includes("\r") || filename.includes("\n")) {
throw new Error(
`File paths cannot contain carriage-return (\\r) \
Expand All @@ -184,11 +179,9 @@ ${filename}
yield enc.encode(
`content-disposition:form-data;name="${id}";filename=${filename}\r\n\r\n`,
);
const fileData = input[inputFileData];
// handle buffers, file paths, and streams:
if (fileData instanceof Uint8Array) yield fileData;
else if (typeof fileData === "string") yield* await streamFile(fileData);
else yield* fileData;
const data = await input[toRaw]();
if (data instanceof Uint8Array) yield data;
else yield* data;
}
/** Returns the default file extension for an API property name */
function getExt(key: string) {
Expand Down
77 changes: 50 additions & 27 deletions src/platform.deno.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/** Are we running on Deno or in a web browser? */
const isDeno = typeof Deno !== "undefined";

// === Needed imports
Expand All @@ -9,27 +10,22 @@ import { iterateReader } from "https://deno.land/std@0.113.0/streams/mod.ts";
export * from "https://cdn.skypack.dev/@grammyjs/types@v2.3.1?dts";

// === Export debug
import debug from "https://cdn.skypack.dev/debug@^4.3.2";
export { debug };
import d from "https://cdn.skypack.dev/debug@^4.3.2";
export { d as debug };
if (isDeno) {
debug.useColors = () => !Deno.noColor;
d.useColors = () => !Deno.noColor;
try {
const val = Deno.env.get("DEBUG");
if (val) debug.enable(val);
if (val) d.enable(val);
} catch {
// cannot access env var, treat as if it is not set
}
}
const debug = d("grammy:warn");

// === Export system-specific operations
// Turn an AsyncIterable<Uint8Array> into a stream
export { readableStreamFromIterable as itrToStream } from "https://deno.land/std@0.113.0/streams/mod.ts";
// Turn a file path into an AsyncIterable<Uint8Array>
export const streamFile = isDeno
? (path: string) => Deno.open(path).then(iterateReader)
: () => {
throw new Error("Reading files by path requires a Deno environment");
};

// === Base configuration for `fetch` calls
export const baseFetchConfig = (_apiRoot: string) => ({});
Expand All @@ -45,7 +41,7 @@ interface URLLike {

// === InputFile handling and File augmenting
// Accessor for file data in `InputFile` instances
export const inputFileData = Symbol("InputFile data");
export const toRaw = Symbol("InputFile data");

/**
* An `InputFile` wraps a number of different sources for [sending
Expand Down Expand Up @@ -74,6 +70,8 @@ export class InputFile {
constructor(
file:
| string
| Blob
| Deno.File
| URL
| URLLike
| Uint8Array
Expand All @@ -82,38 +80,63 @@ export class InputFile {
filename?: string,
) {
this.fileData = file;
if (filename === undefined && typeof file === "string") {
filename = basename(file);
}
filename ??= this.guessFilename(file);
this.filename = filename;
if (
typeof file === "string" &&
(file.startsWith("http:") || file.startsWith("https:"))
) {
debug(
`InputFile received the local file path '${file}' that looks like a URL. Is this a mistake?`,
);
}
}
private guessFilename(
file: ConstructorParameters<typeof InputFile>[0],
): string | undefined {
if (typeof file === "string") return basename(file);
if (typeof file !== "object") return undefined;
if ("url" in file) return basename(file.url);
if (!(file instanceof URL)) return undefined;
return basename(file.pathname) || basename(file.hostname);
}
get [inputFileData]() {
async [toRaw](): Promise<Uint8Array | AsyncIterable<Uint8Array>> {
KnorpelSenf marked this conversation as resolved.
Show resolved Hide resolved
if (this.consumed) {
throw new Error("Cannot reuse InputFile data source!");
}
let data = this.fileData;
if (
typeof data === "object" && ("url" in data || data instanceof URL)
) {
data = fetchFile(data instanceof URL ? data : data.url);
} else if (
typeof data !== "string" && (!(data instanceof Uint8Array))
) {
this.consumed = false;
const data = this.fileData;
// Handle local files
if (typeof data === "string") {
if (!isDeno) {
throw new Error(
"Reading files by path requires a Deno environment",
);
}
const file = await Deno.open(data);
return iterateReader(file);
}
if (data instanceof Blob) return data.stream();
if (isDenoFile(data)) return iterateReader(data);
// Handle URL and URLLike objects
if (data instanceof URL) return fetchFile(data);
if ("url" in data) return fetchFile(data.url);
// Mark streams and iterators as consumed
if (!(data instanceof Uint8Array)) this.consumed = true;
// Return buffers and byte streams as-is
return data;
}
}

async function* fetchFile(url: string | URL): AsyncIterable<Uint8Array> {
const { body } = await fetch(url);
if (body === null) {
throw new Error(
`Download failed, no response body from '${url}'`,
);
throw new Error(`Download failed, no response body from '${url}'`);
}
yield* body;
}
function isDenoFile(data: unknown): data is Deno.File {
return isDeno && data instanceof Deno.File;
}

// === Export InputFile types
type GrammyTypes = InputFileProxy<InputFile>;
Expand Down
59 changes: 37 additions & 22 deletions src/platform.node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,20 @@ import { basename } from "path";
import { Readable } from "stream";
import type { ReadStream } from "fs";
import { URL } from "url";
import { createReadStream } from "fs";

// === Export all API types
export * from "@grammyjs/types";

// === Export debug
export { debug } from "debug";
import { debug as d } from "debug";
export { d as debug };
const debug = d("grammy:warn");

// === Export system-specific operations
// Turn an AsyncIterable<Uint8Array> into a stream
export const itrToStream = (itr: AsyncIterable<Uint8Array>) =>
Readable.from(itr, { objectMode: false });
// Turn a file path into an AsyncIterable<Uint8Array>
export { createReadStream as streamFile } from "fs";

// === Base configuration for `fetch` calls
export function baseFetchConfig(apiRoot: string) {
Expand All @@ -37,9 +38,10 @@ interface URLLike {
*/
url: string;
}

// === InputFile handling and File augmenting
// Accessor for file data in `InputFile` instances
export const inputFileData = Symbol("InputFile data");
export const toRaw = Symbol("InputFile data");

/**
* An `InputFile` wraps a number of different sources for [sending
Expand Down Expand Up @@ -76,36 +78,49 @@ export class InputFile {
filename?: string,
) {
this.fileData = file;
if (filename === undefined && typeof file === "string") {
filename = basename(file);
}
filename ??= this.guessFilename(file);
this.filename = filename;
if (
typeof file === "string" &&
(file.startsWith("http:") || file.startsWith("https:"))
) {
debug(
`InputFile received the local file path '${file}' that looks like a URL. Is this a mistake?`,
);
}
}
get [inputFileData]() {
private guessFilename(
file: ConstructorParameters<typeof InputFile>[0],
): string | undefined {
if (typeof file === "string") return basename(file);
if (typeof file !== "object") return undefined;
if ("url" in file) return basename(file.url);
if (!(file instanceof URL)) return undefined;
return basename(file.pathname) || basename(file.hostname);
}
[toRaw](): Uint8Array | AsyncIterable<Uint8Array> {
if (this.consumed) {
throw new Error("Cannot reuse InputFile data source!");
}
let data = this.fileData;
if (
typeof data === "object" && ("url" in data || data instanceof URL)
) {
data = fetchFile(data instanceof URL ? data : data.url);
} else if (
typeof data !== "string" && (!(data instanceof Uint8Array))
) {
this.consumed = false;
const data = this.fileData;
// Handle local files
if (typeof data === "string") return createReadStream(data);
// Handle URLs and URLLike objects
if (data instanceof URL) {
return data.protocol === "file" // node-fetch does not support file URLs
? createReadStream(data.pathname)
: fetchFile(data);
}
if ("url" in data) return fetchFile(data.url);
// Mark streams and iterators as consumed
if (!(data instanceof Uint8Array)) this.consumed = true;
// Return buffers and byte streams as-is
return data;
}
}

async function* fetchFile(url: string | URL): AsyncIterable<Uint8Array> {
const { body } = await fetch(url);
if (body === null) {
throw new Error(
`Download failed, no response body from '${url}'`,
);
}
for await (const chunk of body) {
if (typeof chunk === "string") {
throw new Error(
Expand Down
2 changes: 1 addition & 1 deletion test/bot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Bot } from "../src/bot.ts";
import {
assertEquals,
assertThrows,
} from "https://deno.land/std@0.97.0/testing/asserts.ts";
} from "https://deno.land/std@0.115.0/testing/asserts.ts";

function createBot(token: string) {
return new Bot(token);
Expand Down
2 changes: 1 addition & 1 deletion test/filter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { FilterQuery, matchFilter } from "../src/filter.ts";
import {
assert,
assertThrows,
} from "https://deno.land/std@0.97.0/testing/asserts.ts";
} from "https://deno.land/std@0.115.0/testing/asserts.ts";
import { Context } from "../src/context.ts";

Deno.test("should reject empty filters", () => {
Expand Down