feat: public search and mii data API routes
- also with an API reference that is not done
This commit is contained in:
parent
20ac1ea280
commit
196f9d4640
7 changed files with 302 additions and 34 deletions
125
API.md
Normal file
125
API.md
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
# TomodachiShare API Reference
|
||||||
|
|
||||||
|
Welcome to the TomodachiShare API Reference!
|
||||||
|
Some routes may require authentication (see [Protected](#protected-endpoints) section - _TODO_).
|
||||||
|
|
||||||
|
Schema properties marked with a **\*** are required.
|
||||||
|
|
||||||
|
## Public Endpoints
|
||||||
|
|
||||||
|
### **Search Miis**
|
||||||
|
|
||||||
|
`GET /api/search?q={query}`
|
||||||
|
|
||||||
|
Searches Miis by name, tags, and description.
|
||||||
|
|
||||||
|
#### **Query Parameters**
|
||||||
|
|
||||||
|
| Name | Type | Required | Description |
|
||||||
|
| ------ | ------ | -------- | ----------------------------------------------------------------- |
|
||||||
|
| **q** | string | **Yes** | The text to search for. Matches names, tags, and descriptions. |
|
||||||
|
| sort | string | No | Sorting mode: `likes`, `newest`, `oldest`, or `random`. |
|
||||||
|
| tags | string | No | Comma-separated list of tags. Example: `anime,frieren`. |
|
||||||
|
| gender | string | No | Gender filter: `MALE` or `FEMALE`. |
|
||||||
|
| limit | number | No | Number of results per page (1-100). |
|
||||||
|
| page | number | No | Page number. Defaults to `1`. |
|
||||||
|
| seed | number | No | Seed used for `random` sorting to ensure unique results per page. |
|
||||||
|
|
||||||
|
#### **Examples**
|
||||||
|
|
||||||
|
```
|
||||||
|
https://tomodachishare.com/api/search?q={query}
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
https://tomodachishare.com/api/search?q=frieren&sort=random&tags=anime,frieren&gender=MALE&limit=20&page=1&seed=1204
|
||||||
|
```
|
||||||
|
|
||||||
|
#### **Response**
|
||||||
|
|
||||||
|
Returns an array of Mii IDs:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[1, 204, 295, 1024]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### **Get Mii Image / QR Code / Metadata Image**
|
||||||
|
|
||||||
|
`GET /mii/{id}/image?type={type}`
|
||||||
|
|
||||||
|
Retrieves the Mii image, QR code, or metadata graphic.
|
||||||
|
|
||||||
|
#### **Path & Query Parameters**
|
||||||
|
|
||||||
|
| Name | Type | Required | Description |
|
||||||
|
| -------- | ------ | -------- | ------------------------------------- |
|
||||||
|
| **id** | number | **Yes** | The Mii’s ID. |
|
||||||
|
| **type** | string | **Yes** | One of: `mii`, `qr-code`, `metadata`. |
|
||||||
|
|
||||||
|
#### **Examples**
|
||||||
|
|
||||||
|
```
|
||||||
|
https://tomodachishare.com/mii/1/image?type=mii
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
https://tomodachishare.com/mii/2/image?type=qr-code
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
https://tomodachishare.com/mii/3/image?type=metadata
|
||||||
|
```
|
||||||
|
|
||||||
|
#### **Response**
|
||||||
|
|
||||||
|
Returns the image file.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### **Get Mii Data**
|
||||||
|
|
||||||
|
`GET /mii/{id}/data`
|
||||||
|
|
||||||
|
Fetches metadata for a specific Mii.
|
||||||
|
|
||||||
|
#### **Path Parameters**
|
||||||
|
|
||||||
|
| Name | Type | Required | Description |
|
||||||
|
| ------ | ------ | -------- | ------------- |
|
||||||
|
| **id** | number | **Yes** | The Mii’s ID. |
|
||||||
|
|
||||||
|
#### **Example**
|
||||||
|
|
||||||
|
```
|
||||||
|
https://tomodachishare.com/mii/1/data
|
||||||
|
```
|
||||||
|
|
||||||
|
#### **Response**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "Frieren",
|
||||||
|
"imageCount": 3,
|
||||||
|
"tags": ["anime", "frieren"],
|
||||||
|
"description": "Frieren from 'Frieren: Beyond Journey's End'\r\nThe first Mii on the site!",
|
||||||
|
"firstName": "Frieren",
|
||||||
|
"lastName": "the Slayer",
|
||||||
|
"gender": "FEMALE",
|
||||||
|
"islandName": "Wuhu",
|
||||||
|
"allowedCopying": false,
|
||||||
|
"createdAt": "2025-05-04T12:29:41Z",
|
||||||
|
"user": {
|
||||||
|
"id": 1,
|
||||||
|
"username": "trafficlunar",
|
||||||
|
"name": "trafficlunar"
|
||||||
|
},
|
||||||
|
"likes": 29
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Protected Endpoints
|
||||||
|
|
||||||
|
_TODO_
|
||||||
|
|
@ -25,7 +25,9 @@
|
||||||
- 🌎 Browse and add Miis from other players
|
- 🌎 Browse and add Miis from other players
|
||||||
- 🏝️ Build your perfect island by finding the perfect residents
|
- 🏝️ Build your perfect island by finding the perfect residents
|
||||||
|
|
||||||
### <a href="/DEVELOPMENT.MD">Development Instructions</a>
|
### <a href="/DEVELOPMENT.md">Development Instructions</a>
|
||||||
|
|
||||||
|
### <a href="/API.md">API Reference</a>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
92
src/app/api/search/route.ts
Normal file
92
src/app/api/search/route.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
import crypto from "crypto";
|
||||||
|
import seedrandom from "seedrandom";
|
||||||
|
|
||||||
|
import { searchSchema } from "@/lib/schemas";
|
||||||
|
import { RateLimit } from "@/lib/rate-limit";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { Prisma } from "@prisma/client";
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const rateLimit = new RateLimit(request, 24, "/api/search");
|
||||||
|
const check = await rateLimit.handle();
|
||||||
|
if (check) return check;
|
||||||
|
|
||||||
|
const parsed = searchSchema.safeParse(Object.fromEntries(request.nextUrl.searchParams));
|
||||||
|
if (!parsed.success) return rateLimit.sendResponse({ error: parsed.error.issues[0].message }, 400);
|
||||||
|
|
||||||
|
const { q: query, sort, tags, gender, page = 1, limit = 24, seed } = parsed.data;
|
||||||
|
|
||||||
|
const where: Prisma.MiiWhereInput = {
|
||||||
|
// Searching
|
||||||
|
...(query && {
|
||||||
|
OR: [{ name: { contains: query, mode: "insensitive" } }, { tags: { has: query } }, { description: { contains: query, mode: "insensitive" } }],
|
||||||
|
}),
|
||||||
|
// Tag filtering
|
||||||
|
...(tags && tags.length > 0 && { tags: { hasEvery: tags } }),
|
||||||
|
// Gender
|
||||||
|
...(gender && { gender: { equals: gender } }),
|
||||||
|
};
|
||||||
|
|
||||||
|
const select: Prisma.MiiSelect = {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
imageCount: true,
|
||||||
|
tags: true,
|
||||||
|
createdAt: true,
|
||||||
|
gender: true,
|
||||||
|
// Like count
|
||||||
|
_count: {
|
||||||
|
select: { likedBy: true },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
if (sort === "random") {
|
||||||
|
// Use seed for consistent random results
|
||||||
|
const randomSeed = seed || crypto.randomInt(0, 1_000_000_000);
|
||||||
|
|
||||||
|
// Get all IDs that match the where conditions
|
||||||
|
const matchingIds = await prisma.mii.findMany({
|
||||||
|
where,
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (matchingIds.length === 0) return;
|
||||||
|
|
||||||
|
const rng = seedrandom(randomSeed.toString());
|
||||||
|
|
||||||
|
// Randomize all IDs using the Durstenfeld algorithm
|
||||||
|
for (let i = matchingIds.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(rng() * (i + 1));
|
||||||
|
[matchingIds[i], matchingIds[j]] = [matchingIds[j], matchingIds[i]];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to number[] array and return paginated results
|
||||||
|
return rateLimit.sendResponse(matchingIds.slice(skip, skip + limit).map((i) => i.id));
|
||||||
|
} else {
|
||||||
|
// Sorting by likes, newest, or oldest
|
||||||
|
let orderBy: Prisma.MiiOrderByWithRelationInput[];
|
||||||
|
|
||||||
|
if (sort === "likes") {
|
||||||
|
orderBy = [{ likedBy: { _count: "desc" } }, { name: "asc" }];
|
||||||
|
} else if (sort === "oldest") {
|
||||||
|
orderBy = [{ createdAt: "asc" }, { name: "asc" }];
|
||||||
|
} else {
|
||||||
|
// default to newest
|
||||||
|
orderBy = [{ createdAt: "desc" }, { name: "asc" }];
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = await prisma.mii.findMany({
|
||||||
|
where,
|
||||||
|
orderBy,
|
||||||
|
select: { id: true },
|
||||||
|
skip,
|
||||||
|
take: limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
return rateLimit.sendResponse(list.map((mii) => mii.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
49
src/app/mii/[id]/data/route.ts
Normal file
49
src/app/mii/[id]/data/route.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
import { idSchema } from "@/lib/schemas";
|
||||||
|
import { RateLimit } from "@/lib/rate-limit";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const rateLimit = new RateLimit(request, 200, "/mii/data");
|
||||||
|
const check = await rateLimit.handle();
|
||||||
|
if (check) return check;
|
||||||
|
|
||||||
|
const { id: slugId } = await params;
|
||||||
|
const parsed = idSchema.safeParse(slugId);
|
||||||
|
if (!parsed.success) return rateLimit.sendResponse({ error: parsed.error.issues[0].message }, 400);
|
||||||
|
const miiId = parsed.data;
|
||||||
|
|
||||||
|
const data = await prisma.mii.findUnique({
|
||||||
|
where: { id: miiId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
likedBy: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
imageCount: true,
|
||||||
|
tags: true,
|
||||||
|
description: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
gender: true,
|
||||||
|
islandName: true,
|
||||||
|
allowedCopying: true,
|
||||||
|
createdAt: true,
|
||||||
|
user: { select: { id: true, username: true, name: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return rateLimit.sendResponse({ error: "Mii not found" }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { _count, ...rest } = data;
|
||||||
|
return rateLimit.sendResponse({
|
||||||
|
...rest,
|
||||||
|
likes: _count.likedBy,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -1,13 +1,12 @@
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
import { MiiGender, Prisma } from "@prisma/client";
|
import { Prisma } from "@prisma/client";
|
||||||
import { Icon } from "@iconify/react";
|
import { Icon } from "@iconify/react";
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
import seedrandom from "seedrandom";
|
import seedrandom from "seedrandom";
|
||||||
|
|
||||||
import { querySchema } from "@/lib/schemas";
|
import { searchSchema } from "@/lib/schemas";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
|
@ -25,36 +24,6 @@ interface Props {
|
||||||
inLikesPage?: boolean; // Self-explanatory
|
inLikesPage?: boolean; // Self-explanatory
|
||||||
}
|
}
|
||||||
|
|
||||||
const searchSchema = z.object({
|
|
||||||
q: querySchema.optional(),
|
|
||||||
sort: z.enum(["likes", "newest", "oldest", "random"], { error: "Sort must be either 'likes', 'newest', 'oldest', or 'random'" }).default("newest"),
|
|
||||||
tags: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.transform((value) =>
|
|
||||||
value
|
|
||||||
?.split(",")
|
|
||||||
.map((tag) => tag.trim())
|
|
||||||
.filter((tag) => tag.length > 0)
|
|
||||||
),
|
|
||||||
gender: z.enum(MiiGender, { error: "Gender must be either 'MALE', or 'FEMALE'" }).optional(),
|
|
||||||
// todo: incorporate tagsSchema
|
|
||||||
// Pages
|
|
||||||
limit: z.coerce
|
|
||||||
.number({ error: "Limit must be a number" })
|
|
||||||
.int({ error: "Limit must be an integer" })
|
|
||||||
.min(1, { error: "Limit must be at least 1" })
|
|
||||||
.max(100, { error: "Limit cannot be more than 100" })
|
|
||||||
.optional(),
|
|
||||||
page: z.coerce
|
|
||||||
.number({ error: "Page must be a number" })
|
|
||||||
.int({ error: "Page must be an integer" })
|
|
||||||
.min(1, { error: "Page must be at least 1" })
|
|
||||||
.optional(),
|
|
||||||
// Random sort
|
|
||||||
seed: z.coerce.number({ error: "Seed must be a number" }).int({ error: "Seed must be an integer" }).optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export default async function MiiList({ searchParams, userId, inLikesPage }: Props) {
|
export default async function MiiList({ searchParams, userId, inLikesPage }: Props) {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { MiiGender } from "@prisma/client";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
// profanity censoring bypasses the regex in some of these but I think it's funny
|
// profanity censoring bypasses the regex in some of these but I think it's funny
|
||||||
|
|
@ -39,6 +40,36 @@ export const idSchema = z.coerce
|
||||||
.int({ error: "ID must be an integer" })
|
.int({ error: "ID must be an integer" })
|
||||||
.positive({ error: "ID must be valid" });
|
.positive({ error: "ID must be valid" });
|
||||||
|
|
||||||
|
export const searchSchema = z.object({
|
||||||
|
q: querySchema.optional(),
|
||||||
|
sort: z.enum(["likes", "newest", "oldest", "random"], { error: "Sort must be either 'likes', 'newest', 'oldest', or 'random'" }).default("newest"),
|
||||||
|
tags: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.transform((value) =>
|
||||||
|
value
|
||||||
|
?.split(",")
|
||||||
|
.map((tag) => tag.trim())
|
||||||
|
.filter((tag) => tag.length > 0)
|
||||||
|
),
|
||||||
|
gender: z.enum(MiiGender, { error: "Gender must be either 'MALE', or 'FEMALE'" }).optional(),
|
||||||
|
// todo: incorporate tagsSchema
|
||||||
|
// Pages
|
||||||
|
limit: z.coerce
|
||||||
|
.number({ error: "Limit must be a number" })
|
||||||
|
.int({ error: "Limit must be an integer" })
|
||||||
|
.min(1, { error: "Limit must be at least 1" })
|
||||||
|
.max(100, { error: "Limit cannot be more than 100" })
|
||||||
|
.optional(),
|
||||||
|
page: z.coerce
|
||||||
|
.number({ error: "Page must be a number" })
|
||||||
|
.int({ error: "Page must be an integer" })
|
||||||
|
.min(1, { error: "Page must be at least 1" })
|
||||||
|
.optional(),
|
||||||
|
// Random sort
|
||||||
|
seed: z.coerce.number({ error: "Seed must be a number" }).int({ error: "Seed must be an integer" }).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
// Account Info
|
// Account Info
|
||||||
export const usernameSchema = z
|
export const usernameSchema = z
|
||||||
.string()
|
.string()
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue