API Integration Patterns
Guide for integrating the frontend with the B12 SIS backend API.
API Base Configuration
Environment Setup
// environments/environment.ts
export const environment = {
production: false,
apiUrl: 'http://localhost:8080/api'
};
// environments/environment.prod.ts
export const environment = {
production: true,
apiUrl: 'https://api.school.edu/api'
};
ApiService
The core HTTP wrapper for all API calls:
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { environment } from 'environments/environment';
@Injectable({ providedIn: 'root' })
export class ApiService {
private baseUrl = environment.apiUrl;
constructor(private http: HttpClient) {}
get<T>(endpoint: string, params?: HttpParams) {
return this.http.get<T>(`${this.baseUrl}/${endpoint}`, { params });
}
post<T>(endpoint: string, body?: any, queryParams?: Record<string, string>) {
let url = `${this.baseUrl}/${endpoint}`;
if (queryParams) {
url += `?${new URLSearchParams(queryParams).toString()}`;
}
return this.http.post<T>(url, body);
}
put<T>(endpoint: string, body?: any) {
return this.http.put<T>(`${this.baseUrl}/${endpoint}`, body);
}
delete<T>(endpoint: string) {
return this.http.delete<T>(`${this.baseUrl}/${endpoint}`);
}
}
Standard CRUD Operations
List with Pagination and Filters
// Request
POST /api/students/list
// Body
{
"page": 1,
"page_size": 25,
"sort_by": "created_at",
"sort_order": "desc",
"filters": [
{
"field": "status",
"operator": "eq",
"value": "active"
}
]
}
Frontend Implementation:
interface ListRequest {
page: number;
page_size: number;
sort_by?: string;
sort_order?: 'asc' | 'desc';
filters?: Filter[];
}
interface Filter {
field: string;
operator: 'eq' | 'ne' | 'like' | 'gt' | 'gte' | 'lt' | 'lte' | 'in' | 'not_in';
value: any;
}
// Service
listStudents(request: ListRequest) {
return this.api.post<ListResponse<Student>>('students/list', request);
}
// Component
loadStudents() {
this.studentService.listStudents({
page: this.currentPage,
page_size: 25,
sort_by: 'last_name',
sort_order: 'asc',
filters: [
{ field: 'status', operator: 'eq', value: 'active' }
]
}).subscribe(response => {
this.students = response.data;
this.totalRecords = response.metadata.total;
});
}
Response Format
All API responses follow this structure:
interface ApiResponse<T> {
data: T;
message?: string;
metadata?: {
page: number;
page_size: number;
total: number;
total_pages: number;
};
}
Create
// Request
POST /api/students
{
"first_name": "John",
"last_name": "Doe",
"email": "john.doe@school.edu",
"date_of_birth": "2010-05-15"
}
// Response
{
"data": {
"id": "uuid-here",
"first_name": "John",
"last_name": "Doe",
...
},
"message": "Student created successfully"
}
Frontend Implementation:
createStudent(student: StudentInput) {
return this.api.post<ApiResponse<Student>>('students', student);
}
// Component
onSubmit() {
this.studentService.createStudent(this.form.value)
.subscribe({
next: (response) => {
this.notification.showSuccess('Student created');
this.router.navigate(['/module/students', response.data.id]);
},
error: (error) => {
this.notification.showError(error.message);
}
});
}
Read (Get by ID)
// Request
GET /api/students/:id
// Response
{
"data": {
"id": "uuid-here",
"first_name": "John",
...
}
}
Frontend Implementation:
getStudent(id: string) {
return this.api.get<ApiResponse<Student>>(`students/${id}`);
}
// Component
ngOnInit() {
const id = this.route.snapshot.paramMap.get('id');
this.studentService.getStudent(id).subscribe(response => {
this.student = response.data;
});
}
Update
// Request
PUT /api/students/:id
{
"first_name": "Jane",
"last_name": "Doe"
}
// Response
{
"data": { ... },
"message": "Student updated successfully"
}
Frontend Implementation:
updateStudent(id: string, data: Partial<StudentInput>) {
return this.api.put<ApiResponse<Student>>(`students/${id}`, data);
}
// Component
onSave() {
this.studentService.updateStudent(this.student.id, this.form.value)
.subscribe({
next: () => {
this.notification.showSuccess('Student updated');
}
});
}
Delete
// Request
DELETE /api/students/:id
// Response
{
"message": "Student deleted successfully"
}
Frontend Implementation:
deleteStudent(id: string) {
return this.api.delete<ApiResponse<void>>(`students/${id}`);
}
// Component with confirmation
onDelete(student: Student) {
if (confirm(`Delete ${student.first_name} ${student.last_name}?`)) {
this.studentService.deleteStudent(student.id)
.subscribe(() => {
this.notification.showSuccess('Student deleted');
this.refreshList();
});
}
}
Authentication
Login Flow
// AuthService
login(email: string, password: string) {
return this.api.post<Auth>('auth/login', { email, password }).pipe(
tap(user => {
this.currentUserSubject.next(user);
}),
concatMap(() => this.api.get<{data: {permissions: Permission[], user: any}}>('auth/me')),
tap(({data}) => {
this.currentUserValue.permissions = data.permissions;
this.currentUserValue.user = data.user;
localStorage.setItem('currentUser', JSON.stringify(this.currentUserValue));
})
);
}
// Component
onLogin() {
this.authService.login(this.email, this.password).subscribe({
next: () => {
this.router.navigate(['/module/dashboard']);
},
error: (error) => {
this.errorMessage = 'Invalid email or password';
}
});
}
JWT Token Handling
The JwtInterceptor automatically adds the token:
@Injectable()
export class JwtInterceptor implements HttpInterceptor {
constructor(private authService: AuthService) {}
intercept(request: HttpRequest<any>, next: HttpHandler) {
const currentUser = this.authService.currentUserValue;
if (currentUser?.token?.access_token) {
request = request.clone({
setHeaders: {
Authorization: `Bearer ${currentUser.token.access_token}`
}
});
}
return next.handle(request);
}
}
Token Refresh
// Backend: POST /api/auth/refresh
// Body: { "refresh_token": "..." }
// Response: { "access_token": "...", "refresh_token": "..." }
refreshToken() {
const refreshToken = this.currentUserValue.token.refresh_token;
return this.api.post<{access_token: string, refresh_token: string}>('auth/refresh', {
refresh_token: refreshToken
}).pipe(
tap(tokens => {
this.currentUserValue.token = tokens;
this.currentUserSubject.next(this.currentUserValue);
localStorage.setItem('currentUser', JSON.stringify(this.currentUserValue));
})
);
}
Multi-Tenancy
Team Header Injection
The TeamInterceptor adds the team context:
@Injectable()
export class TeamInterceptor implements HttpInterceptor {
constructor(private authService: AuthService) {}
intercept(request: HttpRequest<any>, next: HttpHandler) {
const teamId = this.authService.currentUserValue.active_team_id;
if (teamId) {
request = request.clone({
setHeaders: {
'X-Team-ID': teamId
}
});
}
return next.handle(request);
}
}
Switching Teams
// Service
setActiveTeam(teamId: string) {
this.currentUserValue.active_team_id = teamId;
this.currentUserSubject.next(this.currentUserValue);
localStorage.setItem('currentUser', JSON.stringify(this.currentUserValue));
return of(this.currentUserValue);
}
// Component (team selector)
onTeamChange(teamId: string) {
this.authService.setActiveTeam(teamId).subscribe(() => {
// Refresh current view to load team-specific data
window.location.reload();
});
}
Advanced Filtering
Complex Filter Queries
// AND filters (default)
{
"filters": [
{ "field": "status", "operator": "eq", "value": "active" },
{ "field": "grade_level", "operator": "eq", "value": "10" }
]
}
// OR filters
{
"filters": [
{ "field": "status", "operator": "in", "value": ["active", "pending"] }
]
}
// Date range
{
"filters": [
{ "field": "created_at", "operator": "gte", "value": "2024-01-01" },
{ "field": "created_at", "operator": "lte", "value": "2024-12-31" }
]
}
// LIKE search
{
"filters": [
{ "field": "first_name", "operator": "like", "value": "John" }
]
}
Relation Filters
Filter by related entity fields:
// Filter students by class name
{
"filters": [
{
"field": "current_class_id",
"operator": "relate",
"relate_filters": [
{ "field": "name", "operator": "like", "value": "Grade 10" }
]
}
]
}
File Upload
Upload to S3
// Get presigned URL
POST /api/file_uploads/presigned
{
"file_name": "document.pdf",
"content_type": "application/pdf",
"module": "Student"
}
// Response
{
"data": {
"upload_url": "https://s3.../presigned-url",
"file_url": "https://s3.../final-url"
}
}
Frontend Implementation:
uploadFile(file: File, module: string) {
// Step 1: Get presigned URL
return this.api.post<{data: {upload_url: string, file_url: string}}>('file_uploads/presigned', {
file_name: file.name,
content_type: file.type,
module
}).pipe(
// Step 2: Upload to S3
concatMap(response => {
return this.http.put(response.data.upload_url, file, {
headers: { 'Content-Type': file.type }
}).pipe(
map(() => response.data.file_url)
);
})
);
}
// Component
onFileSelected(event: Event) {
const file = (event.target as HTMLInputElement).files[0];
this.fileService.uploadFile(file, 'Student').subscribe(fileUrl => {
this.form.patchValue({ document_url: fileUrl });
});
}
Error Handling
ErrorInterceptor
@Injectable()
export class ErrorInterceptor implements HttpInterceptor {
constructor(
private authService: AuthService,
private router: Router,
private notification: NotificationService
) {}
intercept(request: HttpRequest<any>, next: HttpHandler) {
return next.handle(request).pipe(
catchError((error: HttpErrorResponse) => {
switch (error.status) {
case 401:
// Unauthorized - redirect to login
this.authService.logout();
this.router.navigate(['/authentication/signin']);
break;
case 403:
// Forbidden
this.notification.showError('You do not have permission');
break;
case 404:
this.notification.showError('Resource not found');
break;
case 422:
// Validation error
const message = error.error?.message || 'Validation failed';
this.notification.showError(message);
break;
case 500:
this.notification.showError('Server error. Please try again.');
break;
}
return throwError(() => error);
})
);
}
}
Validation Errors
Backend returns validation errors:
{
"message": "Validation failed",
"errors": {
"email": ["Email is required", "Email format is invalid"],
"first_name": ["First name is required"]
}
}
Frontend Handling:
onSubmit() {
this.studentService.createStudent(this.form.value).subscribe({
error: (error) => {
if (error.error?.errors) {
// Map backend errors to form controls
Object.keys(error.error.errors).forEach(field => {
const control = this.form.get(field);
if (control) {
control.setErrors({ serverError: error.error.errors[field][0] });
}
});
}
}
});
}
Real-Time Updates
Polling Pattern
// Poll for updates every 30 seconds
pollForUpdates() {
return interval(30000).pipe(
startWith(0),
switchMap(() => this.api.get('notifications/unread')),
takeUntil(this.destroy$)
);
}
WebSocket (if implemented)
// BroadcastService for cross-component communication
@Injectable({ providedIn: 'root' })
export class BroadcastService {
private messageSource = new Subject<BroadcastMessage>();
message$ = this.messageSource.asObservable();
send(message: BroadcastMessage) {
this.messageSource.next(message);
}
}
Batch Operations
Bulk Delete
// Request
POST /api/students/bulk-delete
{
"ids": ["uuid-1", "uuid-2", "uuid-3"]
}
// Response
{
"message": "3 students deleted successfully"
}
Frontend Implementation:
bulkDelete(ids: string[]) {
return this.api.post<ApiResponse<void>>('students/bulk-delete', { ids });
}
// Component
onBulkDelete() {
const selectedIds = this.selectedStudents.map(s => s.id);
this.studentService.bulkDelete(selectedIds).subscribe(() => {
this.notification.showSuccess(`${selectedIds.length} students deleted`);
this.clearSelection();
this.refreshList();
});
}
Export/Import
Export to Excel
// Request
POST /api/students/export
{
"filters": [...],
"columns": ["first_name", "last_name", "email", "status"]
}
// Response: File download
Frontend Implementation:
exportStudents(filters: Filter[], columns: string[]) {
return this.http.post(`${this.baseUrl}/students/export`,
{ filters, columns },
{ responseType: 'blob' }
).pipe(
tap(blob => {
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'students.xlsx';
a.click();
window.URL.revokeObjectURL(url);
})
);
}
Import from Excel
See Import System Guide for detailed import documentation.
Caching Strategies
Service-Level Caching
@Injectable({ providedIn: 'root' })
export class GradeLevelService {
private cache$ = new ReplaySubject<GradeLevel[]>(1);
private loaded = false;
getGradeLevels() {
if (!this.loaded) {
this.api.get<ListResponse<GradeLevel>>('grade_levels/list').pipe(
map(response => response.data)
).subscribe(data => {
this.cache$.next(data);
this.loaded = true;
});
}
return this.cache$.asObservable();
}
invalidateCache() {
this.loaded = false;
}
}
HTTP Caching Headers
Backend returns appropriate cache headers:
Cache-Control: no-cachefor dynamic dataCache-Control: max-age=3600for static data
Performance Tips
Use pagination - Never load all records
Lazy load modules - Reduce initial bundle
Cancel pending requests - Use
takeUntilorswitchMapDebounce search - Prevent excessive API calls
Cache reference data - Grade levels, subjects, etc.
// Debounced search
searchControl.valueChanges.pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap(term => this.search(term)),
takeUntil(this.destroy$)
).subscribe(results => {
this.searchResults = results;
});