Skip to content

Commit

Permalink
feat: add initial metadata endpoints
Browse files Browse the repository at this point in the history
  • Loading branch information
beeman committed Apr 3, 2024
1 parent 5c77276 commit bb2b6bd
Show file tree
Hide file tree
Showing 34 changed files with 615 additions and 12 deletions.
2 changes: 2 additions & 0 deletions libs/api/core/feature/src/lib/api-core-feature.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { ApiMintFeatureModule } from '@tokengator-mint/api-mint-feature'
import { ApiCommunityFeatureModule } from '@tokengator-mint/api-community-feature'
import { ApiCommunityMemberFeatureModule } from '@tokengator-mint/api-community-member-feature'
import { ApiSolanaFeatureModule } from '@tokengator-mint/api-solana-feature'
import { ApiMetadataFeatureModule } from '@tokengator-mint/api-metadata-feature'

const imports = [
// The api-feature generator will add the imports here
Expand All @@ -20,6 +21,7 @@ const imports = [
ApiCommunityFeatureModule,
ApiCommunityMemberFeatureModule,
ApiSolanaFeatureModule,
ApiMetadataFeatureModule,
]

@Module({
Expand Down
18 changes: 18 additions & 0 deletions libs/api/metadata/data-access/.eslintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"extends": ["../../../../.eslintrc.json"],
"ignorePatterns": ["!**/*"],
"overrides": [
{
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
"rules": {}
},
{
"files": ["*.ts", "*.tsx"],
"rules": {}
},
{
"files": ["*.js", "*.jsx"],
"rules": {}
}
]
}
7 changes: 7 additions & 0 deletions libs/api/metadata/data-access/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# api-metadata-data-access

This library was generated with [Nx](https://nx.dev).

## Running unit tests

Run `nx test api-metadata-data-access` to execute the unit tests via [Jest](https://jestjs.io).
11 changes: 11 additions & 0 deletions libs/api/metadata/data-access/jest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/* eslint-disable */
export default {
displayName: 'api-metadata-data-access',
preset: '../../../../jest.preset.js',
testEnvironment: 'node',
transform: {
'^.+\\.[tj]s$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.spec.json' }],
},
moduleFileExtensions: ['ts', 'js', 'html'],
coverageDirectory: '../../../../coverage/libs/api/metadata/data-access',
}
20 changes: 20 additions & 0 deletions libs/api/metadata/data-access/project.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"name": "api-metadata-data-access",
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/api/metadata/data-access/src",
"projectType": "library",
"targets": {
"lint": {
"executor": "@nx/eslint:lint",
"outputs": ["{options.outputFile}"]
},
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/api/metadata/data-access/jest.config.ts"
}
}
},
"tags": ["app:api", "type:data-access"]
}
2 changes: 2 additions & 0 deletions libs/api/metadata/data-access/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './lib/api-metadata.data-access.module'
export * from './lib/api-metadata.service'
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common'
import { ApiCoreDataAccessModule } from '@tokengator-mint/api-core-data-access'
import { ApiSolanaDataAccessModule } from '@tokengator-mint/api-solana-data-access'
import { ApiMetadataService } from './api-metadata.service'

@Module({
imports: [ApiCoreDataAccessModule, ApiSolanaDataAccessModule],
providers: [ApiMetadataService],
exports: [ApiMetadataService],
})
export class ApiMetadataDataAccessModule {}
154 changes: 154 additions & 0 deletions libs/api/metadata/data-access/src/lib/api-metadata.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { Injectable, Logger } from '@nestjs/common'
import { PublicKey } from '@solana/web3.js'
import { ApiCoreService } from '@tokengator-mint/api-core-data-access'
import { ApiSolanaService, SolanaAccountInfo } from '@tokengator-mint/api-solana-data-access'
import { LRUCache } from 'lru-cache'

export interface CantFindTheRightTypeScrewItHackathonMode {
extension: 'tokenMetadata'
state: {
additionalMetadata: [string, string][]
mint: PublicKey
name: string
symbol: string
updateAuthority?: PublicKey
uri: string
}
}

export interface ExternalMetadata {
image: string
[key: string]: string
}

export interface LocalMetadata {
name: string
symbol: string
description: string
external_url: string
image: string
}

const ONE_HOUR = 1000 * 60 * 60
const TEN_MINUTES = 1000 * 60 * 10

@Injectable()
export class ApiMetadataService {
private readonly logger = new Logger(ApiMetadataService.name)

private readonly externalMetadataCache = new LRUCache<string, ExternalMetadata>({
max: 1000,
ttl: ONE_HOUR,
fetchMethod: async (url) => {
const fetched = await fetch(url).then((r) => r.json())
if (fetched.image) {
this.logger.verbose(`Caching external metadata for ${url}`)
return fetched
}
throw new Error(`No image found in external metadata for ${url}`)
},
})

private readonly accountCache = new LRUCache<string, SolanaAccountInfo>({
max: 1000,
ttl: ONE_HOUR,
fetchMethod: async (account: string) => {
const found = await this.solana.getAccount(account)
if (found) {
this.logger.verbose(`Caching account info for ${account}`)
return found
}
throw new Error(`Failed to fetch account info for ${account}`)
},
})

private readonly accountMetadataCache = new LRUCache<string, CantFindTheRightTypeScrewItHackathonMode>({
max: 1000,
ttl: TEN_MINUTES,
fetchMethod: async (account: string) => {
const found = await this.accountCache.fetch(account)
const extensions = found?.data?.parsed?.info?.extensions as CantFindTheRightTypeScrewItHackathonMode[]
const metadata = extensions.find((e) => e.extension === 'tokenMetadata')

if (!metadata) {
throw new Error(`Failed to fetch metadata for ${account}`)
}
return metadata
},
})

private readonly jsonCache = new LRUCache<string, LocalMetadata>({
max: 1000,
ttl: ONE_HOUR,
fetchMethod: async (account: string) => {
const image = `${this.core.config.apiUrl}/metadata/image/${account}`
const metadata = await this.accountMetadataCache.fetch(account)

const { name, symbol, description, external_url } = defaults()

if (metadata) {
if (!metadata.state.uri.startsWith(this.core.config.apiUrl)) {
// We have external metadata
const externalMetadata = await this.externalMetadataCache.fetch(metadata.state.uri)

if (!externalMetadata) {
throw new Error(`Failed to fetch external metadata for ${metadata.state.uri}`)
}
if (externalMetadata.image) {
return {
name: metadata.state.name ?? name,
symbol: metadata.state.symbol ?? symbol,
description: metadata.state.uri ?? description,
external_url: metadata.state.uri ?? external_url,
image: externalMetadata.image,
}
}
}

return {
name: metadata.state.name ?? name,
symbol: metadata.state.symbol ?? symbol,
description: metadata.state.uri ?? description,
external_url: metadata.state.uri ?? external_url,
image,
}
}

return {
name,
symbol,
description,
external_url,
image,
}
},
})

constructor(private readonly core: ApiCoreService, private readonly solana: ApiSolanaService) {}

async getImage(account: string): Promise<string> {
const accountMetadata = await this.accountMetadataCache.fetch(account)
if (!accountMetadata) {
throw new Error(`Failed to fetch metadata for ${account}`)
}
const externalMetadata = await this.externalMetadataCache.fetch(accountMetadata.state.uri)
if (!externalMetadata) {
throw new Error(`Failed to fetch external metadata for ${accountMetadata.state.uri}`)
}

return externalMetadata.image
}

async getJson(account: string) {
return this.jsonCache.fetch(account)
}
}

function defaults() {
const name = `Unknown Name`
const symbol = `Unknown Symbol`
const description = `Unknown Description`
const external_url = `https://example.com`

return { name, symbol, description, external_url }
}
22 changes: 22 additions & 0 deletions libs/api/metadata/data-access/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"module": "commonjs",
"forceConsistentCasingInFileNames": true,
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}
16 changes: 16 additions & 0 deletions libs/api/metadata/data-access/tsconfig.lib.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../../dist/out-tsc",
"declaration": true,
"types": ["node"],
"target": "es6",
"strictNullChecks": true,
"noImplicitAny": true,
"strictBindCallApply": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*.ts"],
"exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"]
}
9 changes: 9 additions & 0 deletions libs/api/metadata/data-access/tsconfig.spec.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../../dist/out-tsc",
"module": "commonjs",
"types": ["jest", "node"]
},
"include": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts"]
}
18 changes: 18 additions & 0 deletions libs/api/metadata/feature/.eslintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"extends": ["../../../../.eslintrc.json"],
"ignorePatterns": ["!**/*"],
"overrides": [
{
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
"rules": {}
},
{
"files": ["*.ts", "*.tsx"],
"rules": {}
},
{
"files": ["*.js", "*.jsx"],
"rules": {}
}
]
}
7 changes: 7 additions & 0 deletions libs/api/metadata/feature/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# api-metadata-feature

This library was generated with [Nx](https://nx.dev).

## Running unit tests

Run `nx test api-metadata-feature` to execute the unit tests via [Jest](https://jestjs.io).
11 changes: 11 additions & 0 deletions libs/api/metadata/feature/jest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/* eslint-disable */
export default {
displayName: 'api-metadata-feature',
preset: '../../../../jest.preset.js',
testEnvironment: 'node',
transform: {
'^.+\\.[tj]s$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.spec.json' }],
},
moduleFileExtensions: ['ts', 'js', 'html'],
coverageDirectory: '../../../../coverage/libs/api/metadata/feature',
}
20 changes: 20 additions & 0 deletions libs/api/metadata/feature/project.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"name": "api-metadata-feature",
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/api/metadata/feature/src",
"projectType": "library",
"targets": {
"lint": {
"executor": "@nx/eslint:lint",
"outputs": ["{options.outputFile}"]
},
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/api/metadata/feature/jest.config.ts"
}
}
},
"tags": ["app:api", "type:feature"]
}
1 change: 1 addition & 0 deletions libs/api/metadata/feature/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './lib/api-metadata.feature.module'
20 changes: 20 additions & 0 deletions libs/api/metadata/feature/src/lib/api-metadata.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { Controller, Get, Param, Res } from '@nestjs/common'
import { ApiMetadataService } from '@tokengator-mint/api-metadata-data-access'
import { Response } from 'express-serve-static-core'

@Controller('metadata')
export class ApiMetadataController {
constructor(private readonly service: ApiMetadataService) {}

@Get('image/:account')
async image(@Param('account') account: string, @Res() res: Response) {
const imageUrl = await this.service.getImage(account)

return res.redirect(imageUrl)
}

@Get('json/:account')
async json(@Param('account') account: string) {
return this.service.getJson(account)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common'
import { ApiMetadataDataAccessModule } from '@tokengator-mint/api-metadata-data-access'
import { ApiMetadataController } from './api-metadata.controller'

@Module({
controllers: [ApiMetadataController],
imports: [ApiMetadataDataAccessModule],
})
export class ApiMetadataFeatureModule {}
Loading

0 comments on commit bb2b6bd

Please sign in to comment.