Skip to content

Commit

Permalink
feat: initial commit of PubKey Protocol Resolver
Browse files Browse the repository at this point in the history
  • Loading branch information
beeman committed Jun 16, 2024
1 parent 72d0741 commit 8cf5fd4
Show file tree
Hide file tree
Showing 10 changed files with 254 additions and 16 deletions.
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
SOLANA_RPC_ENDPOINT=https://mainnet.helius-rpc.com/?api-key=<foo-bar>
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,4 @@ Thumbs.db
.nx/cache
.nx/workspace-data
nx-cloud.env
.env
118 changes: 118 additions & 0 deletions api/src/lib/features/profile.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { AnchorProvider } from '@coral-xyz/anchor'
import { PUBKEY_PROFILE_PROGRAM_ID, PubKeyIdentityProvider, PubKeyProfile } from '@pubkey-program-library/anchor'
import { AnchorKeypairWallet, PubKeyProfileSdk } from '@pubkey-program-library/sdk'
import { Keypair, PublicKey } from '@solana/web3.js'
import { ServerConfig } from '../server-config'

export class ProfileService {
private readonly sdk: PubKeyProfileSdk
private readonly validProviders: PubKeyIdentityProvider[] = [
// Add more providers here once the protocol supports them
PubKeyIdentityProvider.Discord,
PubKeyIdentityProvider.Github,
PubKeyIdentityProvider.Google,
PubKeyIdentityProvider.Solana,
PubKeyIdentityProvider.Twitter,
]

constructor(private readonly config: ServerConfig) {
this.sdk = new PubKeyProfileSdk({
connection: this.config.connection,
provider: this.getAnchorProvider(),
programId: PUBKEY_PROFILE_PROGRAM_ID,
})
}

getApiUrl(path: string) {
return `${this.config.apiUrl}/api${path}`
}

getProviders() {
return this.validProviders
}

async getUserProfileByUsername(username: string): Promise<PubKeyProfile | null> {
this.ensureValidUsername(username)

try {
return this.sdk.getProfileByUsernameNullable({ username })
} catch (e) {
throw new Error(`User profile not found for username ${username}`)
}
}

async getUserProfileByProvider(provider: PubKeyIdentityProvider, providerId: string): Promise<PubKeyProfile | null> {
try {
this.ensureValidProvider(provider)
} catch (e) {
throw new Error(`Invalid provider, must be one of ${this.validProviders.join(', ')}`)
}

try {
this.ensureValidProviderId(provider, providerId)
} catch (e) {
throw new Error(`Invalid provider ID for provider ${provider}`)
}
try {
return await this.sdk.getProfileByProviderNullable({ provider, providerId })
} catch (e) {
throw new Error(`User profile not found for provider ${provider} and providerId ${providerId}`)
}
}

async getUserProfiles(): Promise<PubKeyProfile[]> {
return this.sdk.getProfiles().then((res) => res.sort((a, b) => a.username.localeCompare(b.username)))
}

private ensureValidProvider(provider: PubKeyIdentityProvider) {
if (!this.validProviders.includes(provider)) {
throw new Error(`Invalid provider: ${provider}`)
}
}

private ensureValidProviderId(provider: PubKeyIdentityProvider, providerId: string) {
if (provider === PubKeyIdentityProvider.Solana && !isSolanaPublicKey(providerId)) {
throw new Error(`Invalid provider ID for ${provider}.`)
}
if (provider !== PubKeyIdentityProvider.Solana && !isNumericString(providerId)) {
throw new Error(`Invalid provider ID for ${provider}.`)
}
}

private ensureValidUsername(username: string) {
if (!isValidUsername(username)) {
throw new Error(`Invalid username: ${username}`)
}
}

private getAnchorProvider(keypair = Keypair.generate()) {
return new AnchorProvider(this.config.connection, new AnchorKeypairWallet(keypair), AnchorProvider.defaultOptions())
}
}
function isNumericString(str: string): boolean {
return /^\d+$/.test(str)
}

function isSolanaPublicKey(str: string): boolean {
return !!parseSolanaPublicKey(str)
}

function parseSolanaPublicKey(publicKey: string): PublicKey | null {
try {
return new PublicKey(publicKey)
} catch (e) {
return null
}
}

function isValidUsername(username: string): boolean {
if (username.length < 3 || username.length > 20) {
return false
}

if (!username.split('').every((c) => /^[a-z0-9_]$/.test(c))) {
return false
}

return true
}
60 changes: 60 additions & 0 deletions api/src/lib/features/profiles.routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { PubKeyIdentityProvider } from '@pubkey-program-library/anchor'
import express, { Request, Response } from 'express'
import { ServerConfig } from '../server-config'
import { ProfileService } from './profile.service'

export function profilesRoutes(config: ServerConfig): express.Router {
const router = express.Router()
const service = new ProfileService(config)

router.get('', (_: Request, res: Response) => {
return res.send(
[
'/profiles/all',
'/profiles/providers',
'/profiles/provider/:provider/:providerId',
'/profiles/username/:username',
].map((p) => service.getApiUrl(p)),
)
})

router.get('/all', async (_: Request, res: Response) => {
return res.send(await service.getUserProfiles())
})

router.get('/providers', (_: Request, res: Response) => {
return res.send(service.getProviders())
})

router.get('/provider/:provider/:providerId', async (req: Request, res: Response) => {
const { provider, providerId } = req.params

try {
const profile = await service.getUserProfileByProvider(provider as PubKeyIdentityProvider, providerId)
if (!profile) {
return res.status(404).send(`User profile not found for provider ${provider} and providerId ${providerId}`)
}
return res.send(profile)
} catch (e) {
return res.status(404).send(e.message)
}
})

router.get('/username/:username', async (req: Request, res: Response) => {
const { username } = req.params

try {
const profile = await service.getUserProfileByUsername(username)
if (!profile) {
return res.status(404).send(`User profile not found for username ${username}`)
}
return res.send(profile)
} catch (e) {
return res.status(404).send(e.message)
}
})

router.use('*', (req: Request, res: Response) => res.status(404).send('Profile Not Found'))

return router
}
9 changes: 0 additions & 9 deletions api/src/lib/features/uptime.route.ts

This file was deleted.

6 changes: 6 additions & 0 deletions api/src/lib/server-config.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import { Connection } from '@solana/web3.js'

export interface ServerConfig {
apiUrl: string
connection: Connection
host: string
port: string
}

export function getServerConfig(): ServerConfig {
const requiredEnvVars = [
// Place any required environment variables here
'SOLANA_RPC_ENDPOINT',
]
const missingEnvVars = requiredEnvVars.filter((envVar) => !process.env[envVar]?.length)

Expand All @@ -19,9 +23,11 @@ export function getServerConfig(): ServerConfig {
const port = process.env.PORT || '3000'

const apiUrl = process.env.API_URL || `http://${host}:${port}`
const connection = new Connection(process.env.SOLANA_RPC_ENDPOINT, 'confirmed')

return {
apiUrl,
connection,
host,
port,
}
Expand Down
13 changes: 7 additions & 6 deletions api/src/lib/server-router.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import express, { Request, Response } from 'express'
import { profilesRoutes } from './features/profiles.routes'
import { ServerConfig } from './server-config'

import { uptimeRoute } from './features/uptime.route'

export function serverRouter(): express.Router {
export function serverRouter(config: ServerConfig): express.Router {
const router = express.Router()

router.use('/uptime', uptimeRoute())
router.use('/', (req: Request, res: Response) => res.send('PubKey API'))
router.use('*', (req: Request, res: Response) => res.status(404).send('Not Found'))
router.use('/profiles', profilesRoutes(config))
router.use('/uptime', (_: Request, res: Response) => res.json({ uptime: process.uptime() }))
router.use('/', (_: Request, res: Response) => res.send('PubKey API'))
router.use('*', (_: Request, res: Response) => res.status(404).send('Not Found'))

return router
}
2 changes: 1 addition & 1 deletion api/src/lib/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export async function server(config: ServerConfig) {
// Parse JSON
app.use(express.json())
// Set base path to /api
app.use('/api', serverRouter())
app.use('/api', serverRouter(config))
// Serve static files
const staticPath = setupAssets(app, dir)

Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@
},
"dependencies": {
"@coral-xyz/anchor": "^0.29.0",
"@pubkey-program-library/anchor": "^1.7.1",
"@pubkey-program-library/sdk": "^1.7.1",
"@solana/spl-token": "^0.4.1",
"@solana/web3.js": "^1.91.0",
"clsx": "^2.1.0",
Expand Down
58 changes: 58 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit 8cf5fd4

Please sign in to comment.