├── bun.lockb ├── docs └── diagram.png ├── src ├── functions │ ├── api │ │ ├── utils.ts │ │ ├── index.ts │ │ └── short-url.ts │ └── redirect.ts └── core │ ├── error.ts │ ├── utils.ts │ └── short-url │ ├── short-url.entity.ts │ └── index.ts ├── .env.example ├── CHANGELOG.md ├── .changeset ├── config.json └── README.md ├── CONTRIBUTING.md ├── sst.config.ts ├── scripts └── snapshot.ts ├── tsconfig.json ├── .github └── workflows │ └── release.yml ├── package.json ├── sst-env.d.ts ├── .gitignore ├── index.ts └── README.md /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dizzzmas/sst-url-shortener/HEAD/bun.lockb -------------------------------------------------------------------------------- /docs/diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dizzzmas/sst-url-shortener/HEAD/docs/diagram.png -------------------------------------------------------------------------------- /src/functions/api/utils.ts: -------------------------------------------------------------------------------- 1 | import { z } from "@hono/zod-openapi"; 2 | 3 | export function Result(schema: T) { 4 | return z.object({ 5 | result: schema, 6 | }); 7 | } 8 | 9 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # only intended for local development of this package 2 | 3 | # when set to "true" - updates sst.aws.Function "handler" paths for local development to point to /src/* instead of /node_modules/* 4 | DIZZZMAS_DEV_MODE="false" 5 | -------------------------------------------------------------------------------- /src/core/error.ts: -------------------------------------------------------------------------------- 1 | export class VisibleError extends Error { 2 | constructor( 3 | public kind: "input" | "auth" | "not-found", 4 | public code: string, 5 | public message: string, 6 | ) { 7 | super(message); 8 | } 9 | } 10 | 11 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @dizzzmas/sst-url-shortener 2 | 3 | ## 1.0.1 4 | 5 | ### Patch Changes 6 | 7 | - 46ce7c8: update docs to link to openapi sdks 8 | 9 | ## 1.0.0 10 | 11 | ### Major Changes 12 | 13 | - a56213f: v1 14 | 15 | ## 0.0.1 16 | 17 | ### Patch Changes 18 | 19 | - d5cc52e: feat: add changesets 20 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@3.0.3/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "public", 8 | "baseBranch": "main", 9 | "updateInternalDependencies": "patch", 10 | "ignore": [] 11 | } 12 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # sst-url-shortener 2 | 3 | ## Running locally 4 | 5 | ```bash 6 | # install deps 7 | bun install 8 | 9 | # Annoying, but to have proper type inference during development go to `index.ts` and change `"../../../.sst/platform/*` imports to `./.sst/platform/*`. Don't commit this change 10 | 11 | # run in dev mode 12 | bunx sst dev 13 | ``` 14 | -------------------------------------------------------------------------------- /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /sst.config.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { UrlShortener } from "."; 4 | 5 | export default $config({ 6 | app(input) { 7 | return { 8 | name: "sst-url-shortener", 9 | removal: input?.stage === "production" ? "retain" : "remove", 10 | home: "aws" 11 | }; 12 | }, 13 | async run() { 14 | const shortener = new UrlShortener({}) 15 | 16 | return { 17 | ulrShortenerApi: shortener.api.url, 18 | urlShortenerRouter: shortener.router.url, 19 | } 20 | } 21 | }); 22 | -------------------------------------------------------------------------------- /scripts/snapshot.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bun 2 | import { $ } from "bun"; 3 | 4 | import pkg from "../package.json"; 5 | const nextPkg = JSON.parse(JSON.stringify(pkg)); 6 | nextPkg.optionalDependencies = nextPkg.optionalDependencies || {}; 7 | nextPkg.version = `0.0.0-${Date.now()}`; // set snapshot version 8 | const isSnapshot = nextPkg.version.includes("0.0.0"); 9 | if (isSnapshot) { 10 | console.log("snapshot mode"); 11 | } 12 | 13 | console.log("publishing", nextPkg.version); 14 | 15 | 16 | const tag = isSnapshot ? "snapshot" : "latest"; 17 | await Bun.write("package.json", JSON.stringify(nextPkg, null, 2)); 18 | await $`bun publish --access public --tag ${tag}`; 19 | 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // Enable latest features 4 | "lib": [ 5 | "ESNext", 6 | "DOM" 7 | ], 8 | "target": "ESNext", 9 | "module": "ESNext", 10 | "moduleDetection": "force", 11 | "jsx": "react-jsx", 12 | "allowJs": true, 13 | // Bundler mode 14 | "moduleResolution": "bundler", 15 | "allowImportingTsExtensions": true, 16 | "verbatimModuleSyntax": true, 17 | "noEmit": true, 18 | // Best practices 19 | "strict": true, 20 | "skipLibCheck": true, 21 | "noFallthroughCasesInSwitch": true, 22 | // Some stricter flags (disabled by default) 23 | "noUnusedLocals": false, 24 | "noUnusedParameters": false, 25 | "noPropertyAccessFromIndexSignature": false, 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | concurrency: ${{ github.workflow }}-${{ github.ref }} 9 | 10 | jobs: 11 | release: 12 | name: Release 13 | runs-on: ubuntu-latest 14 | permissions: 15 | contents: write 16 | pull-requests: write 17 | 18 | steps: 19 | - name: Checkout Repo 20 | uses: actions/checkout@v4 21 | 22 | - name: Setup Bun 23 | uses: oven-sh/setup-bun@v2 24 | 25 | - name: Install Dependencies 26 | run: bun install 27 | 28 | - name: Create Release Pull Request 29 | uses: changesets/action@v1 30 | with: 31 | title: Release 32 | publish: bunx changeset publish --access public 33 | env: 34 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 35 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 36 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@dizzzmas/sst-url-shortener", 3 | "module": "index.ts", 4 | "version": "1.0.1", 5 | "type": "module", 6 | "scripts": { 7 | "release": "./scripts/release.ts" 8 | }, 9 | "devDependencies": { 10 | "@changesets/cli": "^2.27.9", 11 | "@pulumi/aws": "^6.56.1", 12 | "@pulumi/pulumi": "^3.137.0", 13 | "@types/aws-lambda": "8.10.145", 14 | "@types/bun": "latest" 15 | }, 16 | "peerDependencies": { 17 | "typescript": "^5.0.0" 18 | }, 19 | "dependencies": { 20 | "@hono/swagger-ui": "^0.4.1", 21 | "@hono/zod-openapi": "^0.16.4", 22 | "@paralleldrive/cuid2": "^2.2.2", 23 | "electrodb": "^2.15.0", 24 | "hono": "^4.6.5", 25 | "sst": "^3", 26 | "zod": "^3.23.8" 27 | }, 28 | "optionalDependencies": {}, 29 | "files": [ 30 | "package.json", 31 | "README.md", 32 | "index.ts", 33 | "src/**/*.ts" 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /src/core/utils.ts: -------------------------------------------------------------------------------- 1 | import { init } from "@paralleldrive/cuid2"; 2 | import { ZodSchema, z } from "zod"; 3 | 4 | // not using Resource directly to avoid errors on fresh project setup 5 | // error: "Error evaluating config: It does not look like SST links are active" 6 | let shortIdLength: number 7 | try { 8 | const { Resource } = await import("sst"); 9 | shortIdLength = parseInt(Resource.UrlShortenerShortIdLength.value); 10 | } catch { 11 | shortIdLength = 8 12 | } 13 | 14 | export function fn< 15 | Arg1 extends ZodSchema, 16 | Callback extends (arg1: z.output) => any, 17 | >(arg1: Arg1, cb: Callback) { 18 | const result = function (input: z.input): ReturnType { 19 | const parsed = arg1.parse(input); 20 | return cb.apply(cb, [parsed as any]); 21 | }; 22 | result.schema = arg1; 23 | return result; 24 | } 25 | 26 | export const createShortId = init({ 27 | length: shortIdLength, 28 | }); 29 | 30 | -------------------------------------------------------------------------------- /sst-env.d.ts: -------------------------------------------------------------------------------- 1 | /* This file is auto-generated by SST. Do not edit. */ 2 | /* tslint:disable */ 3 | /* eslint-disable */ 4 | import "sst" 5 | export {} 6 | declare module "sst" { 7 | export interface Resource { 8 | "UrlShortenerApi": { 9 | "type": "sst.aws.ApiGatewayV2" 10 | "url": string 11 | } 12 | "UrlShortenerApiAuthEnabled": { 13 | "type": "sst.sst.Secret" 14 | "value": string 15 | } 16 | "UrlShortenerApiAuthKey": { 17 | "type": "sst.sst.Secret" 18 | "value": string 19 | } 20 | "UrlShortenerOpenApiDocsEnabled": { 21 | "type": "sst.sst.Secret" 22 | "value": string 23 | } 24 | "UrlShortenerRedirectHandlerFunction": { 25 | "name": string 26 | "type": "sst.aws.Function" 27 | "url": string 28 | } 29 | "UrlShortenerRouter": { 30 | "type": "sst.aws.Router" 31 | "url": string 32 | } 33 | "UrlShortenerShortIdLength": { 34 | "type": "sst.sst.Secret" 35 | "value": string 36 | } 37 | "UrlShortenerTable": { 38 | "name": string 39 | "type": "sst.aws.Dynamo" 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/functions/redirect.ts: -------------------------------------------------------------------------------- 1 | import { ShortUrl } from "../core/short-url"; 2 | import { type ApiGatewayRequestContextV2, type LambdaEvent } from "hono/aws-lambda"; 3 | 4 | export const handler = async (event: LambdaEvent) => { 5 | console.log("Event", event); 6 | const path = (event.requestContext as ApiGatewayRequestContextV2).http.path; 7 | 8 | const regex = /(?<=\/)[a-zA-Z0-9]*(?=\/+|$)/; 9 | const shortId = path.match(regex)?.at(0); 10 | 11 | if (!shortId) { 12 | return { 13 | statusCode: 400, 14 | body: "Invalid URL", 15 | }; 16 | } 17 | 18 | const url = await ShortUrl.fromShortId({ 19 | shortId, 20 | }); 21 | if (!url) { 22 | return { 23 | statusCode: 404, 24 | body: "Not found", 25 | }; 26 | } 27 | 28 | if (url.expiredAt && new Date().toISOString() > url.expiredAt) { 29 | return { 30 | statusCode: 404, 31 | body: "URL expired", 32 | }; 33 | } 34 | 35 | return { 36 | statusCode: 301, 37 | headers: { 38 | Location: url.originalUrl, 39 | "Cache-Control": "public, max-age=86400", // 1 day 40 | }, 41 | }; 42 | }; 43 | 44 | -------------------------------------------------------------------------------- /src/core/short-url/short-url.entity.ts: -------------------------------------------------------------------------------- 1 | import { Entity } from "electrodb"; 2 | import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; 3 | 4 | const client = new DynamoDBClient(); 5 | let table: string 6 | try { 7 | const { Resource } = await import("sst"); 8 | table = Resource.UrlShortenerTable.name; 9 | } catch { 10 | table = "" 11 | } 12 | 13 | export const shortUrlEntity = new Entity( 14 | { 15 | model: { 16 | entity: "shortUrl", 17 | version: "1", 18 | service: "urlShortener", 19 | }, 20 | attributes: { 21 | shortId: { 22 | type: "string", 23 | required: true, 24 | }, 25 | originalUrl: { 26 | type: "string", 27 | required: true, 28 | }, 29 | shortUrl: { 30 | type: "string", 31 | required: true, 32 | }, 33 | createdAt: { 34 | type: "string", 35 | required: true, 36 | default: () => new Date().toISOString(), 37 | }, 38 | expiredAt: { 39 | type: "string" 40 | }, 41 | }, 42 | indexes: { 43 | byShortId: { 44 | pk: { 45 | field: "pk", 46 | composite: ["shortId"], 47 | }, 48 | sk: { 49 | field: "sk", 50 | composite: ["shortId"], 51 | }, 52 | }, 53 | 54 | byShortUrl: { 55 | index: "gsi1pk-gsi1sk-index", 56 | pk: { 57 | field: "gsi1pk", 58 | composite: ["shortUrl"], 59 | }, 60 | sk: { 61 | field: "gsi1sk", 62 | composite: ["shortUrl"], 63 | }, 64 | }, 65 | 66 | byOriginalUrl: { 67 | index: "gsi2pk-gsi2sk-index", 68 | pk: { 69 | field: "gsi2pk", 70 | composite: ["originalUrl"], 71 | }, 72 | sk: { 73 | field: "gsi2sk", 74 | composite: ["originalUrl"], 75 | }, 76 | }, 77 | }, 78 | }, 79 | { client, table }, 80 | ); 81 | 82 | -------------------------------------------------------------------------------- /src/functions/api/index.ts: -------------------------------------------------------------------------------- 1 | import { OpenAPIHono } from "@hono/zod-openapi"; 2 | import { logger } from "hono/logger"; 3 | import { handle } from "hono/aws-lambda"; 4 | import { ZodError } from "zod"; 5 | import { swaggerUI } from "@hono/swagger-ui"; 6 | import { VisibleError } from "../../core/error"; 7 | import { UrlApi } from "./short-url"; 8 | import type { StatusCode } from "hono/utils/http-status"; 9 | import { bearerAuth } from 'hono/bearer-auth' 10 | import { Resource } from "sst"; 11 | import { HTTPException } from "hono/http-exception"; 12 | 13 | const isAuthEnabled = Resource.UrlShortenerApiAuthEnabled.value === "true" 14 | const areOpenApiDocsEnabled = Resource.UrlShortenerOpenApiDocsEnabled.value === "true" 15 | const token = Resource.UrlShortenerApiAuthKey?.value 16 | 17 | if (isAuthEnabled && !token?.length) { 18 | throw new Error("Bearer auth is enabled but no token provided. Please set UrlShortenerApiAuthKey secret.") 19 | } 20 | 21 | const app = new OpenAPIHono(); 22 | app.use(logger(), async (c, next) => { 23 | c.header("Cache-Control", "no-store"); 24 | return next(); 25 | }); 26 | app.use("/urls/*", async (c, next) => { 27 | if (isAuthEnabled && token?.length) { 28 | const bearer = bearerAuth({ token }) 29 | return bearer(c, next) 30 | } 31 | return next() 32 | }) 33 | app.openAPIRegistry.registerComponent("securitySchemes", "Bearer", { 34 | type: "http", 35 | scheme: "bearer", 36 | }); 37 | 38 | 39 | const routes = app.route("/urls", UrlApi.route).onError((error, c) => { 40 | if (error instanceof VisibleError) { 41 | let statusCode: StatusCode 42 | switch (error.kind) { 43 | case "input": 44 | statusCode = 400 45 | break 46 | case "auth": 47 | statusCode = 401 48 | break 49 | case "not-found": 50 | statusCode = 404 51 | break 52 | default: 53 | statusCode = 500 54 | } 55 | return c.json( 56 | { 57 | code: error.code, 58 | message: error.message, 59 | }, 60 | statusCode 61 | ); 62 | } 63 | 64 | // for when bearer auth is enabled 65 | if (error instanceof HTTPException) { 66 | if (error.status === 401) { 67 | return c.json({ 68 | code: "auth", 69 | message: "Unauthorized", 70 | }, error.status) 71 | } 72 | } 73 | 74 | if (error instanceof ZodError) { 75 | const e = error.errors[0]; 76 | if (e) { 77 | return c.json( 78 | { 79 | code: e?.code, 80 | message: e?.message, 81 | }, 82 | 400, 83 | ); 84 | } 85 | } 86 | return c.json( 87 | { 88 | code: "internal", 89 | message: "Internal server error", 90 | }, 91 | 500, 92 | ); 93 | }); 94 | 95 | 96 | if (areOpenApiDocsEnabled) { 97 | app.doc("/doc", () => ({ 98 | openapi: "3.0.0", 99 | info: { 100 | title: "sst-url-shortener", 101 | version: "0.0.1", 102 | }, 103 | })); 104 | app.get("/ui", swaggerUI({ url: "/doc" })); 105 | } 106 | 107 | export type Routes = typeof routes; 108 | export const handler = handle(app) 109 | 110 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore 2 | 3 | # Logs 4 | 5 | logs 6 | _.log 7 | npm-debug.log_ 8 | yarn-debug.log* 9 | yarn-error.log* 10 | lerna-debug.log* 11 | .pnpm-debug.log* 12 | 13 | # Caches 14 | 15 | .cache 16 | 17 | # Diagnostic reports (https://nodejs.org/api/report.html) 18 | 19 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 20 | 21 | # Runtime data 22 | 23 | pids 24 | _.pid 25 | _.seed 26 | *.pid.lock 27 | 28 | # Directory for instrumented libs generated by jscoverage/JSCover 29 | 30 | lib-cov 31 | 32 | # Coverage directory used by tools like istanbul 33 | 34 | coverage 35 | *.lcov 36 | 37 | # nyc test coverage 38 | 39 | .nyc_output 40 | 41 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 42 | 43 | .grunt 44 | 45 | # Bower dependency directory (https://bower.io/) 46 | 47 | bower_components 48 | 49 | # node-waf configuration 50 | 51 | .lock-wscript 52 | 53 | # Compiled binary addons (https://nodejs.org/api/addons.html) 54 | 55 | build/Release 56 | 57 | # Dependency directories 58 | 59 | node_modules/ 60 | jspm_packages/ 61 | 62 | # Snowpack dependency directory (https://snowpack.dev/) 63 | 64 | web_modules/ 65 | 66 | # TypeScript cache 67 | 68 | *.tsbuildinfo 69 | 70 | # Optional npm cache directory 71 | 72 | .npm 73 | 74 | # Optional eslint cache 75 | 76 | .eslintcache 77 | 78 | # Optional stylelint cache 79 | 80 | .stylelintcache 81 | 82 | # Microbundle cache 83 | 84 | .rpt2_cache/ 85 | .rts2_cache_cjs/ 86 | .rts2_cache_es/ 87 | .rts2_cache_umd/ 88 | 89 | # Optional REPL history 90 | 91 | .node_repl_history 92 | 93 | # Output of 'npm pack' 94 | 95 | *.tgz 96 | 97 | # Yarn Integrity file 98 | 99 | .yarn-integrity 100 | 101 | # dotenv environment variable files 102 | 103 | .env 104 | .env.development.local 105 | .env.test.local 106 | .env.production.local 107 | .env.local 108 | 109 | # parcel-bundler cache (https://parceljs.org/) 110 | 111 | .parcel-cache 112 | 113 | # Next.js build output 114 | 115 | .next 116 | out 117 | 118 | # Nuxt.js build / generate output 119 | 120 | .nuxt 121 | dist 122 | 123 | # Gatsby files 124 | 125 | # Comment in the public line in if your project uses Gatsby and not Next.js 126 | 127 | # https://nextjs.org/blog/next-9-1#public-directory-support 128 | 129 | # public 130 | 131 | # vuepress build output 132 | 133 | .vuepress/dist 134 | 135 | # vuepress v2.x temp and cache directory 136 | 137 | .temp 138 | 139 | # Docusaurus cache and generated files 140 | 141 | .docusaurus 142 | 143 | # Serverless directories 144 | 145 | .serverless/ 146 | 147 | # FuseBox cache 148 | 149 | .fusebox/ 150 | 151 | # DynamoDB Local files 152 | 153 | .dynamodb/ 154 | 155 | # TernJS port file 156 | 157 | .tern-port 158 | 159 | # Stores VSCode versions used for testing VSCode extensions 160 | 161 | .vscode-test 162 | 163 | # yarn v2 164 | 165 | .yarn/cache 166 | .yarn/unplugged 167 | .yarn/build-state.yml 168 | .yarn/install-state.gz 169 | .pnp.* 170 | 171 | # IntelliJ based IDEs 172 | .idea 173 | 174 | # Finder (MacOS) folder config 175 | .DS_Store 176 | 177 | # sst 178 | .sst 179 | -------------------------------------------------------------------------------- /src/core/short-url/index.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { shortUrlEntity } from "./short-url.entity"; 3 | import { createShortId, fn } from "../utils"; 4 | import { VisibleError } from "../error"; 5 | import { DynamoDBClient, DescribeTableCommand } from "@aws-sdk/client-dynamodb"; 6 | 7 | // not using Resource directly to avoid errors on fresh project setup 8 | // error: "Error evaluating config: It does not look like SST links are active" 9 | let tableName: string 10 | let baseShortUrl: string 11 | try { 12 | const { Resource } = await import("sst") 13 | tableName = Resource.UrlShortenerTable.name 14 | baseShortUrl = Resource.UrlShortenerRouter.url 15 | } catch { 16 | tableName = "" 17 | baseShortUrl = "" 18 | } 19 | 20 | 21 | export module ShortUrl { 22 | export const Info = z.object({ 23 | shortId: z.string(), 24 | originalUrl: z.string().url(), 25 | shortUrl: z.string().url(), 26 | createdAt: z.string().datetime(), 27 | expiredAt: z.string().datetime().optional(), 28 | }); 29 | export type Info = z.infer; 30 | 31 | export const create = fn( 32 | z.object({ 33 | originalUrl: z.string().url().max(2048), 34 | expiredAt: z.string().datetime().optional(), 35 | }), 36 | async ({ originalUrl, expiredAt }) => { 37 | const existingShortUrl = await fromOriginalUrl({ originalUrl }); 38 | if (existingShortUrl) { 39 | return existingShortUrl; 40 | } 41 | 42 | const shortId = createShortId(); 43 | const shortUrl = `${baseShortUrl}/${shortId}`; 44 | const url = { 45 | shortId, 46 | originalUrl, 47 | shortUrl, 48 | expiredAt, 49 | createdAt: new Date().toISOString(), 50 | } 51 | await shortUrlEntity 52 | .create(url) 53 | .go(); 54 | 55 | return url 56 | }, 57 | ); 58 | 59 | export const fromShortUrl = fn( 60 | z.object({ 61 | shortUrl: z.string().url().max(2048), 62 | }), 63 | async ({ shortUrl }) => { 64 | const res = await shortUrlEntity.query 65 | .byShortUrl({ 66 | shortUrl, 67 | }) 68 | .go() 69 | .then((r) => r.data); 70 | 71 | return res.at(0); 72 | }, 73 | ); 74 | 75 | export const fromOriginalUrl = fn( 76 | z.object({ 77 | originalUrl: z.string().url().max(2048), 78 | }), 79 | async ({ originalUrl }) => { 80 | const res = await shortUrlEntity.query 81 | .byOriginalUrl({ 82 | originalUrl, 83 | }) 84 | .go() 85 | .then((r) => r.data); 86 | 87 | return res.at(0); 88 | }, 89 | ); 90 | 91 | export const fromOriginalUrlOrFall = fn( 92 | z.object({ 93 | originalUrl: z.string().url().max(2048), 94 | }), 95 | async ({ originalUrl }) => { 96 | const url = await fromOriginalUrl({ originalUrl }); 97 | if (!url) { 98 | throw new VisibleError("not-found", "shorturl.not-found", `Short URL not found from original url ${originalUrl}`); 99 | } 100 | return url; 101 | }, 102 | ); 103 | 104 | export const fromShortUrlOrFall = fn( 105 | fromShortUrl.schema, 106 | async ({ shortUrl }) => { 107 | const url = await fromShortUrl({ shortUrl }); 108 | if (!url) { 109 | throw new VisibleError("not-found", "shorturl.not-found", `Short URL not found from short url ${shortUrl}`); 110 | } 111 | return url; 112 | }, 113 | ); 114 | 115 | export const fromShortId = fn( 116 | z.object({ 117 | shortId: z.string().min(3).max(36), 118 | }), 119 | async ({ shortId }) => { 120 | const res = await shortUrlEntity.query 121 | .byShortId({ 122 | shortId, 123 | }) 124 | .go() 125 | .then((r) => r.data); 126 | 127 | return res.at(0); 128 | }, 129 | ); 130 | 131 | export const fromShortIdOrFall = fn( 132 | fromShortId.schema, 133 | async ({ shortId }) => { 134 | const url = await fromShortId({ shortId }); 135 | if (!url) { 136 | throw new VisibleError("not-found", "shorturl.not-found", `Short URL not found from short id ${shortId}`); 137 | } 138 | return url; 139 | }, 140 | ); 141 | 142 | export const removeByOriginalUrl = fn( 143 | z.object({ 144 | originalUrl: z.string().url().max(2048), 145 | }), 146 | async ({ originalUrl }) => { 147 | const res = await fromOriginalUrlOrFall({ originalUrl }); 148 | await shortUrlEntity.delete({ 149 | shortId: res.shortId, 150 | }).go() 151 | }, 152 | ); 153 | 154 | export const removeByShortId = fn( 155 | z.object({ 156 | shortId: z.string().max(36), 157 | }), 158 | async ({ shortId }) => { 159 | await fromShortIdOrFall({ shortId }); 160 | await shortUrlEntity.delete({ 161 | shortId 162 | }).go() 163 | }, 164 | ); 165 | 166 | export const search = fn( 167 | z.object({ 168 | originalUrlBeginsWith: z.string().max(2048).optional(), 169 | expiredAtLTE: z.string().datetime().optional(), 170 | cursor: z.string().max(200).optional(), 171 | limit: z.coerce.number().min(1).max(100).optional().default(10).transform(v => typeof v === 'string' ? parseInt(v) : v), 172 | }), 173 | async ({ originalUrlBeginsWith, expiredAtLTE, cursor, limit }) => { 174 | let query = shortUrlEntity.scan 175 | if (originalUrlBeginsWith) { 176 | query = query.where(({ originalUrl }, { begins }) => 177 | `${begins(originalUrl, originalUrlBeginsWith)}` 178 | ) 179 | } 180 | 181 | if (expiredAtLTE) { 182 | query = query.where(({ expiredAt }, { lte }) => 183 | `${lte(expiredAt, expiredAtLTE)}` 184 | ) 185 | } 186 | 187 | const res = await query.go({ 188 | cursor, 189 | count: limit 190 | }) 191 | 192 | return { 193 | urls: res.data, 194 | cursor: res.cursor 195 | } 196 | }, 197 | ); 198 | 199 | export const quickCount = fn( 200 | z.void(), 201 | async () => { 202 | const client = new DynamoDBClient(); 203 | const command = new DescribeTableCommand({ 204 | TableName: tableName 205 | }) 206 | 207 | const res = await client.send(command); 208 | const count = res.Table?.ItemCount 209 | 210 | if (count === undefined) { 211 | throw new Error("Failed to get table item count") 212 | } 213 | 214 | return { 215 | count 216 | } 217 | }, 218 | ); 219 | 220 | export const slowCount = fn( 221 | z.void(), 222 | async () => { 223 | const limit = 100 224 | let count = 0 225 | let cursor = null 226 | 227 | do { 228 | const res = await shortUrlEntity.scan.go({ 229 | count: limit, 230 | cursor: null 231 | }) 232 | cursor = res.cursor 233 | count += res.data.length 234 | } 235 | while (cursor) 236 | 237 | 238 | return { 239 | count 240 | } 241 | }, 242 | ); 243 | } 244 | 245 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | // TODO: figure out absolute imports; currently committed imports are relative to the node_modules folder 2 | import { type CdnArgs } from "../../../.sst/platform/src/components/aws"; 3 | import type { FunctionArgs } from "../../../.sst/platform/src/components/aws/function"; 4 | import type { ApiGatewayV2Args } from "../../../.sst/platform/src/components/aws/apigatewayv2"; 5 | import type { DynamoArgs } from "../../../.sst/platform/src/components/aws/dynamo"; 6 | import type { RouterArgs } from "../../../.sst/platform/src/components/aws/router"; 7 | import { type Input } from "@pulumi/pulumi"; 8 | 9 | // export domain functions that can be used in the app 10 | export * from './src/core/short-url' 11 | 12 | 13 | type UrlShortenerArgs = { 14 | /** 15 | * Require bearer authentication on API requests 16 | * 17 | * When `true`, the auth token value is inferred from the `UrlShortenerApiAuthKey` [Secret](https://sst.dev/docs/component/secret/) 18 | * To change the token run `sst secret set UrlShortenerApiAuthKey "YOUR_TOKEN"` 19 | * @default false 20 | */ 21 | enableApiAuth?: boolean 22 | 23 | /** 24 | * Have Swagger UI under /ui and openapi.json under /doc 25 | * 26 | * @default true 27 | */ 28 | enableOpenApiDocs?: boolean 29 | 30 | /** 31 | * Desired length of the id in shortened urls e.g. my-shortener.com/{shortId} 32 | * Allowed values between 4 and 24 33 | * 34 | * Inferred from the `UrlShortenerShortIdLength` [Secret](https://sst.dev/docs/component/secret/) 35 | * To change, run `sst secret set UrlShortenerShortIdLength "YOUR_TOKEN"` 36 | * @default 8 37 | */ 38 | shortIdLength?: number 39 | 40 | /** 41 | * Set a custom domain for your short URLs and the API 42 | * 43 | * ```typescript 44 | * const shortener = new UrlShortener({ 45 | * domain: { 46 | * name: "share.acme.com", 47 | * dns: sst.aws.dns() 48 | * } 49 | * }) 50 | * ``` 51 | * The above example will results in short URLs looking like `https://share.acme.com/etogiyeu`, 52 | * and the API looking like `https://api.share.acme.com/ui` 53 | * 54 | * 55 | * Automatically manages domains hosted on AWS Route 53, Cloudflare, and Vercel. For other 56 | * providers, you'll need to pass in a `cert` that validates domain ownership and add the 57 | * DNS records. 58 | * 59 | * :::tip 60 | * Built-in support for AWS Route 53, Cloudflare, and Vercel. And manual setup for other 61 | * providers. 62 | * ::: 63 | * 64 | * @example 65 | * 66 | * By default this assumes the domain is hosted on Route 53. 67 | * 68 | * ```js 69 | * { 70 | * domain: "example.com" 71 | * } 72 | * ``` 73 | * 74 | * For domains hosted on Cloudflare. 75 | * 76 | * ```js 77 | * { 78 | * domain: { 79 | * name: "example.com", 80 | * dns: sst.cloudflare.dns() 81 | * } 82 | * } 83 | * ``` 84 | * 85 | * Specify a `www.` version of the custom domain. 86 | * 87 | * ```js 88 | * { 89 | * domain: { 90 | * name: "domain.com", 91 | * redirects: ["www.domain.com"] 92 | * } 93 | * } 94 | * ``` 95 | */ 96 | domain?: CdnArgs["domain"] 97 | 98 | /** 99 | * Specify VPC configuration for the Lambda Functions used by the URL shortener. 100 | * 101 | * @example 102 | * ```js 103 | * { 104 | * vpc: { 105 | * privateSubnets: ["subnet-0b6a2b73896dc8c4c", "subnet-021389ebee680c2f0"] 106 | * securityGroups: ["sg-0399348378a4c256c"], 107 | * } 108 | * } 109 | * ``` 110 | */ 111 | vpc?: FunctionArgs["vpc"] 112 | 113 | /** 114 | * [Transform](https://sst.dev/docs/components/#transform) how this component creates its underlying 115 | * resources. 116 | */ 117 | transform?: { 118 | redirectHandler?: FunctionArgs['transform'] 119 | api?: ApiGatewayV2Args['transform'] 120 | router?: RouterArgs['transform'] 121 | table?: DynamoArgs['transform'] 122 | }; 123 | } 124 | 125 | export class UrlShortener { 126 | api: sst.aws.ApiGatewayV2 127 | router: sst.aws.Router 128 | redirectHandler: sst.aws.Function 129 | table: sst.aws.Dynamo 130 | /** 131 | * used to link URLShortener to other components 132 | */ 133 | link: Input 134 | 135 | constructor( 136 | args: UrlShortenerArgs, 137 | ) { 138 | const isAuthEnabled = new sst.Secret("UrlShortenerApiAuthEnabled", args.enableApiAuth ? "true" : "false") 139 | const areOpenApiDocsEnabled = new sst.Secret("UrlShortenerOpenApiDocsEnabled", args.enableOpenApiDocs === false ? "false" : "true") 140 | const authKey = new sst.Secret("UrlShortenerApiAuthKey", "your_secret") 141 | if (args.shortIdLength && (args.shortIdLength < 4 || args.shortIdLength > 24)) { 142 | throw new Error("shortIdLength must be between 4 and 24") 143 | } 144 | const shortIdLength = new sst.Secret("UrlShortenerShortIdLength", args.shortIdLength ? args.shortIdLength.toString() : "8") 145 | const handlerPathPrefix = process.env.DIZZZMAS_DEV_MODE === "true" ? "" : "node_modules/@dizzzmas/sst-url-shortener/" 146 | 147 | // single table design with https://electrodb.dev/ 148 | const table = new sst.aws.Dynamo("UrlShortenerTable", { 149 | fields: { 150 | pk: "string", 151 | sk: "string", 152 | gsi1pk: "string", 153 | gsi1sk: "string", 154 | gsi2pk: "string", 155 | gsi2sk: "string", 156 | }, 157 | primaryIndex: { hashKey: "pk", rangeKey: "sk" }, 158 | globalIndexes: { 159 | "gsi1pk-gsi1sk-index": { hashKey: "gsi1pk", rangeKey: "gsi1sk" }, 160 | "gsi2pk-gsi2sk-index": { hashKey: "gsi2pk", rangeKey: "gsi2sk" }, 161 | }, 162 | transform: args?.transform?.table, 163 | }); 164 | 165 | const redirectHandler = new sst.aws.Function("UrlShortenerRedirectHandlerFunction", { 166 | handler: `${handlerPathPrefix}src/functions/redirect.handler`, 167 | vpc: args.vpc, 168 | link: [table, shortIdLength], 169 | url: true, 170 | transform: args.transform?.redirectHandler 171 | }); 172 | 173 | const redirectRouter = new sst.aws.Router("UrlShortenerRouter", { 174 | routes: { 175 | "/*": redirectHandler.url, 176 | }, 177 | domain: args.domain, 178 | transform: args.transform?.router, 179 | }); 180 | 181 | const api = new sst.aws.ApiGatewayV2("UrlShortenerApi", { 182 | domain: args.domain && $output(args.domain).apply(d => ( 183 | typeof d === 'string' ? `api.${d}` : { ...d, name: `api.${d.name}` } 184 | )), 185 | link: [table, redirectRouter, isAuthEnabled, areOpenApiDocsEnabled, authKey, shortIdLength], 186 | transform: args.transform?.api, 187 | vpc: args.vpc && { subnets: $output(args.vpc).privateSubnets, securityGroups: $output(args.vpc).securityGroups }, 188 | }); 189 | api.route("$default", `${handlerPathPrefix}src/functions/api/index.handler`) 190 | 191 | this.api = api 192 | this.router = redirectRouter 193 | this.table = table 194 | this.redirectHandler = redirectHandler 195 | this.link = [table, api, redirectHandler, redirectRouter, shortIdLength, isAuthEnabled, areOpenApiDocsEnabled, authKey] 196 | } 197 | } 198 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # :hocho: sst-url-shortener 2 | 3 | [![NPM version](https://img.shields.io/npm/v/@dizzzmas/sst-url-shortener.svg)](https://npmjs.org/package/@dizzzmas/sst-url-shortener) ![npm bundle size](https://img.shields.io/bundlephobia/minzip/@dizzzmas/sst-url-shortener) 4 | 5 | Host your own URL shortener on AWS with [SST](https://github.com/sst/sst) and chop up those beefy links in a breeze! 6 | 7 | - :twisted_rightwards_arrows: Add as a component to your existing SST app or deploy standalone and integrate with your [JS](https://github.com/Dizzzmas/sst-url-shortener-js-sdk)/[Python](https://github.com/Dizzzmas/sst-url-shortener-python-sdk)/[Go](https://github.com/Dizzzmas/sst-url-shortener-go-sdk) backend via the OpenAPI sdk 8 | - :lock: Opt-in API bearer auth and Swagger docs UI 9 | - :sparkles: URL search and expiration support 10 | - :house: Bring your custom domain 11 | - :moneybag: Serverless and fully within AWS Free Tier, 0 upfront cost 12 | 13 | ![diagram](./docs/diagram.png) 14 | 15 | # Pre-requisites 16 | 17 | If this is your first time using SST or deploying to AWS, make sure you have the [AWS credentials](https://sst.dev/docs/iam-credentials/) properly setup 18 | 19 | # Quickstart 20 | 21 | ## Standalone SST app 22 | This is for cases when you can't or don't want to integrate the `URLShortener` component into your existing SST app. 23 | Here we will create a new SST app and use an OpenAPI SDK to integrate with our backend deployed elsewhere. 24 | 25 | - Create a new project: 26 | ```bash 27 | mkdir my-shortener-app && cd my-shortener-app 28 | npm init -y 29 | ``` 30 | 31 | - Init SST and install the `URLShortener` component: 32 | ```bash 33 | npx sst@latest init 34 | npm install @dizzzmas/sst-url-shortener 35 | ``` 36 | 37 | - Declare the URL shortener component in `sst.config.ts`: 38 | ```typescript 39 | /// 40 | import { UrlShortener } from "@dizzzmas/sst-url-shortener"; 41 | 42 | export default $config({ 43 | app(input) { 44 | return { 45 | name: "url-shortener", 46 | removal: input?.stage === "production" ? "retain" : "remove", 47 | home: "aws", 48 | }; 49 | }, 50 | async run() { 51 | const urlShortener = new UrlShortener({}) 52 | 53 | return { 54 | api: urlShortener.api.url, 55 | } 56 | }, 57 | }); 58 | ``` 59 | 60 | - Deploy the app to your personal stage via SST dev mode: 61 | ```bash 62 | npx sst dev 63 | ``` 64 | 65 | Notice that our app once deployed returns a URL of an API endpoint. 66 | By default the API doesn't require authentication and has Swagger UI enabled. 67 | We can visit `{api}/ui` to access the swagger UI and test our API. 68 | 69 | ### Backend integration via OpenAPI SDK 70 | SDKs are available for: 71 | 72 | - [JS](https://github.com/Dizzzmas/sst-url-shortener-js-sdk) 73 | - [Python](https://github.com/Dizzzmas/sst-url-shortener-python-sdk) 74 | - [Go](https://github.com/Dizzzmas/sst-url-shortener-go-sdk) 75 | 76 | Below is an example of using the JS SDK to shorten a URL: 77 | 78 | Install the SDK in your backend project 79 | ```bash 80 | npm install @dizzzmas/sst-url-shortener-sdk 81 | ``` 82 | 83 | Use it: 84 | ```typescript 85 | import SstURLShortener from '@dizzzmas/sst-url-shortener-sdk'; 86 | 87 | const client = new SstURLShortener({ 88 | baseURL: "YOUR_API_URL", 89 | bearerToken: undefined // auth disabled in this example 90 | }); 91 | 92 | async function main() { 93 | const newUrl = await client.urls.create({ 94 | originalUrl: "https://sst.dev/docs" 95 | }); 96 | const shortUrl = newUrl.result.shortUrl; 97 | console.log(shortUrl); // the shortened URL 98 | 99 | const urls = await client.urls.search({}); 100 | console.log(urls.result); 101 | } 102 | 103 | main(); 104 | ``` 105 | 106 | ## Add as a component to an existing SST app 107 | 108 | Install the component: 109 | ```bash 110 | npm install @dizzzmas/sst-url-shortener 111 | ``` 112 | 113 | Modify `sst.config.ts` to include the component: 114 | ```typescript 115 | import { UrlShortener } from "@dizzzmas/sst-url-shortener"; 116 | 117 | async run() { 118 | // ...your existing components 119 | const urlShortener = new UrlShortener({}); 120 | 121 | // link URL shortener to another component e.g. a lambda function 122 | const example = new sst.aws.Function("Example", { 123 | link: [...urlShortener.link], 124 | handler: "example.handler", 125 | url: true, 126 | }) 127 | } 128 | ``` 129 | 130 | Inside the `example.ts` Lambda handler: 131 | ```typescript 132 | import { ShortUrl } from "@dizzzmas/sst-url-shortener" 133 | 134 | export const handler = async () => { 135 | const { shortUrl } = await ShortUrl.create({ 136 | originalUrl: "https://sst.dev/docs" 137 | }) 138 | console.log("new short url", shortUrl) 139 | 140 | const searchResult = await ShortUrl.search({}) 141 | console.log("search results", searchResult) 142 | 143 | return shortUrl 144 | } 145 | ``` 146 | ## Component configuration 147 | 148 | ### Authentication 149 | 150 | API bearer authentication is disabled by default and can be enabled via setting `enableApiAuth` to `true` on the component. 151 | ```typescript 152 | const shortener = new UrlShortener({ 153 | enableApiAuth: true, 154 | }) 155 | ``` 156 | Make sure you specify the Bearer token expected by the API for authentication. 157 | 158 | The Bearer token can be set via `UrlShortenerApiAuthKey` SST [Secret](https://sst.dev/docs/component/secret/) and defaults to `your_secret` 159 | ```bash 160 | # set the secret 161 | npx sst secret set UrlShortenerApiAuthKey "YOUR_TOKEN" 162 | ``` 163 | 164 | ### Swagger UI 165 | 166 | Swagger UI is enabled by default and can be disabled via settings `enableOpenApiDocs` to `false` on the component. 167 | 168 | ```typescript 169 | const shortener = new UrlShortener({ 170 | enableOpenApiDocs: false, 171 | }) 172 | ``` 173 | 174 | ### Custom domain 175 | 176 | You can specify a custom domain for the URL shortener and its API. 177 | ```typescript 178 | const shortener = new UrlShortener({ 179 | domain: { 180 | name: "share.acme.com", 181 | dns: sst.aws.dns() 182 | } 183 | }) 184 | ``` 185 | The above example will results in short URLs looking like `https://share.acme.com/etogiyeu`, and the API looking like `https://api.share.acme.com/ui` 186 | 187 | Custom domains work out of the box if you use AWS Route53, Cloudflare or Vercel as your DNS provider, but will require [manual setup](https://sst.dev/docs/custom-domains#manual-setup) for other providers. 188 | Please check out SST [Custom Domains](https://sst.dev/docs/custom-domains) docs for more info. 189 | 190 | ### Short id length 191 | 192 | Short id is the alphanumeric identifier for your URLs generated using [cuid2](https://github.com/paralleldrive/cuid2) 193 | e.g. in `https://share.acme.com/etogiyeu` the short id is `etogiyeu` 194 | 195 | Its length can be anywhere from 4 to 24 characters and defaults to 8. 196 | ```typescript 197 | const shortener = new UrlShortener({ 198 | shortIdLength: 12 199 | }) 200 | ``` 201 | 202 | ### Transform underlying resources 203 | 204 | You can fully customize the underlying resources thanks to the SST [Transform](https://sst.dev/docs/components/#transform) feature. 205 | 206 | ```typescript 207 | const shortener = new UrlShortener({ 208 | transform: { 209 | redirectHandler: { 210 | function: { 211 | timeout: 30 212 | } 213 | } 214 | } 215 | }) 216 | ``` 217 | 218 | -------------------------------------------------------------------------------- /src/functions/api/short-url.ts: -------------------------------------------------------------------------------- 1 | import { OpenAPIHono, createRoute, z } from "@hono/zod-openapi"; 2 | import { Result } from "./utils"; 3 | import { ShortUrl } from "../../core/short-url"; 4 | 5 | export module UrlApi { 6 | export const ShortUrlSchema = z 7 | .object(ShortUrl.Info.shape) 8 | .openapi("ShortUrl"); 9 | export const ShortUrlSearchResultSchema = z 10 | .object({ urls: ShortUrl.Info.array(), cursor: z.string().nullable() }) 11 | .openapi("ShortUrlSearchResult") 12 | export const ShortrUrlCountResultSchema = z 13 | .object({ count: z.number() }) 14 | .openapi("ShortUrlCountResult") 15 | 16 | export const route = new OpenAPIHono() 17 | .openapi( 18 | createRoute({ 19 | security: [{ Bearer: [] }], 20 | method: "post", 21 | path: "/create", 22 | description: "Create a new short url", 23 | request: { 24 | body: { 25 | content: { 26 | "application/json": { 27 | schema: ShortUrl.create.schema, 28 | }, 29 | }, 30 | }, 31 | }, 32 | responses: { 33 | 200: { 34 | content: { 35 | "application/json": { 36 | schema: Result(ShortUrlSchema), 37 | }, 38 | }, 39 | description: "Return the created short url", 40 | }, 41 | }, 42 | }), 43 | async (c) => { 44 | const input = c.req.valid("json"); 45 | const result = await ShortUrl.create(input); 46 | return c.json({ result }, 200); 47 | }, 48 | ) 49 | .openapi( 50 | createRoute({ 51 | security: [{ Bearer: [] }], 52 | method: "get", 53 | path: "/from-original-url", 54 | description: "Get the short url from the original url", 55 | request: { 56 | query: ShortUrl.fromOriginalUrl.schema, 57 | }, 58 | responses: { 59 | 404: { 60 | content: { 61 | "application/json": { 62 | schema: z.object({ error: z.string() }), 63 | }, 64 | }, 65 | description: "Short URL not found", 66 | }, 67 | 200: { 68 | content: { 69 | "application/json": { 70 | schema: Result(ShortUrlSchema), 71 | }, 72 | }, 73 | description: "Return the url data", 74 | }, 75 | }, 76 | }), 77 | async (c) => { 78 | const input = c.req.valid("query"); 79 | const result = await ShortUrl.fromOriginalUrlOrFall(input); 80 | return c.json({ result }, 200); 81 | }, 82 | ) 83 | .openapi( 84 | createRoute({ 85 | security: [{ Bearer: [] }], 86 | method: "get", 87 | path: "/from-short-id", 88 | description: "Get the short url from the short id", 89 | request: { 90 | query: ShortUrl.fromShortId.schema, 91 | }, 92 | responses: { 93 | 404: { 94 | content: { 95 | "application/json": { 96 | schema: z.object({ error: z.string() }), 97 | }, 98 | }, 99 | description: "Short URL not found", 100 | }, 101 | 200: { 102 | content: { 103 | "application/json": { 104 | schema: Result(ShortUrlSchema), 105 | }, 106 | }, 107 | description: "Return the url data", 108 | }, 109 | }, 110 | }), 111 | async (c) => { 112 | const input = c.req.valid("query"); 113 | const result = await ShortUrl.fromShortIdOrFall(input); 114 | return c.json({ result }, 200); 115 | }, 116 | ).openapi( 117 | createRoute({ 118 | security: [{ Bearer: [] }], 119 | method: "get", 120 | path: "/search", 121 | description: "Paginated search of short urls", 122 | request: { 123 | query: ShortUrl.search.schema, 124 | }, 125 | responses: { 126 | 200: { 127 | content: { 128 | "application/json": { 129 | schema: Result(ShortUrlSearchResultSchema), 130 | }, 131 | }, 132 | description: "List of short urls", 133 | }, 134 | }, 135 | }), 136 | async (c) => { 137 | const input = c.req.valid("query"); 138 | const result = await ShortUrl.search(input); 139 | return c.json({ result }, 200); 140 | }, 141 | ).openapi( 142 | createRoute({ 143 | security: [{ Bearer: [] }], 144 | method: "get", 145 | path: "/quick-count", 146 | description: "Get approximate count of short urls in the DB. Updated every 6 hours.", 147 | request: {}, 148 | responses: { 149 | 200: { 150 | content: { 151 | "application/json": { 152 | schema: Result(ShortrUrlCountResultSchema), 153 | }, 154 | }, 155 | description: "Count of short urls", 156 | }, 157 | }, 158 | }), 159 | async (c) => { 160 | const result = await ShortUrl.quickCount(); 161 | return c.json({ result }, 200); 162 | }, 163 | ).openapi( 164 | createRoute({ 165 | security: [{ Bearer: [] }], 166 | method: "get", 167 | path: "/slow-count", 168 | description: "Scan through the entire table to get real-time count of items", 169 | request: {}, 170 | responses: { 171 | 200: { 172 | content: { 173 | "application/json": { 174 | schema: Result(ShortrUrlCountResultSchema), 175 | }, 176 | }, 177 | description: "Count of short urls", 178 | }, 179 | }, 180 | }), 181 | async (c) => { 182 | const result = await ShortUrl.slowCount(); 183 | return c.json({ result }, 200); 184 | }, 185 | ).openapi( 186 | createRoute({ 187 | security: [{ Bearer: [] }], 188 | method: "delete", 189 | path: "/delete-by-original-url", 190 | description: "Delete a short url by original url", 191 | request: { 192 | query: ShortUrl.removeByOriginalUrl.schema, 193 | }, 194 | responses: { 195 | 200: { 196 | content: { 197 | "application/json": { 198 | schema: z.object({}), 199 | }, 200 | }, 201 | description: "Return empty object", 202 | }, 203 | }, 204 | }), 205 | async (c) => { 206 | const input = c.req.valid("query"); 207 | await ShortUrl.removeByOriginalUrl(input); 208 | return c.json({}, 200); 209 | }, 210 | ) 211 | .openapi( 212 | createRoute({ 213 | security: [{ Bearer: [] }], 214 | method: "delete", 215 | path: "/delete-by-short-id", 216 | description: "Delete a short url by short id", 217 | request: { 218 | query: ShortUrl.removeByShortId.schema, 219 | }, 220 | responses: { 221 | 200: { 222 | content: { 223 | "application/json": { 224 | schema: z.object({}), 225 | }, 226 | }, 227 | description: "Return empty object", 228 | }, 229 | }, 230 | }), 231 | async (c) => { 232 | const input = c.req.valid("query"); 233 | await ShortUrl.removeByShortId(input); 234 | return c.json({}, 200); 235 | }, 236 | ) 237 | 238 | } 239 | 240 | --------------------------------------------------------------------------------