No description
  • TypeScript 75.4%
  • Svelte 20.3%
  • JavaScript 3%
  • HTML 1.3%
Find a file
2025-12-03 09:48:14 +00:00
assets week 12 pdf 2025-12-02 23:55:43 +00:00
e2e week 12 lab 2025-12-02 23:48:18 +00:00
src week 12 pdf 2025-12-02 23:55:43 +00:00
static week 12 lab 2025-12-02 23:48:18 +00:00
.env.example week 12 lab 2025-12-02 23:48:18 +00:00
.gitignore week 12 lab 2025-12-02 23:48:18 +00:00
.npmrc week 12 lab 2025-12-02 23:48:18 +00:00
.prettierignore week 12 lab 2025-12-02 23:48:18 +00:00
.prettierrc week 12 lab 2025-12-02 23:48:18 +00:00
contact.sql week 12 lab 2025-12-02 23:48:18 +00:00
data.json week 12 lab 2025-12-02 23:48:18 +00:00
drizzle.config.ts week 12 lab 2025-12-02 23:48:18 +00:00
local.db.bak fixed db 2025-12-03 09:48:14 +00:00
package.json week 12 lab 2025-12-02 23:48:18 +00:00
playwright.config.ts week 12 lab 2025-12-02 23:48:18 +00:00
products.sql week 12 lab 2025-12-02 23:48:18 +00:00
README.md fixed db 2025-12-03 09:48:14 +00:00
recipes.sql week 12 lab 2025-12-02 23:48:18 +00:00
students.sql week 12 lab 2025-12-02 23:48:18 +00:00
svelte.config.js week 12 lab 2025-12-02 23:48:18 +00:00
tsconfig.json week 12 lab 2025-12-02 23:48:18 +00:00
vite.config.ts week 12 lab 2025-12-02 23:48:18 +00:00

Lab 12: API and Client

Enda Lee 2025

PDF version

Getting Started

  1. Clone or download this repository.
  2. Open the app folder in Visual Studio Code.
  3. Create a .env file in the application root using .env.example as a template.
  4. Rename local.db.bak to local.db
  5. In the terminal, run npm install to install dependencies.
  6. Start the development server with npm run dev.

Overview

The examples in previous labs were self-contained fullstack applications which access their own data stores. While this works well, there are times when you will want to share data between different client applications - for example, a web app and website could share the same data source. This can be achieved by accessing all data via a shared API.

The example database consists of 2 tables: productCategory and product, which are generated using the Drizzle ORM as before and stored in the local.db SQLite database.

ERD

You will find the data access and service functions in src\lib\server. Note the types exports in schema.ts. Theses are used throughout the application allowing TypeScript to validate types.

// Type exports
export type ProductCategory = typeof productCategory.$inferSelect;
export type NewProductCategory = typeof productCategory.$inferInsert;

export type Product = typeof product.$inferSelect;
export type NewProduct = typeof product.$inferInsert;

// Combined type for Product with its Category details
export type ProductWithCategory = Product & {
	categoryDetails: ProductCategory;
};

// Combined type for ProductCategory with its Products
export type ProductCategoryWithProducts = ProductCategory & {
	products: Product[];
};	

Accessing data via HTTP requests

This lab demonstrates how to build a RESTful API in SvelteKit. Data will be accessed via HTTP requests to various endpoints. The following endpoints are defined:

  1. GET /api/v1/products — retrieve all products.

  2. GET /api/v1/products/:id — retrieve a product by ID.

  3. POST /api/v1/products — create a new product.

  4. PUT /api/v1/products/:id — update an existing product.

  5. DELETE /api/v1/products/:id — delete a product.

  6. GET /api/v1/categories — retrieve all categories.

  7. GET /api/v1/categories/:id — retrieve a category by ID.

The HTTP method defines the operation type: GET reads data, POST creates, PUT updates, and DELETE removes. This follows RESTful API design principles.

1. Building the API

API endpoints work similiar to page routes used before but they return data, in this case JSON insteade of complete HTML pages.

They are defined under src\routes\api. Here we have two enpoints for categories and products. The route for each includes an optional parameter, [[id]] , which is used to requerst a single category or product. If this is not included in the URI then all items will be returned in the response.

It is important to maintain compatibility if the endpoint changes so the api version, api/v1, ios included in the routes.

API routing endpoints

The API endpoints are defined in the +server.ts files. Each endpoint function implments an HTTP method, GET, POST, etc.

The following GET function, defined in src/routes/api/v1/products/[[id]]/+server.ts returns a product or all products as JSON

// src/routes/api/v1/products/[[id]]/+server.ts
import { json } from '@sveltejs/kit';
import { productService } from '$lib/server/services/product-service';
import { error } from '@sveltejs/kit';
import type { RequestHandler } from '../$types';
import type { Product } from '$lib/server/db/schema';

// GET /api/v1/products - Get all products
// get /api/v1/products/:id - Get product by ID
export const GET: RequestHandler = async ({ params }) => {
    try {

        // Check if an ID parameter is provided
        if (params.id && !isNaN(Number(params.id))) {
            const productId = Number(params.id);
            const product: Product | undefined = await productService.getProductById(productId);
            if (product) {
                return json(product, { status: 200 });
            } else {
                throw error(404, 'Product not found');
            }
        }
        // If no ID is provided, return all products
        const products = await productService.getAllProducts();
        return json(products, { status: 200 });

    } catch (err) {
        console.error('Error fetching products:', err);
        throw error(500, 'Failed to fetch products');
    }
};

Exercise: Run the application and test the endpoint in a web browser.

You should see a result similiar to the screenshot.

get all products

2. Testing the API

We now have a way to get data via HTTP, without requiring direct access to the database. However, a standard web browser does not make it easy to test API routes other than GET.

To test an API properly, tools such as PostMan and Thunder Client can be used. These tools allow the requiredHTTP method to be selected to test all endpooints.

Thunder Client can be installed as a VS Code extension and will be used in this example.

Thunder Client

2.1 Start by getting all products using a GET request for http://localhoost:5173/api/v1/products

Follow the steps below to try this in Thunder Client

image-20251202224430598

2.2 Add a new product using a POST request

Add another new request - otherwise this will overwrite the previous one (in Thunder Client).

This time choose POST and use the same URI as last time. Even though the address is the same, the API will see a HTTP POST (not a GET) and handle it appropriately.

POST requests can include a payload in the request BODY which is used to send JSON data for a new product. Try it.

Add a new product thunder client

Exercises

  1. Use Thunder Client to update existings products via the API.
  2. Delete a product via the API.

Part 2: Build a client application to use the API

Now that the API is running, buid a client application, using SvelteKit, to use the API as a data source.

Important: Leave the API running and listening on http://localhost:5173

1. Create a new application

Start with an empty folder, e.g. lab12-client-app and create a new SvelteKit application.

npx sv create .

Note that drizzle and the database are not required in this example.

1.1 Setup Bootstrap, shared layout, and navigation

Follow previous examples for this. Two page routes are required for the home page and products

1.2 Add a .env for environment variables

This is to avoid hard coding values such as API endpoinfts which may need to be changed

.env

PUBLIC_BASE_API_URL="http://localhost:5173/api/v1/"
PUBLIC_PRODUCTS_ENDPOINT="products"
PUBLIC_CATEGORIES_ENDPOINT="categories"

1.3 Define data objects and types

Define the Product and ProductCategory data objects and types for validation and TypeScript checking.

  1. Start by installing zod
npm install zod
  1. Add a new file src\lib\dataModel\index.ts

    The content is similar to the server-side, Zod is used to define the objects as validation schema.

// src/lib/dataModel/index.ts
import { z } from 'zod';

// Product
export const Product = z.object({
    id: z.number().int().positive(),
    category_id: z.number().int().positive(),
    thumbnail: z.url().min(7).max(500),    
    title: z.string().min(1).max(255),
    description: z.string().max(1000),
    price: z.number().nonnegative(),
    stock: z.number().int().min(0),
});

// ProductCategory
export const ProductCategory = z.object({
    id: z.number().int().positive(),
    name: z.string().min(1).max(100),
});

// ProductWithCategory (combination of Product and ProductCategory)
export const ProductWithCategory = Product.extend({
    categoryDetails: ProductCategory,
});

// CategoryWithProducts (ProductCategory with an array of Products)
export const CategoryWithProducts = ProductCategory.extend({
    products: z.array(Product),
});

// Export TypeScript types
export type Product = z.infer<typeof Product>;
export type ProductCategory = z.infer<typeof ProductCategory>;
export type ProductWithCategory = z.infer<typeof ProductWithCategory>;
export type CategoryWithProducts = z.infer<typeof CategoryWithProducts>;

1.4 Define API client functions

These functions will use the fetch API to access the API endpoints.

Create src/lib/api/products.ts

// src/lib/api/products.ts

import type { Product, ProductCategory, ProductWithCategory, CategoryWithProducts } from '$lib/dataModel/index.js';

// Import public (client-side accessible) environment variables for API endpoints
import { PUBLIC_BASE_API_URL, PUBLIC_PRODUCTS_ENDPOINT, PUBLIC_CATEGORIES_ENDPOINT} from '$env/static/public';

// Construct full API URLs
const productsApiUrl = `${PUBLIC_BASE_API_URL}${PUBLIC_PRODUCTS_ENDPOINT}`;
const categoriesApiUrl = `${PUBLIC_BASE_API_URL}${PUBLIC_CATEGORIES_ENDPOINT}`;

// Fetch all products
export async function fetchAllProducts(): Promise<ProductWithCategory[]> {
    // Fetch products from the API
    const response = await fetch(productsApiUrl);
    if (!response.ok) {
        throw new Error('Failed to fetch products');
    }
    // Parse and return the products
    const products: ProductWithCategory[] = await response.json();
    return products;
}

// Fetch product by ID
export async function fetchProductById(id: number): Promise<ProductWithCategory> {
    // Fetch product from the API
    const response = await fetch(`${productsApiUrl}/${id}`);
    if (!response.ok) {
        throw new Error(`Failed to fetch product with ID ${id}`);
    }
    // Parse and return the product
    const product: ProductWithCategory = await response.json();
    return product;
}

// Fetch all categories
export async function fetchAllCategories(): Promise<ProductCategory[]> {
    const response = await fetch(categoriesApiUrl);
    if (!response.ok) {
        throw new Error('Failed to fetch categories with products');
    }
    const categories: CategoryWithProducts[] = await response.json();
    return categories;
}
// Fetch category by ID
export async function fetchCategoryById(id: number): Promise<CategoryWithProducts> {
    const response = await fetch(`${categoriesApiUrl}/${id}`);
    if (!response.ok) {
        throw new Error(`Failed to fetch category with ID ${id}`);
    }
    const category: CategoryWithProducts = await response.json();
    return category;
}

1.5 The products route

This page will display categories and products. It will look like previous examples but work differently.

Products page

Add the route and files:

route/products

+page.ts runs on server-side as well as in the browser, allowing server-side rendering before display. Add the following code to fetch category and product data for the page:

// src/routes/products/+page.ts
import { fetchAllProducts, fetchAllCategories } from "$lib/api/products";

export async function load() {
    const products = await fetchAllProducts();
    const categories = await fetchAllCategories();

    return { products, categories };

}

The products page, +page.svelte looks very similar except noe data is coming from the API.

Filtering by category also retrieves from the API instead of simply filtering data already loaded. As the asynd call comes from the client-side, the entire page does not need to be refreshed so it will still behave as before.

<script lang="ts">
    import type { Product, ProductCategory, ProductWithCategory, CategoryWithProducts } from '$lib/dataModel/index.js';
	import { fetchAllProducts, fetchCategoryById } from "$lib/api/products";

	// Get data passed from the load function
	let { data } = $props();

	// State for all products
	let products = $state<ProductWithCategory[]>(data.products);
    let categories = $state<ProductCategory[]>(data.categories);

	// filter products by category
	async function handleFilterClick(filter_value: number | null) {
		// If no filter is selected, return all products
		if (filter_value === null) {
			products = await fetchAllProducts();
		} else {
			// Otherwise, filter products by selected category
			let result = await fetchCategoryById(filter_value);
			// Update the products state with the filtered products
			products = result.products as ProductWithCategory[];
		}
	}
</script>

<div class="container-fluid">
	<div class="row">
		<div class="col-lg-2 mb-4">
			<div class="sticky-top" style="top: 1rem;">
				<h2 class="h5 mb-3">Filters</h2>
				<nav aria-label="filter">
					<div id="filter-menu" class="list-group mb-3">
						<!-- This button shows all products -->
						<button
							onclick={() => handleFilterClick(null)}
							class="list-group-item list-group-item-action">All</button
						>
						<!-- These buttons filters products by category onclick calls the event handler -->
						{#each categories as category}
                            <button
                                onclick={() => handleFilterClick(category.id)}
                                class="list-group-item list-group-item-action">{category.name}</button
                            >
             {/each}
					</div>
				</nav>
			</div>
		</div>

		<div class="col-lg-10">
			<h1>products</h1>
			<table class="table table-striped">
				<thead>
					<tr>
						<th scope="col">Picture</th>
						<th scope="col">Title</th>
						<th scope="col">Category</th>
						<th scope="col">Description</th>
						<th scope="col">Stock</th>
						<th scope="col">Price</th>
					</tr>
				</thead>
				<tbody id="productList">
					{#each products as product}
						<tr>
							<td><img src={product.thumbnail} alt={product.title} class="img-thumbnail" /></td>
							<td>{product.title}</td>
							<td>{product.categoryDetails?.name || product.category_id}</td>
							<td>{product.description}</td>
                            <td>{product.stock}</td>
							<td>{product.price}</td>
						</tr>
					{/each}
				</tbody>
			</table>
		</div>
	</div>
</div>

Notes

The current access control implementation based on Lucia and server-side sessions will no longer work as the application api and client applications are on different servers, localhost:5173 and localhost:5174. This will require a different solution, e.g using JSON Web Tokens (JWT).

Exercise

Use a form to add a new product using an API call.


Enda Lee 2025