Skip to content

Commit

Permalink
Initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
anthonyhastings committed Oct 17, 2023
0 parents commit 2dc14f8
Show file tree
Hide file tree
Showing 18 changed files with 1,068 additions and 0 deletions.
9 changes: 9 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Ignore all files by default.
*

# White list only the required files and folders.
!src/
!.prettierignore
!.prettierrc
!package.json
!yarn.lock
4 changes: 4 additions & 0 deletions .env
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# In normal circumstances this file should not be under source control, however, it's fine for this demonstration repository.

APP_PORT=54321
MONGODB_URL=mongodb://appUser:examplePassword@database:27017/urlShortener
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
database/data/
node_modules/
yarn-debug.log*
yarn-error.log*
.DS_Store
1 change: 1 addition & 0 deletions .nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
20
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
**/database/data/
3 changes: 3 additions & 0 deletions .prettierrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"singleQuote": true
}
3 changes: 3 additions & 0 deletions .vscode/extensions.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"recommendations": ["esbenp.prettier-vscode"]
}
6 changes: 6 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"[javascript][typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
}
}
15 changes: 15 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
FROM node:20-alpine AS base

LABEL maintainer="Anthony Hastings <[email protected]>"

USER node

WORKDIR /url-shortener

COPY --chown=node ./package.json ./yarn.lock ./

RUN yarn install --frozen-lockfile && yarn cache clean

COPY --chown=node . ./

CMD yarn start
45 changes: 45 additions & 0 deletions compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
version: "3.8"
services:
database:
image: mongo:7
ports:
- 45678:27017
environment:
MONGO_INITDB_ROOT_USERNAME: root
MONGO_INITDB_ROOT_PASSWORD: 123456
volumes:
- ./database/data:/data/db
- ./database/init:/docker-entrypoint-initdb.d
healthcheck:
test: echo 'db.runCommand("ping").ok' | mongosh database:27017 --quiet
start_period: 10s
interval: 3s
timeout: 10s
retries: 3
app:
build:
context: ./
target: base
command: sh -c "yarn run dev"
environment:
APP_PORT: ${APP_PORT}
MONGODB_URL: ${MONGODB_URL}
ports:
- ${APP_PORT}:${APP_PORT}
healthcheck:
test: wget -qO- http://app:$APP_PORT/health || exit 1
start_period: 5s
interval: 5s
timeout: 3s
retries: 3
depends_on:
database:
condition: service_healthy
restart: true
x-develop:
watch:
- action: sync
path: ./src
target: /url-shortener/src
- action: rebuild
path: package.json
20 changes: 20 additions & 0 deletions database/init/01-schema.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
db = new Mongo().getDB('urlShortener');

db.createCollection('links', { capped: false });

db.links.createIndex({ shortId: 1 });

db.links.insert([
{
shortId: 'fPbD2NkcxZ70QPT',
target: 'https://www.google.co.uk/',
},
{
shortId: '4Biym9VuXikRrr4',
target: 'https://www.reddit.com/',
},
{
shortId: 'Zx8lP5bWMhEgnvH',
target: 'https://www.youtube.com/',
},
]);
13 changes: 13 additions & 0 deletions database/init/02-user.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
db = new Mongo().getDB('urlShortener');

db.createUser({
user: 'appUser',
pwd: 'examplePassword',
roles: [
{
role: 'readWrite',
db: 'urlShortener',
collection: 'links',
},
],
});
27 changes: 27 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"name": "url-shortener",
"version": "1.0.0",
"description": "An example of a URL shortening service",
"author": "Anthony Hastings <[email protected]> (https://antwan1986.github.io/)",
"license": "MIT",
"private": true,
"main": "src/index.mjs",
"module": "src/index.mjs",
"type": "module",
"scripts": {
"dev": "nodemon src/index.mjs",
"dev:native": "node --watch src/index.mjs",
"start": "node src/index.mjs"
},
"dependencies": {
"dotenv": "^16.3.1",
"express": "^4.18.2",
"mongoose": "^7.6.2",
"nanoid": "^5.0.2",
"zod": "^3.22.4"
},
"devDependencies": {
"nodemon": "^3.0.1",
"prettier": "^3.0.3"
}
}
24 changes: 24 additions & 0 deletions src/config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { z } from 'zod';

export const configSchema = z.object({
APP_PORT: z.string(),
MONGODB_URL: z.string(),
});

export let config;

try {
config = configSchema.parse(process.env);
} catch (err) {
if (err instanceof z.ZodError) {
const { fieldErrors } = err.flatten();

const errorMessage = Object.entries(fieldErrors)
.map(([field, errors]) =>
errors ? `${field}: ${errors.join(', ')}` : field,
)
.join('\n ');

throw new Error(`Missing environment variables:\n ${errorMessage}`);
}
}
68 changes: 68 additions & 0 deletions src/index.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import 'dotenv/config';
import express from 'express';
import mongoose from 'mongoose';
import { nanoid } from 'nanoid';
import { config } from './config.mjs';
import { LinkModel } from './models/link.mjs';
import { isValidHTTPURL } from './utils.mjs';

mongoose.connection.once('open', () => {
console.log('Connected to MongoDB');
});

await mongoose.connect(config.MONGODB_URL);

const app = express();

app.use(express.json());

app.get('/health', (_req, res) => {
res.status(200).json({ status: 'ok' });
});

app.get('/links/:shortId', async (req, res) => {
const link = await LinkModel.findOne({ shortId: req.params.shortId });

if (link === null) {
return res.status(404).json({ success: false, error: 'Record not found' });
}

res.status(200).json({ success: true, data: link.toJSON() });
});

app.post('/links', async (req, res) => {
const isValidTarget = isValidHTTPURL(req.body.target);
if (!isValidTarget) {
return res.status(409).json({ success: false, error: 'Invalid target' });
}

const shortId = nanoid(25);

const existingLink = await LinkModel.findOne({ shortId });
if (existingLink) {
return res
.status(409)
.json({ success: false, error: 'Short Id already exists' });
}

const link = await new LinkModel({
shortId,
target: req.body.target,
}).save();

res.status(201).json({ success: true, data: link.toJSON() });
});

app.get('/:shortId', async (req, res) => {
const link = await LinkModel.findOne({ shortId: req.params.shortId });

if (link === null) {
return res.status(404).json({ success: false, error: 'Record not found' });
}

res.redirect(301, link.target);
});

app.listen(config.APP_PORT, '0.0.0.0', () => {
console.info(`Listening on port 0.0.0.0:${config.APP_PORT}`);
});
9 changes: 9 additions & 0 deletions src/models/link.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import mongoose from 'mongoose';

export const LinkModel = mongoose.model(
'Link',
new mongoose.Schema({
shortId: String,
target: String,
}),
);
6 changes: 6 additions & 0 deletions src/utils.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export const isValidHTTPURL = (str) => {
const urlPattern =
/^https?:\/\/[-a-zA-Z0-9+&@#/%?=~_|!:,.;]*[-a-zA-Z0-9+&@#/%=~_|]$/;

return urlPattern.test(str);
};
Loading

0 comments on commit 2dc14f8

Please sign in to comment.