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

HTTP server #1

Merged
merged 4 commits into from
Apr 6, 2024
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
20 changes: 20 additions & 0 deletions .github/workflows/check.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
name: Check
"on":
push:
branches:
- main
pull_request:
branches:
- main
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: denoland/setup-deno@v1
- name: Format
run: deno fmt && git diff-index --quiet HEAD
- name: Lint
run: deno lint && git diff-index --quiet HEAD
- name: Test
run: deno test
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
.env
19 changes: 19 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"deno.enable": true,
"deno.unstable": true,
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"[markdown]": {
"editor.defaultFormatter": "denoland.vscode-deno"
},
"[jsonc]": {
"editor.defaultFormatter": "denoland.vscode-deno"
},
"[typescript]": {
"editor.defaultFormatter": "denoland.vscode-deno"
},
"[typescriptreact]": {
"editor.defaultFormatter": "denoland.vscode-deno"
},
"files.eol": "\n"
}
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,23 @@
# go.fart.tools

[![GitHub Actions](https://github.com/FartLabs/rtx/actions/workflows/check.yaml/badge.svg)](https://github.com/FartLabs/rtx/actions/workflows/check.yaml)

Link shortening service with Deno.

## Contribute

### Style

Run `deno fmt` to format the code.

Run `deno lint` to lint the code.

### Test

Run `deno test` to run the tests.

Run `deno task start` to start the server.

---

Developed with ❤️ [**@FartLabs**](https://github.com/FartLabs)
95 changes: 95 additions & 0 deletions cli/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { createRouter } from "@fartlabs/rt";
import { go } from "go/go.ts";

class GoService {
public constructor(
private readonly kv: Deno.Kv,
private readonly kvNamespace: Deno.KvKey = ["go"],
) {}

public async add(
alias: string,
destination: string,
force = false,
): Promise<void> {
const collectionResult = await this.kv.get<string>(this.kvNamespace);
const collection = collectionResult.value
? JSON.parse(collectionResult.value)
: {};
if (!force && collection[alias]) {
throw new Error("Shortlink already exists.");
}

collection[alias] = destination;
const result = await this.kv.atomic()
.check(collectionResult)
.set(this.kvNamespace, JSON.stringify(collection))
.commit();
if (!result.ok) {
throw new Error("Failed to add shortlink.");
}
}

public async delete(alias: string): Promise<void> {
const collectionResult = await this.kv.get<string>(this.kvNamespace);
const collection = collectionResult.value
? JSON.parse(collectionResult.value)
: {};
delete collection[alias];
const result = await this.kv.atomic()
.check(collectionResult)
.set(this.kvNamespace, JSON.stringify(collection))
.commit();
if (!result.ok) {
throw new Error("Failed to delete shortlink.");
}
}

public async collection(): Promise<Record<string, string>> {
const collectionResult = await this.kv.get<string>(this.kvNamespace);
return collectionResult.value ? JSON.parse(collectionResult.value) : {};
}
}

function isAuthorized(headers: Headers): boolean {
const auth = headers.get("Authorization");
return auth === `Token ${Deno.env.get("GO_TOKEN")}`;
}

if (import.meta.main) {
const kv = await Deno.openKv();
const goService = new GoService(kv);
const router = createRouter()
// TODO: Use rtx to define the routes. Use htx to define the HTML index page.
.post("/api", async (ctx) => {
if (!isAuthorized(ctx.request.headers)) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}

const body = await ctx.request.json();
await goService.add(body.alias, body.destination, body.force);
return Response.json({ message: "Shortlink created." }, { status: 201 });
})
.delete("/api", async (ctx) => {
if (!isAuthorized(ctx.request.headers)) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}

const body = await ctx.request.json();
await goService.delete(body.alias);
return Response.json({ message: "Shortlink deleted." });
})
.get("/:path*", async (ctx) => {
const collection = await goService.collection();
const destination = go(ctx.url, collection);
return new Response(
`Going to ${destination.href}...`,
{
status: 302,
headers: { "Location": destination.href },
},
);
});

Deno.serve((request) => router.fetch(request));
}
11 changes: 11 additions & 0 deletions deno.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"lock": false,
"imports": {
"go/": "./",
"@fartlabs/rt": "jsr:@fartlabs/rt@^0.0.1",
"@std/assert": "jsr:@std/assert@^0.221.0"
},
"tasks": {
"start": "deno run --unstable-kv --env -A cli/main.ts"
}
}
116 changes: 116 additions & 0 deletions go.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/**
* go resolves a link to its destination given a collection of shortlinks.
* @param url of incoming request.
* @param shortlinks collection of shortlinks.
* @returns the computed destination URL.
*/
export function go<ID extends string>(
url: URL,
shortlinks: Record<ID, string>,
): URL {
const foundURL = findURL(url.pathname, shortlinks, url.origin);
if (!foundURL) {
return url;
}

const { pathname, query: initialQuery, hash: initialHash, destination: dst } =
foundURL;
const hash = url.hash || initialHash || dst.hash;
const query = combineQueries(dst.search, initialQuery, url.search);
return new URL(`${dst.origin}${dst.pathname}${pathname}${query}${hash}`);
}

interface FoundURL {
pathname: string;
query: string;
hash: string;
destination: URL;
}

function findURL<ID extends string>(
pathname: string,
shortlinks: Record<ID, string>,
destinationOrigin: string,
maxInternalRedirects = 256,
): FoundURL {
let initialId: ID | undefined;
let initialHash: string | undefined;
let initialQuery: string | undefined;
let relativePathname = "";
while (maxInternalRedirects-- > 0) {
const hashIdx = pathname.lastIndexOf("#");
if (hashIdx > -1) {
if (initialHash === undefined) {
initialHash = pathname.slice(hashIdx);
}

pathname = pathname.slice(0, hashIdx);
}

const queryIdx = pathname.lastIndexOf("?");
if (queryIdx > -1) {
if (initialQuery === undefined) {
initialQuery = pathname.slice(queryIdx);
}

pathname = pathname.slice(0, queryIdx);
}

let id = pathname.slice(1) as ID;
while (id.length > 0 && !shortlinks[id]) {
const slashIndex = id.lastIndexOf("/");
if (slashIndex === -1) {
break;
}

id = id.slice(0, slashIndex) as ID;
}

if (!shortlinks[id]) {
return {
pathname: relativePathname,
query: initialQuery || "",
hash: initialHash || "",
destination: new URL(pathname, destinationOrigin),
};
}

if (shortlinks[id].startsWith("http")) {
relativePathname = pathname.slice(id.length + 1) + relativePathname;

return {
pathname: relativePathname,
query: initialQuery || "",
hash: initialHash || "",
destination: new URL(shortlinks[id]),
};
}

if (shortlinks[id].startsWith("/")) {
relativePathname = pathname.slice(id.length + 1) + relativePathname;
pathname = shortlinks[id];

if (!initialId) {
initialId = id;
}

continue;
}
}

throw new Error("too many internal redirects");
}

function combineQueries(baseQuery: string, ...queries: string[]): string {
const baseQueryParams = new URLSearchParams(baseQuery);

const queryParams = queries.map((q) => new URLSearchParams(q));
for (const params of queryParams) {
for (const [key, value] of params) {
baseQueryParams.set(key, value);
}
}

const query = baseQueryParams.toString();
return query.length > 0 ? `?${query}` : "";
}
98 changes: 98 additions & 0 deletions go_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { assertEquals } from "@std/assert";
import { go } from "./go.ts";

Deno.test("go resolves shortlinks", () => {
const actual = go(new URL("https://example.com/github"), {
github: "https://github.com/FartLabs/go.fart.tools",
});
const expected = new URL("https://github.com/FartLabs/go.fart.tools");
assertEquals(actual.href, expected.href);
});

Deno.test("go throws for circularly recursive shortlinks", () => {
function testCircularShortlinks() {
return go(new URL("https://example.com/zig"), {
zig: "/zag",
zag: "/zig",
});
}

let error;
try {
testCircularShortlinks();
} catch (e) {
error = e;
} finally {
assertEquals(error.message, "too many internal redirects");
}
});

Deno.test("go properly combines queries", () => {
const actual = go(new URL("https://example.com/example?foo=bar"), {
example: "https://example.com?baz=qux",
});
const expected = new URL("https://example.com/?baz=qux&foo=bar");
assertEquals(actual.href, expected.href);
});

Deno.test("go properly overwrites hash", () => {
const actual = go(new URL("https://example.com/example#yang"), {
example: "https://example.com#yin",
});
assertEquals(actual, new URL("https://example.com/#yang"));
});

Deno.test("go overwrites hash (2)", () => {
const actual = go(new URL("https://example.com/one#uno"), {
one: "/two",
two: "/three#dos",
three: "/example#tres",
example: "https://example.com",
});
const expected = new URL("https://example.com/#uno");
assertEquals(actual.href, expected.href);
});

Deno.test("go appends pathnames", () => {
const actual = go(new URL("https://example.com/example/baz/qux"), {
example: "https://example.com/foo/bar",
});
const expected = new URL("https://example.com/foo/bar/baz/qux");
assertEquals(actual.href, expected.href);
});

Deno.test("go appends pathnames only if separated by / or end of string", () => {
const shortlinks = { c: "https://example.com/calendar" };
const actual1 = go(new URL("https://example.com/colors"), shortlinks);
const expected1 = new URL("https://example.com/colors");
assertEquals(actual1.href, expected1.href);

const actual2 = go(new URL("https://example.com/c"), shortlinks);
const expected2 = new URL("https://example.com/calendar");
assertEquals(actual2.href, expected2.href);
});

Deno.test("go resolves relative paths", () => {
const actual = go(new URL("https://example.com/student-pack"), {
"student-pack": "/blog/806",
});
const expected = new URL("https://example.com/blog/806");
assertEquals(actual.href, expected.href);
});

Deno.test("go resolves alias shortlink", () => {
const expected = new URL("https://example.com/blog/806");
const input = new URL("https://example.com/student-pack");
const actual = go(input, { "student-pack": "/blog/806" });
assertEquals(actual.href, expected.href);
});

Deno.test("go returns passed URL if invalid or not found", () => {
[
new URL("https://example.com/doesnotexist"),
new URL("https://example.com/does/not/exist"),
new URL("https://example.com/<invalid>"),
].forEach((input) => {
assertEquals(go(input, {}), input, `failed on ${input}`);
});
});