Skip to content

Commit 25f5bd2

Browse files
authored
feat: add backend integration (#11)
* feat: integrate Orval for API generation and add Axios instance * refactor: migrate from Axios to Undici for API requests * feat: enhance UrlDialog and UrlInput components for improved user experience * fix: update UrlDialog to copy full URL instead of short URL * feat: add GET route for short URL redirection with error handling
1 parent 26b5362 commit 25f5bd2

11 files changed

Lines changed: 902 additions & 104 deletions

File tree

frontend/bun.lock

Lines changed: 524 additions & 16 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/orval.config.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { defineConfig } from "orval";
2+
3+
export default defineConfig({
4+
"lnk-file": {
5+
input: {
6+
target: "http://localhost:8080/swagger/doc.json",
7+
validation: false,
8+
},
9+
output: {
10+
target: "./src/api/lnk.ts",
11+
client: "fetch",
12+
override: {
13+
mutator: {
14+
path: "./src/api/undici-instance.ts",
15+
name: "customInstance",
16+
default: false,
17+
},
18+
},
19+
},
20+
},
21+
});

frontend/package.json

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
"start": "next start",
99
"lint": "biome check",
1010
"lint:fix": "biome check --write",
11-
"format": "biome format --write"
11+
"format": "biome format --write",
12+
"generate:api": "orval"
1213
},
1314
"dependencies": {
1415
"@radix-ui/react-dialog": "^1.1.15",
@@ -24,7 +25,8 @@
2425
"react-dom": "19.2.0",
2526
"react-hook-form": "^7.66.0",
2627
"sonner": "^2.0.7",
27-
"tailwind-merge": "^3.4.0"
28+
"tailwind-merge": "^3.4.0",
29+
"undici": "^7.16.0"
2830
},
2931
"devDependencies": {
3032
"@biomejs/biome": "2.2.0",
@@ -33,6 +35,7 @@
3335
"@types/react": "^19",
3436
"@types/react-dom": "^19",
3537
"babel-plugin-react-compiler": "1.0.0",
38+
"orval": "^8.0.0-rc.2",
3639
"shadcn": "^3.5.0",
3740
"tailwindcss": "^4",
3841
"tw-animate-css": "^1.4.0",

frontend/src/api/lnk.ts

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
/**
2+
* Generated by orval v8.0.0-rc.2 🍺
3+
* Do not edit manually.
4+
* LNK URL Shortener API
5+
* A URL shortener service API
6+
* OpenAPI spec version: 1.0
7+
*/
8+
import { customInstance } from './undici-instance';
9+
export interface HandlersCreateURLRequest {
10+
url: string;
11+
}
12+
13+
export interface HandlersCreateURLResponse {
14+
original_url?: string;
15+
short_url?: string;
16+
}
17+
18+
export interface HandlersErrorResponse {
19+
error?: string;
20+
}
21+
22+
export type GetHealth200 = {[key: string]: string};
23+
24+
export type GetShortUrl308 = {[key: string]: string};
25+
26+
export type GetShortUrl500 = {[key: string]: string};
27+
28+
/**
29+
* Check if the API is running
30+
* @summary Health check endpoint
31+
*/
32+
export type getHealthResponse200 = {
33+
data: GetHealth200
34+
status: 200
35+
}
36+
37+
export type getHealthResponseSuccess = (getHealthResponse200) & {
38+
headers: Headers;
39+
};
40+
;
41+
42+
export type getHealthResponse = (getHealthResponseSuccess)
43+
44+
export const getGetHealthUrl = () => {
45+
46+
47+
48+
49+
return `/health`
50+
}
51+
52+
export const getHealth = async ( options?: RequestInit): Promise<getHealthResponse> => {
53+
54+
return customInstance<getHealthResponse>(getGetHealthUrl(),
55+
{
56+
...options,
57+
method: 'GET'
58+
59+
60+
}
61+
);}
62+
63+
64+
65+
/**
66+
* Create a short URL from a long URL
67+
* @summary Create a short URL
68+
*/
69+
export type postShortenResponse200 = {
70+
data: HandlersCreateURLResponse
71+
status: 200
72+
}
73+
74+
export type postShortenResponse400 = {
75+
data: HandlersErrorResponse
76+
status: 400
77+
}
78+
79+
export type postShortenResponse500 = {
80+
data: HandlersErrorResponse
81+
status: 500
82+
}
83+
84+
export type postShortenResponseSuccess = (postShortenResponse200) & {
85+
headers: Headers;
86+
};
87+
export type postShortenResponseError = (postShortenResponse400 | postShortenResponse500) & {
88+
headers: Headers;
89+
};
90+
91+
export type postShortenResponse = (postShortenResponseSuccess | postShortenResponseError)
92+
93+
export const getPostShortenUrl = () => {
94+
95+
96+
97+
98+
return `/shorten`
99+
}
100+
101+
export const postShorten = async (handlersCreateURLRequest: HandlersCreateURLRequest, options?: RequestInit): Promise<postShortenResponse> => {
102+
103+
return customInstance<postShortenResponse>(getPostShortenUrl(),
104+
{
105+
...options,
106+
method: 'POST',
107+
headers: { 'Content-Type': 'application/json', ...options?.headers },
108+
body: JSON.stringify(
109+
handlersCreateURLRequest,)
110+
}
111+
);}
112+
113+
114+
115+
/**
116+
* Get the original URL from a short URL
117+
* @summary Get original URL by short URL
118+
*/
119+
export type getShortUrlResponse308 = {
120+
data: GetShortUrl308
121+
status: 308
122+
}
123+
124+
export type getShortUrlResponse404 = {
125+
data: HandlersErrorResponse
126+
status: 404
127+
}
128+
129+
export type getShortUrlResponse500 = {
130+
data: GetShortUrl500
131+
status: 500
132+
}
133+
134+
;
135+
export type getShortUrlResponseError = (getShortUrlResponse308 | getShortUrlResponse404 | getShortUrlResponse500) & {
136+
headers: Headers;
137+
};
138+
139+
export type getShortUrlResponse = (getShortUrlResponseError)
140+
141+
export const getGetShortUrlUrl = (shortUrl: string,) => {
142+
143+
144+
145+
146+
return `/${shortUrl}`
147+
}
148+
149+
export const getShortUrl = async (shortUrl: string, options?: RequestInit): Promise<getShortUrlResponse> => {
150+
151+
return customInstance<getShortUrlResponse>(getGetShortUrlUrl(shortUrl),
152+
{
153+
...options,
154+
method: 'GET'
155+
156+
157+
}
158+
);}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
const baseURL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8080";
2+
3+
const getFetch = async (): Promise<typeof fetch> => {
4+
if (typeof window === "undefined") {
5+
try {
6+
const { fetch: undiciFetch } = await import("undici");
7+
return undiciFetch as unknown as typeof fetch;
8+
} catch {
9+
return globalThis.fetch;
10+
}
11+
} else {
12+
return window.fetch;
13+
}
14+
};
15+
16+
export const customInstance = async <T>(
17+
url: string,
18+
options?: RequestInit,
19+
): Promise<{
20+
data: T;
21+
status: number;
22+
statusText: string;
23+
headers: Headers;
24+
}> => {
25+
const fullUrl = `${baseURL}${url}`;
26+
27+
const fetchImpl = await getFetch();
28+
const response = await fetchImpl(fullUrl, {
29+
...options,
30+
method: options?.method || "GET",
31+
});
32+
33+
let responseData: T;
34+
const contentType = response.headers.get("content-type");
35+
if (contentType?.includes("application/json")) {
36+
responseData = await response.json();
37+
} else {
38+
const text = await response.text();
39+
try {
40+
responseData = JSON.parse(text) as T;
41+
} catch {
42+
responseData = text as unknown as T;
43+
}
44+
}
45+
46+
return {
47+
data: responseData,
48+
status: response.status,
49+
statusText: response.statusText,
50+
headers: response.headers,
51+
};
52+
};
53+
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { NextResponse } from "next/server";
2+
import { getShortUrl } from "@/api/lnk";
3+
4+
export async function GET(
5+
_request: Request,
6+
context: { params: Promise<{ shortUrl: string }> | { shortUrl: string } }
7+
) {
8+
const params = context.params;
9+
const resolvedParams = params instanceof Promise ? await params : params;
10+
const shortUrl = resolvedParams?.shortUrl;
11+
12+
if (!shortUrl) {
13+
return NextResponse.json(
14+
{ error: "Short URL parameter is required" },
15+
{ status: 400 }
16+
);
17+
}
18+
19+
try {
20+
const response = await getShortUrl(shortUrl);
21+
22+
console.log("response", response);
23+
24+
if (response.status === 308) {
25+
const location = response.headers.get("Location");
26+
if (location) {
27+
return NextResponse.redirect(location, 308);
28+
}
29+
30+
const data = response.data as { [key: string]: string };
31+
const originalUrl = data.original_url || data.url || Object.values(data)[0];
32+
if (originalUrl) {
33+
return NextResponse.redirect(originalUrl, 308);
34+
}
35+
}
36+
37+
if (response.status === 404) {
38+
return NextResponse.json(
39+
{ error: "Short URL not found" },
40+
{ status: 404 }
41+
);
42+
}
43+
44+
return NextResponse.json(
45+
{ error: "Failed to redirect", status: response.status },
46+
{ status: response.status }
47+
);
48+
} catch (error) {
49+
console.error("Error fetching short URL:", error);
50+
return NextResponse.json(
51+
{ error: "Internal server error" },
52+
{ status: 500 }
53+
);
54+
}
55+
}
56+

frontend/src/app/actions.ts

Lines changed: 0 additions & 44 deletions
This file was deleted.

frontend/src/components/ui/dialog.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ function DialogOverlay({
3838
<DialogPrimitive.Overlay
3939
data-slot="dialog-overlay"
4040
className={cn(
41-
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
41+
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50 backdrop-blur-sm",
4242
className,
4343
)}
4444
{...props}
@@ -60,7 +60,7 @@ function DialogContent({
6060
<DialogPrimitive.Content
6161
data-slot="dialog-content"
6262
className={cn(
63-
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
63+
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-100 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
6464
className,
6565
)}
6666
{...props}
@@ -69,7 +69,7 @@ function DialogContent({
6969
{showCloseButton && (
7070
<DialogPrimitive.Close
7171
data-slot="dialog-close"
72-
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
72+
className="ring-offset-background focus:ring-ring text-foreground data-[state=open]:bg-accent data-[state=open]:text-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 hover:text-foreground focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
7373
>
7474
<XIcon />
7575
<span className="sr-only">Close</span>

0 commit comments

Comments
 (0)