feat: Add "version" and "os" fields to rice database

- The rice database now includes new fields:
  - `version`: Represents the version of the rice entry.
  - `os`: Represents the operating system associated with the rice entry.
- These fields are required for all new rice entries.

refactor: Stop uploading rice.json to GitHub
- The `rice.json` file is no longer uploaded to GitHub during rice creation or updates.
- This reduces redundancy as all metadata is now managed directly in the database (Supabase).

fix: Improve exception handling with proper HTTP status codes
- Enhanced exception handling to align with standard HTTP status codes:
  - `BadRequestException` for validation errors.
  - `ConflictException` for duplicate entries.
  - `NotFoundException` for missing resources.
  - Generic `InternalServerErrorException` for unexpected errors.
- This ensures the API returns meaningful and accurate responses.

feat: Enhance rice download to act as a standard HTTP GET
- The `findOne` method now returns the raw content of the rice file directly as the response body.
- Removes unnecessary JSON wrappers, allowing the endpoint to behave like a typical HTTP GET request.
- Improved usability for clients consuming the API.
This commit is contained in:
oscargonzalezmoreno@gmail.com 2024-12-27 11:24:39 +01:00
parent def257b9ba
commit d44ea66b40
11 changed files with 215 additions and 80 deletions

View file

@ -2,6 +2,8 @@
CREATE TABLE rices (
id UUID NOT NULL, -- Unique identifier
version VARCHAR(10) NOT NULL, -- Data version
os VARCHAR(30) NOT NULL, -- Operating system
slug VARCHAR(75) NOT NULL, -- Unique user-friendly identifier
name VARCHAR(75) NOT NULL, -- Name of the rice
token UUID NOT NULL, -- Unique authorization token
@ -14,7 +16,6 @@ CREATE TABLE rices (
UNIQUE (name) -- Ensure name is unique
);
CREATE OR REPLACE FUNCTION increment_visits(slug_param TEXT)
RETURNS VOID AS $$
BEGIN

View file

@ -0,0 +1,24 @@
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
} from '@nestjs/common';
import { Response } from 'express';
@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
catch(exception: HttpException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const status = exception.getStatus();
const exceptionResponse = exception.getResponse();
response.status(status).json({
statusCode: status,
...(typeof exceptionResponse === 'string'
? { message: exceptionResponse }
: exceptionResponse),
});
}
}

View file

@ -336,6 +336,67 @@ export class GitHubService implements OnModuleInit {
}
}
/**
* Deletes an empty folder from the repository.
* Assumes the folder is already empty.
* @param folderPath Path of the folder in the repository.
* @param commitMessage Commit message for the deletion.
*/
async deleteFolder(folderPath: string, commitMessage: string): Promise<void> {
try {
// GitHub does not support direct folder deletion; instead, ensure no files remain
const files = await this.listFilesInDirectory(folderPath);
if (files.length > 0) {
throw new Error(`Folder ${folderPath} is not empty. Cannot delete.`);
}
// GitHub API requires at least a dummy file like .gitkeep to keep directories
const gitkeepPath = `${folderPath}/.gitkeep`;
try {
const { data: existingFile } = await this.octokit.repos.getContent({
owner: this.repoOwner,
repo: this.repoName,
path: gitkeepPath,
ref: this.defaultBranch,
});
if (!('sha' in existingFile)) {
throw new Error(
`The .gitkeep file in ${folderPath} does not have a valid SHA`,
);
}
const sha = existingFile.sha;
// Delete the .gitkeep file
await this.octokit.repos.deleteFile({
owner: this.repoOwner,
repo: this.repoName,
path: gitkeepPath,
message: commitMessage,
sha,
branch: this.defaultBranch,
});
this.logger.log(`Folder ${folderPath} deleted successfully.`);
} catch (error: any) {
if (isOctokitResponseError(error) && error.status === 404) {
this.logger.warn(
`The .gitkeep file in ${folderPath} does not exist.`,
);
} else {
throw error;
}
}
} catch (error) {
this.logger.error(`Error deleting folder ${folderPath}: ${error}`);
throw new InternalServerErrorException(
`Failed to delete folder: ${error}`,
);
}
}
/**
* Clears all files in the GitHub repository.
* Useful for cleaning the state before running tests.

View file

@ -3,11 +3,13 @@ import { AppModule } from './app.module';
import { ValidationPipe } from '@nestjs/common';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { ThrottlerExceptionFilter } from './common/filters/throttler-exception.filter';
import { HttpExceptionFilter } from './filters/http-exception.filter';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(new ValidationPipe({ transform: true }));
app.useGlobalFilters(new HttpExceptionFilter());
app.useGlobalFilters(new ThrottlerExceptionFilter());
const config = new DocumentBuilder()

View file

@ -4,6 +4,12 @@ export class CreateRiceDto {
@IsString()
name!: string;
@IsString()
version!: string;
@IsString()
os!: string;
@IsString()
content!: string;
}

View file

@ -3,6 +3,7 @@ import {
NotFoundException,
UnauthorizedException,
ConflictException,
BadRequestException,
} from '@nestjs/common';
import { CreateRiceDto } from './dto/create-rice.dto';
import { UpdateRiceDto } from './dto/update-rice.dto';
@ -19,6 +20,14 @@ export class RicesService {
) {}
async create(createRiceDto: CreateRiceDto) {
try {
// Ensure required fields are present
if (!createRiceDto.name || !createRiceDto.version || !createRiceDto.os) {
throw new BadRequestException(
'Missing required fields: name, version, and os are mandatory.',
);
}
// Check if a rice with the same name already exists
const existingRice = await this.supabaseService.getRiceByName(
createRiceDto.name,
@ -42,23 +51,18 @@ export class RicesService {
const metadata = {
id: uuidv4(),
token,
name: createRiceDto.name || null,
name: createRiceDto.name,
version: createRiceDto.version,
os: createRiceDto.os,
slug: slug,
visits: 0,
level: 0,
created_at: new Date().toISOString(),
};
// Insert metadata into Supabase
await this.supabaseService.insertRice(metadata);
const metadataContent = JSON.stringify(metadata, null, 2);
const riceJsonPath = `rices/${slug}/rice.json`;
await this.gitHubService.createOrUpdateFile(
riceJsonPath,
metadataContent,
`Add rice ${slug}`,
);
if (createRiceDto.content) {
const uploadedFilePath = `rices/${slug}/data.zenrice`;
await this.gitHubService.createOrUpdateFile(
@ -69,6 +73,23 @@ export class RicesService {
}
return { slug, token };
} catch (error) {
// Log the error for debugging
console.error('Error in create method:', error);
if (
error instanceof ConflictException ||
error instanceof BadRequestException
) {
throw error; // Or create a custom response
} else if (error instanceof Error) {
// Extract a user-friendly message if possible, or log details
throw new Error(`Rice creation failed: ${error.message}`); // More informative error message
} else {
// Catch unexpected errors
throw new Error('Internal Server Error'); // Only for truly unexpected issues
}
}
}
async findOne(slug: string) {
@ -90,7 +111,7 @@ export class RicesService {
// Remove unescaped double quotes at the beginning and end, if present
const content = contentPrev.replace(/^"|"$/g, '');
return { slug, content };
return content;
}
async update(slug: string, token: string, updateRiceDto: UpdateRiceDto) {
@ -111,6 +132,11 @@ export class RicesService {
await this.supabaseService.getRiceBySlug(slug);
if (!rice) throw new NotFoundException('Rice not found');
if (rice.token !== token) throw new UnauthorizedException('Invalid token');
if (!updateRiceDto.content) {
throw new BadRequestException(
'Missing required fields: content is mandatory.',
);
}
const updatedMetadata = {
...rice,
@ -119,14 +145,6 @@ export class RicesService {
await this.supabaseService.updateRice(slug, updatedMetadata);
const metadataContent = JSON.stringify(updatedMetadata, null, 2);
const riceJsonPath = `rices/${slug}/rice.json`;
await this.gitHubService.createOrUpdateFile(
riceJsonPath,
metadataContent,
`Update rice ${slug}`,
);
if (updateRiceDto.content) {
const encodedContent = Buffer.from(
JSON.stringify(updateRiceDto.content),
@ -150,8 +168,25 @@ export class RicesService {
await this.supabaseService.deleteRice(slug);
const riceJsonPath = `rices/${slug}/rice.json`;
await this.gitHubService.deleteFile(riceJsonPath, `Remove rice ${slug}`);
const folderPath = `rices/${slug}`;
// List all files in the folder
const files = await this.gitHubService.listFilesInDirectory(folderPath);
// Delete all files within the folder
for (const file of files) {
const filePath = `${folderPath}/${file}`;
await this.gitHubService.deleteFile(
filePath,
`Remove file ${file} in rice ${slug}`,
);
}
// Finally, remove the folder itself
await this.gitHubService.deleteFolder(
folderPath,
`Remove folder ${folderPath}`,
);
}
/**
@ -169,27 +204,30 @@ export class RicesService {
// 2. Delete metadata from Supabase
await this.supabaseService.deleteRice(slug);
// 3. Delete rice.json from GitHub
const riceJsonPath = `rices/${slug}/rice.json`;
// 3. Delete data.zenrice from GitHub
const riceJsonPath = `rices/${slug}/data.zenrice`;
await this.gitHubService.deleteFile(
riceJsonPath,
`[MODERATION] Remove rice ${slug}`,
);
// 4. List and delete uploaded files from GitHub (if any)
const uploadedFilesPath = `rices/${slug}`;
const files =
await this.gitHubService.listFilesInDirectory(uploadedFilesPath);
const filesPath = `rices/${slug}`;
const files = await this.gitHubService.listFilesInDirectory(filesPath);
for (const file of files) {
if (file !== 'rice.json') {
const filePath = `rices/${slug}/${file}`;
await this.gitHubService.deleteFile(
filePath,
`[MODERATION] Remove file ${file} from rice ${slug}`,
);
}
}
// 4. Finally, remove the folder itself
await this.gitHubService.deleteFolder(
filesPath,
`[MODERATION] Remove folder ${filesPath}`,
);
} catch (error) {
console.error('Error removing rice by moderation:', error);
if (error instanceof NotFoundException) {

View file

@ -55,7 +55,7 @@ export class SupabaseService {
`Failed to fetch rice with slug ${slug}: ${error.message}`,
error.details,
);
throw new Error(`Failed to fetch rice: ${error.message}`);
return null;
}
return data;
}

View file

@ -11,7 +11,9 @@ POST {{baseUrl}}/rices
Content-Type: application/json
{
"name": "cool-zenrice-aurora",
"name": "cool-zenrice-aurora2",
"version": "1.0.0",
"os": "EndeavourOS x86_64",
"content": "{'key':'value','description':'Example content'}"
}

View file

@ -1,4 +1,5 @@
@baseUrl = http://localhost:3000
@previous_slug = cool-zenrice-aurora-265e3881-f9de-4bbf-bb22-b9c37dbf04bc
@previous_slug = cool-zenrice-aurora-e99096ae-00da-4d54-9a47-53b20eb57647
GET {{baseUrl}}/rices/{{previous_slug}}

View file

@ -1,6 +1,6 @@
@baseUrl = http://localhost:3000
@previous_slug = cool-zenrice-aurora2-1d1e74b3-8d6d-40ea-bd4f-a6f4ad893f88
@previous_token = be4545f4-d92b-416c-8b3b-50cc9a49dee9
@previous_slug = cool-zenrice-aurora-ef732cbc-fdde-4f76-b4e3-cff0ec8b6f39
@previous_token = b406f962-5c51-43a9-8382-40e0983a46e7
# {
# "key": "value",

View file

@ -1,6 +1,6 @@
@baseUrl = http://localhost:3000
@previous_slug = cool-zenrice-aurora-e8abd40d-8657-4ddd-99c5-912cced67497
@previous_token = 2ce5c580-46e5-4a28-adff-30ce5662f29d
@previous_slug = cool-zenrice-aurora2-b970a742-789c-4349-8a4d-da63c8bbe77d
@previous_token = 03fbfdb4-d3a5-4d64-8740-feac7d32e7a8
DELETE {{baseUrl}}/rices/{{previous_slug}}
x-rices-token: {{previous_token}}