diff --git a/examples/http.js b/examples/http.js new file mode 100644 index 0000000..d064e96 --- /dev/null +++ b/examples/http.js @@ -0,0 +1,12 @@ +// Copyright (c) Tribufu. All Rights Reserved. +// SPDX-License-Identifier: MIT + +import { HttpClient } from '../build/index.mjs'; + +async function main() { + const http = new HttpClient(); + const reponse = await http.get('https://www.tribufu.com'); + console.log(reponse); +} + +main(); diff --git a/package.json b/package.json index 6f4ab77..2858e9a 100644 --- a/package.json +++ b/package.json @@ -16,14 +16,25 @@ "build": "npm run clean && tsc && node scripts/esbuild.js" }, "dependencies": { - "@tribufu/mintaka": "0.1.8" + "axios": "^1.6.3", + "camelcase-keys": "^9.1.2", + "fp-ts": "^2.16.1", + "json-bigint": "^1.0.0", + "jsonwebtoken": "^9.0.2", + "snakecase-keys": "^5.5.0", + "toml": "^3.0.0", + "uuid": "^9.0.1", + "uuidv7": "^0.6.3" }, "devDependencies": { + "@types/json-bigint": "^1.0.4", + "@types/jsonwebtoken": "^9.0.5", "@types/node": "^20.10.6", + "@types/uuid": "^9.0.7", "cross-env": "^7.0.3", "dotenv": "^16.3.1", - "esbuild": "^0.19.10", "esbuild-node-externals": "^1.12.0", + "esbuild": "^0.19.10", "rimraf": "^5.0.5", "typescript": "^5.3.3" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 953fa75..b8ceff9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,14 +5,47 @@ settings: excludeLinksFromLockfile: false dependencies: - '@tribufu/mintaka': - specifier: 0.1.8 - version: 0.1.8 + axios: + specifier: ^1.6.3 + version: 1.6.4 + camelcase-keys: + specifier: ^9.1.2 + version: 9.1.2 + fp-ts: + specifier: ^2.16.1 + version: 2.16.2 + json-bigint: + specifier: ^1.0.0 + version: 1.0.0 + jsonwebtoken: + specifier: ^9.0.2 + version: 9.0.2 + snakecase-keys: + specifier: ^5.5.0 + version: 5.5.0 + toml: + specifier: ^3.0.0 + version: 3.0.0 + uuid: + specifier: ^9.0.1 + version: 9.0.1 + uuidv7: + specifier: ^0.6.3 + version: 0.6.3 devDependencies: + '@types/json-bigint': + specifier: ^1.0.4 + version: 1.0.4 + '@types/jsonwebtoken': + specifier: ^9.0.5 + version: 9.0.6 '@types/node': specifier: ^20.10.6 version: 20.10.6 + '@types/uuid': + specifier: ^9.0.7 + version: 9.0.8 cross-env: specifier: ^7.0.3 version: 7.0.3 @@ -260,21 +293,15 @@ packages: dev: true optional: true - /@tribufu/mintaka@0.1.8: - resolution: {integrity: sha512-qUvReWlz8irSIbKCTfjFnUnUq7MIgLjnTBPeWv2ayyiwGkf8L3q7qi1Zxuqt3OduOugSOtxYQwXOaClldIMUTQ==} + /@types/json-bigint@1.0.4: + resolution: {integrity: sha512-ydHooXLbOmxBbubnA7Eh+RpBzuaIiQjh8WGJYQB50JFGFrdxW7JzVlyEV7fAXw0T2sqJ1ysTneJbiyNLqZRAag==} + dev: true + + /@types/jsonwebtoken@9.0.6: + resolution: {integrity: sha512-/5hndP5dCjloafCXns6SZyESp3Ldq7YjH3zwzwczYnjxIT0Fqzk5ROSYVGfFyczIue7IUEj8hkvLbPoLQ18vQw==} dependencies: - axios: 1.6.4 - camelcase-keys: 9.1.2 - fp-ts: 2.16.2 - json-bigint: 1.0.0 - jsonwebtoken: 9.0.2 - snakecase-keys: 5.5.0 - toml: 3.0.0 - uuid: 9.0.1 - uuidv7: 0.6.3 - transitivePeerDependencies: - - debug - dev: false + '@types/node': 20.10.6 + dev: true /@types/node@20.10.6: resolution: {integrity: sha512-Vac8H+NlRNNlAmDfGUP7b5h/KA+AtWIzuXy0E6OyP8f1tCLYAtPvKRRDJjAPqhpCb0t6U2j7/xqAuLEebW2kiw==} @@ -282,6 +309,10 @@ packages: undici-types: 5.26.5 dev: true + /@types/uuid@9.0.8: + resolution: {integrity: sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==} + dev: true + /ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} diff --git a/src/api.ts b/src/api.ts index 0e94a38..c15b54f 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,9 +1,11 @@ // Copyright (c) Tribufu. All Rights Reserved. // SPDX-License-Identifier: MIT -import { HttpHeaders, HttpClient } from "@tribufu/mintaka"; +import { HttpClient } from "./http/client"; +import { HttpHeaders } from "./http/headers"; import { JavaScriptRuntime } from "./node"; -import { JsonCasing, JwtDecoder } from "@tribufu/mintaka"; +import { JwtDecoder } from "./jwt"; +import { JsonCasing } from "./json"; import { TokenPayload } from "./token"; import { TRIBUFU_API_URL, TRIBUFU_VERSION } from "."; import { TribufuApiOptions } from "./options"; diff --git a/src/client.ts b/src/client.ts index 1d576b6..28fb20b 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,8 +1,9 @@ // Copyright (c) Tribufu. All Rights Reserved. // SPDX-License-Identifier: MIT -import { HttpCookieMap, HttpHeaders } from "@tribufu/mintaka"; -import { OAuth2GrantType, OAuth2IntrospectionRequest, OAuth2IntrospectionResponse, OAuth2TokenRequest, OAuth2TokenResponse } from "@tribufu/mintaka"; +import { HttpHeaders } from "./http/headers"; +import { HttpCookieMap } from "./http/cookies"; +import { OAuth2GrantType, OAuth2IntrospectionRequest, OAuth2IntrospectionResponse, OAuth2TokenRequest, OAuth2TokenResponse } from "./oauth2"; import { TribufuApi } from "./api"; /** diff --git a/src/collections/string_map.ts b/src/collections/string_map.ts new file mode 100644 index 0000000..4f5a319 --- /dev/null +++ b/src/collections/string_map.ts @@ -0,0 +1,42 @@ +// Copyright (c) Tribufu. All Rights Reserved. +// SPDX-License-Identifier: MIT + +/** + * Map Inner + * + * Helper type to represent raw map. + */ +export type RawStringMap = { + [key: string]: T +}; + +/** + * Generic Map + * + * Helper class to manage generic maps. + */ +export class StringMap extends Map { + constructor(inner?: RawStringMap | null) { + super(); + + if (inner) { + Object.entries(inner).forEach(([key, value]) => { + this.set(key, value); + }); + } + } + + /** + * Get all values as raw map. + * @returns {RawStringMap} + */ + public getRaw(): RawStringMap { + const result: RawStringMap = {}; + + this.forEach((value, key) => { + result[key] = value; + }); + + return result; + } +} diff --git a/src/http/client.ts b/src/http/client.ts new file mode 100644 index 0000000..f292401 --- /dev/null +++ b/src/http/client.ts @@ -0,0 +1,199 @@ +// Copyright (c) Tribufu. All Rights Reserved. +// SPDX-License-Identifier: MIT + +import axios, { AxiosInstance } from "axios"; +import { JsonCasing, JsonSerializer } from "../json"; +import { HttpHeaders } from "./headers"; + +export interface ErrorResponse { + error: string; +}; + +export interface MessageResponse { + message: string; +}; + +export interface HttpClientOptions { + baseUrl?: string | null; + headers?: HttpHeaders; + logEnabled?: boolean; + logTarget?: string; + jsonRequestCasing?: JsonCasing | null; + jsonResponseCasing?: JsonCasing | null; +}; + +/** + * Http Client + * + * Helper class to make HTTP requests. + */ +export class HttpClient { + private readonly inner: AxiosInstance; + protected readonly options: HttpClientOptions; + + constructor(options?: HttpClientOptions | null) { + const defaultOptions = HttpClient.defaultOptions(); + + this.options = { + baseUrl: options?.baseUrl ?? defaultOptions.baseUrl, + headers: options?.headers ?? defaultOptions.headers, + logEnabled: options?.logEnabled ?? defaultOptions.logEnabled, + logTarget: options?.logTarget ?? defaultOptions.logTarget, + jsonRequestCasing: options?.jsonRequestCasing ?? defaultOptions.jsonRequestCasing, + jsonResponseCasing: options?.jsonResponseCasing ?? defaultOptions.jsonResponseCasing, + }; + + const inner = axios.create({ + baseURL: this.options?.baseUrl ?? undefined, + headers: this.options?.headers?.getRaw(), + }); + + inner.interceptors.request.use((req) => { + if (this.options.logEnabled ?? false) { + console.log(`(${this.options.logTarget}) ${req.method?.toUpperCase()} ${req.baseURL}${req.url}`); + } + + if (req.url && req.url.includes("oauth2") && !req.url.includes("userinfo")) { + return req; + } + + const contentType = req.headers["Content-Type"]; + if (req.data && (contentType === "application/json" || contentType === "application/x-www-form-urlencoded")) { + req.data = JsonSerializer.toCase(req.data, this.options.jsonRequestCasing); + } + + return req; + }); + + inner.interceptors.response.use((res) => { + if (res.config.url && res.config.url.includes("oauth2") && !res.config.url.includes("userinfo")) { + return res; + } + + if (res.data) { + res.data = JsonSerializer.toCase(res.data, this.options.jsonResponseCasing); + } + + return res; + }); + + this.inner = inner; + } + + private static defaultOptions(): HttpClientOptions { + return { + baseUrl: null, + headers: new HttpHeaders(), + logEnabled: false, + logTarget: "HttpClient", + jsonRequestCasing: null, + jsonResponseCasing: null, + }; + }; + + /** + * Get a resource from the http server. + * @returns {T | null} + */ + public async get(path: string, headers?: HttpHeaders | null): Promise { + try { + const requestHeaders = headers ?? this.options.headers; + const response = await this.inner.get(path, { headers: requestHeaders?.getRaw() }); + + if (response.status !== 200) { + return null; + } + + return response.data as T; + } catch (error) { + return null; + } + } + + /** + * Create a resource on the http server. + * @param path + * @param body + * @param headers + * @returns {T | null} + */ + public async post(path: string, body: S, headers?: HttpHeaders | null): Promise { + try { + const requestHeaders = headers ?? this.options.headers; + const response = await this.inner.post(path, body, { headers: requestHeaders?.getRaw() }); + + if (response.status !== 200) { + return null; + } + + return response.data as T; + } catch (error) { + return null; + } + } + + /** + * Update a resource on the http server. + * @param path + * @param body + * @param headers + * @returns {T | null} + */ + public async put(path: string, body: S, headers?: HttpHeaders | null): Promise { + try { + const requestHeaders = headers ?? this.options.headers; + const response = await this.inner.put(path, body, { headers: requestHeaders?.getRaw() }); + + if (response.status !== 200) { + return null; + } + + return response.data as T; + } catch (error) { + return null; + } + } + + /** + * Patch a resource on the http server. + * @param path + * @param body + * @param headers + * @returns {T | null} + */ + public async patch(path: string, body: S, headers?: HttpHeaders | null): Promise { + try { + const requestHeaders = headers ?? this.options.headers; + const response = await this.inner.patch(path, body, { headers: requestHeaders?.getRaw() }); + + if (response.status !== 200) { + return null; + } + + return response.data as T; + } catch (error) { + return null; + } + } + + /** + * Delete a resource from the http server. + * @param path + * @param headers + * @returns {T | null} + */ + public async delete(path: string, headers?: HttpHeaders | null): Promise { + try { + const requestHeaders = headers ?? this.options.headers; + const response = await this.inner.delete(path, { headers: requestHeaders?.getRaw() }); + + if (response.status !== 200) { + return null; + } + + return response.data as T; + } catch (error) { + return null; + } + } +} diff --git a/src/http/cookies.ts b/src/http/cookies.ts new file mode 100644 index 0000000..eb1ab34 --- /dev/null +++ b/src/http/cookies.ts @@ -0,0 +1,19 @@ +// Copyright (c) Tribufu. All Rights Reserved. +// SPDX-License-Identifier: MIT + +import { RawStringMap, StringMap } from "../collections/string_map"; + +/** + * Http Cookie Map + * + * Helper type to represent HTTP cookies. + */ +export type HttpCookieMap = RawStringMap; + +/** + * Http Cookies + * + * Helper class to manage HTTP cookies. + */ +export class HttpCookies extends StringMap { +} diff --git a/src/http/headers.ts b/src/http/headers.ts new file mode 100644 index 0000000..cb6e5cb --- /dev/null +++ b/src/http/headers.ts @@ -0,0 +1,28 @@ +// Copyright (c) Tribufu. All Rights Reserved. +// SPDX-License-Identifier: MIT + +import { RawStringMap, StringMap } from "../collections/string_map"; + +/** + * Http Header Map + * + * Helper type to represent HTTP headers. + */ +export type HttpHeaderMap = RawStringMap; + +/** + * Http Headers + * + * Helper class to manage HTTP headers. + */ +export class HttpHeaders extends StringMap { + public static parseAuthorizationHeader(value: string): { scheme: string, token: string } { + const parts = value.split(" "); + + if (parts.length !== 2) { + throw new Error("Invalid authorization header"); + } + + return { scheme: parts[0], token: parts[1] }; + } +} diff --git a/src/index.ts b/src/index.ts index 845cf6d..f136b49 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,3 +28,107 @@ export { TribufuClient, TribufuServer, }; + +import { + RawStringMap, + StringMap, +} from "./collections/string_map"; + +export { + RawStringMap, + StringMap, +} + +import { + HttpClient, + HttpClientOptions, +} from "./http/client"; + +export { + HttpClient, + HttpClientOptions, +} + +import { + HttpCookieMap, + HttpCookies, +} from "./http/cookies"; + +export { + HttpCookieMap, + HttpCookies, +} + +import { + HttpHeaderMap, + HttpHeaders, +} from "./http/headers"; + +export { + HttpHeaderMap, + HttpHeaders, +} + +import { + UuidGenerator, +} from "./uuid"; + +export { + UuidGenerator, +} + +import { + JwtDecoder, +} from "./jwt"; + +export { + JwtDecoder, +} + +import { + JsonCasing, + JsonSerializer, +} from "./json"; + +export { + JsonCasing, + JsonSerializer, +} + +import { + TomlSerializer, +} from "./toml"; + +export { + TomlSerializer, +} + +import { + OAuth2AuthorizeRequest, + OAuth2ClientType, + OAuth2CodeResponse, + OAuth2GrantType, + OAuth2IntrospectionRequest, + OAuth2IntrospectionResponse, + OAuth2ResponseType, + OAuth2RevokeRequest, + OAuth2TokenHintType, + OAuth2TokenRequest, + OAuth2TokenResponse, + OAuth2TokenType +} from "./oauth2"; + +export { + OAuth2AuthorizeRequest, + OAuth2ClientType, + OAuth2CodeResponse, + OAuth2GrantType, + OAuth2IntrospectionRequest, + OAuth2IntrospectionResponse, + OAuth2ResponseType, + OAuth2RevokeRequest, + OAuth2TokenHintType, + OAuth2TokenRequest, + OAuth2TokenResponse, + OAuth2TokenType +} diff --git a/src/json/index.ts b/src/json/index.ts new file mode 100644 index 0000000..d732f38 --- /dev/null +++ b/src/json/index.ts @@ -0,0 +1,92 @@ +// Copyright (c) Tribufu. All Rights Reserved. +// SPDX-License-Identifier: MIT + +import camelcaseKeys from "camelcase-keys"; +import snakecaseKeys from "snakecase-keys"; + +export enum JsonCasing { + CamelCase, + PascalCase, + SnakeCase, +}; + +export class JsonSerializer { + /** + * Format json to string. + * + * @param json + * @returns {string} + */ + public static toString(object: any): string { + return JSON.stringify(object, null, 0); + } + + /** + * Format json to pretty string. + * + * @param json + * @returns {string} + */ + public static toStringPretty(object: any): string { + return JSON.stringify(object, null, 4); + } + + /** + * Parse json string to object. + * @param json + * @returns {any} + */ + public static fromString(jsonString: string): any { + return JSON.parse(jsonString); + } + + /** + * Convert json object keys to camel case. + * + * @param json + * @returns {any} + */ + public static toCamelCase(json: any): any { + return camelcaseKeys(json, { deep: true }); + } + + /** + * Convert json object keys to pascal case. + * + * @param json + * @returns {any} + */ + public static toPascalCase(json: any): any { + return camelcaseKeys(json, { deep: true, pascalCase: true }); + } + + /** + * Convert json object keys to snake case. + * + * @param json + * @returns {any} + */ + public static toSnakeCase(json: any): any { + return snakecaseKeys(json, { deep: true }); + } + + /** + * Convert json object keys to specified case. + * + * @param json + * @param casing + * @returns {any} + */ + public static toCase(json: any, casing?: JsonCasing | null): any { + switch (casing) { + case JsonCasing.CamelCase: + return JsonSerializer.toCamelCase(json); + case JsonCasing.PascalCase: + return JsonSerializer.toPascalCase(json); + case JsonCasing.SnakeCase: + return JsonSerializer.toSnakeCase(json); + default: + return json; + } + } +} diff --git a/src/jwt/index.ts b/src/jwt/index.ts new file mode 100644 index 0000000..d56834e --- /dev/null +++ b/src/jwt/index.ts @@ -0,0 +1,36 @@ +// Copyright (c) Tribufu. All Rights Reserved. +// SPDX-License-Identifier: MIT + +import jwt from "jsonwebtoken"; + +export class JwtDecoder { + /** + * Decode JWT token. + * + * @param token + * @returns {any} + */ + public static decode(token: string): any { + return jwt.decode(token); + } + + /** + * Encode JWT token. + * + * @param token + * @returns {any} + */ + public static encode(payload: any, secret: string, options?: any): string { + return jwt.sign(payload, secret, options); + } + + /** + * Verify JWT token. + * + * @param token + * @returns {any} + */ + public static verify(token: string, secret: string): any { + return jwt.verify(token, secret); + } +} diff --git a/src/oauth2/index.ts b/src/oauth2/index.ts new file mode 100644 index 0000000..568b63d --- /dev/null +++ b/src/oauth2/index.ts @@ -0,0 +1,101 @@ +// Copyright (c) Tribufu. All Rights Reserved. +// SPDX-License-Identifier: MIT + +/** + * Helper type to represent OAuth2 client type. + */ +export type OAuth2ClientType = "web" | "native"; + +/** + * Helper type to represent OAuth2 grant type. + */ +export type OAuth2GrantType = "authorization_code" | "client_credentials" | "device_code" | "password" | "passkey" | "refresh_token"; + +/** + * Helper type to represent OAuth2 response type. + */ +export type OAuth2ResponseType = "code" | "token"; + +/** + * Helper type to represent OAuth2 token hint type. + */ +export type OAuth2TokenHintType = "refresh_token" | "access_token"; + +/** + * Helper type to represent OAuth2 token type. + */ +export type OAuth2TokenType = "bearer"; + +/** + * Helper type to represent OAuth2 authorize request body. + */ +export interface OAuth2AuthorizeRequest { + response_type: OAuth2ResponseType; + client_id: string; + client_secret: string; + scope?: string | null; + redirect_uri: string; + state?: string | null; +}; + +/** + * Helper type to represent OAuth2 authorize response body. + */ +export interface OAuth2CodeResponse { + code: string; + state?: string | null; +}; + +/** + * Helper type to represent OAuth2 token request body. + */ +export interface OAuth2TokenRequest { + grant_type: OAuth2GrantType; + code?: string | null; + refresh_token?: string | null; + username?: string | null; + password?: string | null; + passkey?: string | null; + client_id: string; + client_secret: string; + redirect_uri?: string | null; +}; + +/** + * Helper type to represent OAuth2 revoke request body. + */ +export interface OAuth2RevokeRequest { + token: string; + token_type_hint: OAuth2TokenHintType; +}; + +/** + * Helper type to represent OAuth2 token response body. + */ +export interface OAuth2TokenResponse { + token_type: OAuth2TokenType; + access_token: string; + refresh_token?: string | null; + scope?: string | null; + state?: string | null; + expires_in: number; +}; + +/** + * Helper type to represent OAuth2 introspection request body. + */ +export interface OAuth2IntrospectionRequest { + token: string; + token_type_hint: OAuth2TokenHintType; +}; + +/** + * Helper type to represent OAuth2 introspection response body. + */ +export interface OAuth2IntrospectionResponse { + active: boolean; + client_id?: string | null; + username?: string | null; + scope?: string | null; + exp?: number | null; +}; diff --git a/src/toml/index.ts b/src/toml/index.ts new file mode 100644 index 0000000..1f63715 --- /dev/null +++ b/src/toml/index.ts @@ -0,0 +1,15 @@ +// Copyright (c) Tribufu. All Rights Reserved. +// SPDX-License-Identifier: MIT + +import toml from "toml"; + +export class TomlSerializer { + /** + * Parse toml string to object. + * @param toml + * @returns {any} + */ + public static fromString(tomlString: string): any { + return toml.parse(tomlString); + } +} diff --git a/src/uuid/index.ts b/src/uuid/index.ts new file mode 100644 index 0000000..7c9eae7 --- /dev/null +++ b/src/uuid/index.ts @@ -0,0 +1,31 @@ +// Copyright (c) Tribufu. All Rights Reserved. +// SPDX-License-Identifier: MIT + +import { v1 as uuidv1, v4 as uuidv4 } from "uuid"; +import { uuidv7 } from "uuidv7"; + +export class UuidGenerator { + /** + * Generate a version 1 (time-based) UUID. + * @returns {string} + */ + public static v1(): string { + return uuidv1(); + } + + /** + * Generate a version 4 (random) UUID. + * @returns {string} + */ + public static v4(): string { + return uuidv4(); + } + + /** + * Generate a version 7 (time-based) UUID. + * @returns {string} + */ + public static v7(): string { + return uuidv7(); + } +}