Skip to content

Commit

Permalink
feat: 🚀 add support for svg response type for quotes endpoint
Browse files Browse the repository at this point in the history
  • Loading branch information
AkashRajpurohit committed Oct 27, 2024
1 parent d2f156d commit 68835cc
Show file tree
Hide file tree
Showing 7 changed files with 206 additions and 33 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -171,3 +171,5 @@ dist
.dev.vars

.coverage

.wrangler
3 changes: 3 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"editor.formatOnSave": true
}
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
<h3 align="center">The Office API</h3>

<p align="center">
<samp>A free restful API serving quotes from "The Office U.S." series.</samp>
<samp>A free restful API serving quotes (and seasons information) from "The Office U.S." series.</samp>
<br />
<a href="https://akashrajpurohit.github.io/the-office-api/"><strong>Explore the api »</strong></a>
<br />
Expand All @@ -27,6 +27,8 @@

Base URL - [https://officeapi.akashrajpurohit.com](https://officeapi.akashrajpurohit.com)

# Quotes

## Get random Quote

Request Path - [/quote/random](https://officeapi.akashrajpurohit.com/quote/random)
Expand Down Expand Up @@ -57,6 +59,11 @@ Response -
}
```

> Quotes API support both JSON and SVG responses. To get SVG responses, add a `responseType` query parameter to `svg`.
> For example, `https://officeapi.akashrajpurohit.com/quote/156?responseType=svg`.
>
> Along with `responseType` you can also pass the `mode` query parameter to get the SVG card is `dark` or `light` mode.
## Get Season by ID

Request Path - [/season/:id](https://officeapi.akashrajpurohit.com/season/1)
Expand Down
28 changes: 28 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ import { prettyJSON } from 'hono/pretty-json';
import { cors } from 'hono/cors';
import quotes from '../data/quotes.json';
import episodes from '../data/episodes.json';
import { getSVGQuote } from './lib/svgQuote';
import {
QuoteResponseTypeQuery,
QuoteSVGMode as QuoteSVGModeQuery,
} from '../types';

const app = new Hono();

Expand All @@ -22,13 +27,27 @@ app.get('/', (c) => {
// Quotes routes
app.get('/quote/random', (c) => {
const randomQuote = quotes[Math.floor(Math.random() * quotes.length)];
const responseType = (c.req.query('responseType') ||
'json') as QuoteResponseTypeQuery;

if (responseType === 'svg') {
const mode = (c.req.query('mode') || 'dark') as QuoteSVGModeQuery;
const svgQuote = getSVGQuote(randomQuote, { mode });
return c.text(svgQuote, 200, {
'Content-Type': 'image/svg+xml',
'Cache-Control': 'max-age=3600, s-maxage=1800',
});
}

return c.json(randomQuote, 200, {
'Cache-Control': 's-maxage=15',
});
});

app.get('/quote/:id', (c) => {
const id = Number(c.req.param('id'));
const responseType = (c.req.query('responseType') ||
'json') as QuoteResponseTypeQuery;

if (Number.isNaN(id)) {
return c.json(
Expand All @@ -52,6 +71,15 @@ app.get('/quote/:id', (c) => {
);
}

if (responseType === 'svg') {
const mode = (c.req.query('mode') || 'dark') as QuoteSVGModeQuery;
const svgQuote = getSVGQuote(quote, { mode });
return c.text(svgQuote, 200, {
'Content-Type': 'image/svg+xml',
'Cache-Control': 'max-age=3600, s-maxage=1800',
});
}

return c.json(quote, 200, {
'Cache-Control': 's-maxage=15',
});
Expand Down
84 changes: 84 additions & 0 deletions src/lib/svgQuote.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { IOfficeQuote, ISVGQuoteOptions } from '../../types';

export const getSVGQuote = (
quote: IOfficeQuote,
{ mode }: ISVGQuoteOptions
) => {
const baseHeight = 250;
const extendedHeight = 300;
const cardHeight = quote.quote.length > 280 ? extendedHeight : baseHeight;

const modeClass = mode === 'dark' ? 'dark' : 'light';

const svgTemplate = `
<svg width="400" height="${cardHeight}" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 ${cardHeight}">
<defs>
<linearGradient id="lightGradient" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#f9fafb" />
<stop offset="100%" stop-color="#f3f4f6" />
</linearGradient>
<linearGradient id="darkGradient" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#111827" />
<stop offset="100%" stop-color="#1f2937" />
</linearGradient>
</defs>
<style>
.card-light { fill: url(#lightGradient); stroke: #7dd3fc; stroke-width: 10px; }
.card-dark { fill: url(#darkGradient); stroke: #0284c7; stroke-width: 10px; }
.quote-text {
font-size: 18px;
font-family: 'Segoe UI', Ubuntu, "Helvetica Neue", Sans-Serif;
font-weight: bold;
fill: var(--text-color);
opacity: 0;
animation: fadeUp 1s ease forwards;
}
.character-info {
font-size: 14px;
font-family: 'Segoe UI', Ubuntu, "Helvetica Neue", Sans-Serif;
fill: var(--text-color);
opacity: 0;
animation: fadeIn 1s ease forwards 1s;
}
.avatar {
clip-path: circle(50%);
transform-origin: center;
opacity: 0;
animation: fadeIn 2s ease forwards 0.5s;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes fadeUp {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
</style>
<rect width="100%" height="100%" class="card-${modeClass}" rx="15" ry="15" />
<foreignObject x="20" y="30" width="360" height="${cardHeight - 90}">
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; flex-direction: column; gap: 5px;">
<div class="quote-text" style="color: ${mode === 'dark' ? '#f1f1f1' : '#333'};">
${quote.quote}
</div>
</div>
</foreignObject>
<g transform="translate(20, ${cardHeight - 50})">
<image href="${quote.character_avatar_url}" width="30" height="30" class="avatar" />
<text x="40" y="20" class="character-info" style="--text-color: ${mode === 'dark' ? '#cccccc' : '#555'};">
- ${quote.character}
</text>
</g>
</svg>
`;

return svgTemplate;
};
106 changes: 74 additions & 32 deletions tests/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,46 +17,88 @@ describe('Basic routes', () => {
});

describe('Quote routes', () => {
it('should return a random quote on /quote/random endpoint', async () => {
const res = await app.request('http://localhost/quote/random');
const body = await res.json<IOfficeQuote>();
describe('JSON Response', () => {
it('should return a random quote on /quote/random endpoint', async () => {
const res = await app.request('http://localhost/quote/random');
const body = await res.json<IOfficeQuote>();

expect(res.status).toBe(200);
expect(res.headers.get('Content-Type')).toContain('application/json');
expect(body.character).not.toBe('');
expect(body.quote).not.toBe('');
expect(body.character_avatar_url).not.toBe('');
});
expect(res.status).toBe(200);
expect(res.headers.get('Content-Type')).toContain('application/json');
expect(body.character).not.toBe('');
expect(body.quote).not.toBe('');
expect(body.character_avatar_url).not.toBe('');
});

it('should return a quote for a valid id', async () => {
const res = await app.request('http://localhost/quote/1');
const body = await res.json<IOfficeQuote>();
it('should return a quote for a valid id', async () => {
const res = await app.request('http://localhost/quote/1');
const body = await res.json<IOfficeQuote>();

expect(res.status).toBe(200);
expect(res.headers.get('Content-Type')).toContain('application/json');
expect(body.character).not.toBe('');
expect(body.quote).not.toBe('');
expect(body.character_avatar_url).not.toBe('');
});
expect(res.status).toBe(200);
expect(res.headers.get('Content-Type')).toContain('application/json');
expect(body.character).toBe('Michael Scott');
expect(body.quote).toBe(
'Would I rather be feared or loved? Easy. Both. I want people to be afraid of how much they love me.'
);
expect(body.character_avatar_url).toBe(
'https://i.gyazo.com/5a3113ead3f3541731bf721d317116df.jpg'
);
});

it('should return a error for a invalid id', async () => {
const res = await app.request('http://localhost/quote/PIZZA!!');
const body = await res.json<IErrorResponse>();
it('should return a error for a invalid id', async () => {
const res = await app.request('http://localhost/quote/PIZZA!!');
const body = await res.json<IErrorResponse>();

expect(res.status).toBe(400);
expect(res.headers.get('Content-Type')).toContain('application/json');
expect(body.ok).toBe(false);
expect(body.message).toBe('Invalid ID');
expect(res.status).toBe(400);
expect(res.headers.get('Content-Type')).toContain('application/json');
expect(body.ok).toBe(false);
expect(body.message).toBe('Invalid ID');
});

it('should return a error for a id does not exists', async () => {
const res = await app.request('http://localhost/quote/100000000000');
const body = await res.json<IErrorResponse>();

expect(res.status).toBe(400);
expect(res.headers.get('Content-Type')).toContain('application/json');
expect(body.ok).toBe(false);
expect(body.message).toBe('ID does not exists... yet!');
});
});

it('should return a error for a id does not exists', async () => {
const res = await app.request('http://localhost/quote/100000000000');
const body = await res.json<IErrorResponse>();
describe('SVG Response', () => {
it('should return a random quote svg image on /quote/random if responseType is svg', async () => {
const res = await app.request(
'http://localhost/quote/random?responseType=svg'
);

expect(res.status).toBe(400);
expect(res.headers.get('Content-Type')).toContain('application/json');
expect(body.ok).toBe(false);
expect(body.message).toBe('ID does not exists... yet!');
expect(res.status).toBe(200);
expect(res.headers.get('Content-Type')).toContain('image/svg+xml');
});

it('should return a quote for a valid id', async () => {
const res = await app.request(
'http://localhost/quote/1?responseType=svg'
);
const svgText = await res.text();

expect(res.status).toBe(200);
expect(res.headers.get('Content-Type')).toContain('image/svg+xml');
expect(svgText).toContain('Michael Scott');
expect(svgText).toContain(
'Would I rather be feared or loved? Easy. Both. I want people to be afraid of how much they love me.'
);
expect(svgText).toContain(
'https://i.gyazo.com/5a3113ead3f3541731bf721d317116df.jpg'
);
expect(svgText).toContain('class="card-dark"'); // default mode is dark mode

// Test for light mode as well
const res2 = await app.request(
'http://localhost/quote/1?responseType=svg&mode=light'
);
const svgText2 = await res2.text();
expect(svgText2).toContain('class="card-light"');
});
});
});

Expand Down
7 changes: 7 additions & 0 deletions types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,10 @@ export interface IErrorResponse {
ok: boolean;
message: string;
}

export interface ISVGQuoteOptions {
mode?: QuoteSVGMode;
}

export type QuoteResponseTypeQuery = 'json' | 'svg';
export type QuoteSVGMode = 'light' | 'dark';

0 comments on commit 68835cc

Please sign in to comment.