diff --git a/packages/randomuser/README.md b/packages/randomuser/README.md new file mode 100644 index 0000000..1e1e8fe --- /dev/null +++ b/packages/randomuser/README.md @@ -0,0 +1,43 @@ +# 🧘 @untypeable/randomuser + +Untypeable router type definitions & validators for the random user generator API + +## 🚀 Install + +Install it locally in your project + +```bash +# npm +npm install @untypeable/randomuser + +# yarn +yarn add @untypeable/randomuser + +# pnpm +pnpm install @untypeable/randomuser +``` + +## 🦄 Usage + +Create a new client instance with the `LilRouter` & your desired fetch handler + +```typescript +import { createTypeLevelClient } from "untypeable"; + +import type { RandomUserRouter } from "@untypeable/randomuser"; + +const client = createTypeLevelClient(async (path) => { + const url = new URL(path, "https://randomuser.me/"); + Object.entries(input).forEach(([key, value]) => + url.searchParams.append(key, value as string) + ); + + const response = await fetch(url.href); + + return await response.json(); +}); + +const randomUser = await client("/api"); + +const femaleUser = await client("/api", { gender: "female" }); +``` diff --git a/packages/randomuser/package.json b/packages/randomuser/package.json new file mode 100644 index 0000000..52c720a --- /dev/null +++ b/packages/randomuser/package.json @@ -0,0 +1,76 @@ +{ + "name": "@untypeable/randomuser", + "version": "1.0.2", + "description": "Untypeable router type definitions & validators for the random user generator API", + "publishConfig": { + "access": "public" + }, + "repository": { + "directory": "packages/randomuser", + "type": "git", + "url": "https://github.com/nurodev/untypeable.git" + }, + "homepage": "https://lil.software/api", + "bugs": "https://github.com/nurodev/untypeable/issues", + "readme": "README.md", + "author": { + "name": "nurodev", + "email": "me@nuro.dev", + "url": "https://nuro.dev" + }, + "keywords": [ + "api", + "random", + "randomuser", + "typescript", + "untypeable", + "user" + ], + "license": "MIT", + "main": "./dist/index.js", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "exports": { + ".": "./dist/index", + "./zod": "./dist/zod", + "./package.json": "./package.json" + }, + "typesVersions": { + "*": { + "zod": [ + "./dist/zod.d.ts" + ] + } + }, + "files": [ + "dist/**/*", + "LICENSE", + "README.md" + ], + "scripts": { + "build": "tsup", + "test": "vitest run", + "test:watch": "vitest watch", + "test:ui": "vitest watch --ui" + }, + "dependencies": { + "untypeable": "^0.2.1" + }, + "devDependencies": { + "@types/node": "^18.15.3", + "@vitest/ui": "^0.29.7", + "dotenv": "^16.0.3", + "typescript": "^4.9.5", + "undici": "^5.21.0", + "vitest": "^0.29.7", + "zod": "^3.21.4" + }, + "peerDependencies": { + "zod": "^3.21.4" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } +} diff --git a/packages/randomuser/src/api/api.types.ts b/packages/randomuser/src/api/api.types.ts new file mode 100644 index 0000000..1666352 --- /dev/null +++ b/packages/randomuser/src/api/api.types.ts @@ -0,0 +1,22 @@ +import type { z } from "zod"; + +import type { + ApiSchema, + ApiInputSchema, + FieldSchema, + GenderSchema, + NationalitySchema, + UserSchema, +} from "./api.validators"; + +export type Api = z.infer; + +export type ApiInput = z.infer; + +export type Field = z.infer; + +export type Gender = z.infer; + +export type Nationality = z.infer; + +export type User = z.infer; diff --git a/packages/randomuser/src/api/api.validators.ts b/packages/randomuser/src/api/api.validators.ts new file mode 100644 index 0000000..1c1b031 --- /dev/null +++ b/packages/randomuser/src/api/api.validators.ts @@ -0,0 +1,125 @@ +import { z } from "zod"; + +export const GenderSchema = z.enum(["male", "female"]); + +export const FieldSchema = z.enum([ + "cell", + "dob", + "email", + "gender", + "id", + "location", + "login", + "name", + "nat", + "phone", + "picture", + "registered", +]); + +export const NationalitySchema = z.enum([ + "AU", + "BR", + "CA", + "CH", + "DE", + "DK", + "ES", + "FI", + "FR", + "GB", + "IE", + "IN", + "IR", + "MX", + "NL", + "NO", + "NZ", + "RS", + "TR", + "UA", + "US", +]); + +export const UserSchema = z.object({ + cell: z.string(), + dob: z.object({ + age: z.number(), + date: z.string().datetime(), + }), + email: z.string().email(), + gender: GenderSchema, + id: z.object({ + name: z.string(), + value: z.string().nullable(), + }), + location: z.object({ + city: z.string(), + coordinates: z.object({ + latitude: z.string().transform(Number), + longitude: z.string().transform(Number), + }), + country: z.string(), + postcode: z.string().or(z.number()), + state: z.string(), + street: z.object({ + name: z.string(), + number: z.number(), + }), + timezone: z.object({ + description: z.string(), + offset: z.string(), + }), + }), + login: z.object({ + md5: z.string(), + password: z.string(), + salt: z.string(), + sha1: z.string(), + sha256: z.string(), + username: z.string(), + uuid: z.string().uuid(), + }), + name: z.object({ + first: z.string(), + last: z.string(), + title: z.string(), + }), + nat: NationalitySchema, + phone: z.string(), + picture: z.object({ + large: z.string().url(), + medium: z.string().url(), + thumbnail: z.string().url(), + }), + registered: z.object({ + age: z.number(), + date: z.string().datetime(), + }), +}); + +export const ApiSchema = z.object({ + info: z.object({ + page: z.number(), + results: z.number(), + seed: z.string(), + version: z.string(), + }), + results: z.array(UserSchema), +}); + +export const ApiInputSchema = z + .object({ + exc: z.array(FieldSchema), + format: z.enum(["csv", "json", "prettyjson", "xml", "yaml"]), + gender: GenderSchema, + inc: z.array(FieldSchema), + nat: NationalitySchema, + noinfo: z.boolean(), + page: z.number(), + password: z.array(z.string()), + results: z.number(), + seed: z.string(), + version: z.enum(["1.0", "1.1", "1.2", "1.3", "1.4"]), + }) + .partial(); diff --git a/packages/randomuser/src/index.ts b/packages/randomuser/src/index.ts new file mode 100644 index 0000000..8bfcb0d --- /dev/null +++ b/packages/randomuser/src/index.ts @@ -0,0 +1,13 @@ +import { initUntypeable } from "untypeable"; + +import type { Api, ApiInput } from "./api/api.types"; + +const u = initUntypeable(); + +const router = u.router({ + "/api": u.input().output(), +}); + +export type RandomUserRouter = typeof router; + +export * from "./api/api.types"; diff --git a/packages/randomuser/src/zod.ts b/packages/randomuser/src/zod.ts new file mode 100644 index 0000000..957e5f1 --- /dev/null +++ b/packages/randomuser/src/zod.ts @@ -0,0 +1 @@ +export * from "./api/api.validators"; diff --git a/packages/randomuser/tests/_client.ts b/packages/randomuser/tests/_client.ts new file mode 100644 index 0000000..e305868 --- /dev/null +++ b/packages/randomuser/tests/_client.ts @@ -0,0 +1,28 @@ +import { beforeAll } from "vitest"; +import { createTypeLevelClient } from "untypeable"; +import { fetch } from "undici"; + +import type { RandomUserRouter } from "../src"; + +export function useTestClient() { + const client = createTypeLevelClient( + async (path, input = {}) => { + const url = new URL(path, "https://randomuser.me/"); + Object.entries(input).forEach(([key, value]) => + url.searchParams.append(key, value as string) + ); + + const response = await fetch(url.href); + if (!response.ok) + throw new Error(`HTTP ${response.status} ${response.statusText}`); + + return await response.json(); + } + ); + + beforeAll(() => { + if (!client) throw "Failed to initialise untypeable client instance."; + }); + + return client; +} diff --git a/packages/randomuser/tests/api.test.ts b/packages/randomuser/tests/api.test.ts new file mode 100644 index 0000000..38ae8ec --- /dev/null +++ b/packages/randomuser/tests/api.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } from "vitest"; + +import { ApiSchema, UserSchema } from "../src/zod"; +import { useTestClient } from "./_client"; + +describe.concurrent("Random User - API", () => { + const client = useTestClient(); + + it("GET - /api", async () => { + const randomUser = await client("/api"); + + expect(randomUser).toBeDefined(); + expect(randomUser.results).toBeDefined(); + expect(randomUser.results.length).toBeGreaterThan(0); + expect(randomUser.results.at(0)).toBeDefined(); + + expect(ApiSchema.safeParse(randomUser).success).toBe(true); + expect(UserSchema.safeParse(randomUser.results.at(0)).success).toBe(true); + }); + + it("GET - /api?seed=foobar&gender=female", async () => { + const gender = "female"; + const seed = "foobar"; + + const randomUser = await client("/api", { + gender, + seed, + }); + + expect(randomUser).toBeDefined(); + expect(randomUser.results).toBeDefined(); + expect(randomUser.results.length).toBeGreaterThan(0); + expect(randomUser.results.at(0)).toBeDefined(); + expect(randomUser.results.at(0)?.gender).toBe(gender); + expect(randomUser.info.seed).toBe(seed); + + expect(ApiSchema.safeParse(randomUser).success).toBe(true); + expect(UserSchema.safeParse(randomUser.results.at(0)).success).toBe(true); + }); +}); diff --git a/packages/randomuser/tsconfig.json b/packages/randomuser/tsconfig.json new file mode 100644 index 0000000..bbcbfb3 --- /dev/null +++ b/packages/randomuser/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "esModuleInterop": true, + "lib": ["ESNext"], + "module": "ESNext", + "moduleResolution": "Node", + "noImplicitReturns": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "resolveJsonModule": true, + "sourceMap": true, + "strict": true, + "target": "ESNext" + }, + "include": ["./src", "./tests/*.test.ts"] +} diff --git a/packages/randomuser/tsup.config.ts b/packages/randomuser/tsup.config.ts new file mode 100644 index 0000000..1e8abad --- /dev/null +++ b/packages/randomuser/tsup.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "tsup"; + +export default defineConfig(({ watch = false }) => ({ + clean: true, + dts: { + resolve: true, + }, + entry: { + index: "src/index.ts", + zod: "src/zod.ts", + }, + format: ["cjs", "esm"], + minify: true, + watch, +}));