diff --git a/src/api/routes/inscriptions.ts b/src/api/routes/inscriptions.ts index 23c2017b..6d19ffc2 100644 --- a/src/api/routes/inscriptions.ts +++ b/src/api/routes/inscriptions.ts @@ -1,6 +1,5 @@ import { TypeBoxTypeProvider } from '@fastify/type-provider-typebox'; import { Type } from '@sinclair/typebox'; -import { TypeCompiler } from '@sinclair/typebox/compiler'; import { Value } from '@sinclair/typebox/value'; import { FastifyPluginAsync, FastifyPluginCallback } from 'fastify'; import { Server } from 'http'; @@ -20,7 +19,10 @@ import { OrderParam, OrderBy, Order, - InscriptionIdParam, + InscriptionIdentifierParam, + BlockHashParamCType, + BlockHeightParamCType, + InscriptionIdParamCType, } from '../types'; import { handleChainTipCache, handleInscriptionCache } from '../util/cache'; import { @@ -30,9 +32,6 @@ import { parseDbInscriptions, } from '../util/helpers'; -const BlockHashParamCType = TypeCompiler.Compile(BlockHashParam); -const BlockHeightParamCType = TypeCompiler.Compile(BlockHeightParam); - const IndexRoutes: FastifyPluginCallback, Server, TypeBoxTypeProvider> = ( fastify, options, @@ -103,14 +102,14 @@ const ShowRoutes: FastifyPluginCallback, Server, TypeBoxTyp ) => { fastify.addHook('preHandler', handleInscriptionCache); fastify.get( - '/inscriptions/:inscription_id', + '/inscriptions/:id', { schema: { summary: 'Inscription', description: 'Retrieves a single inscription', tags: ['Inscriptions'], params: Type.Object({ - inscription_id: InscriptionIdParam, + id: InscriptionIdentifierParam, }), response: { 200: InscriptionResponse, @@ -119,8 +118,11 @@ const ShowRoutes: FastifyPluginCallback, Server, TypeBoxTyp }, }, async (request, reply) => { + const idArg = InscriptionIdParamCType.Check(request.params.id) + ? { genesis_id: request.params.id } + : { number: request.params.id }; const inscription = await fastify.db.getInscriptions({ - genesis_id: request.params.inscription_id, + ...idArg, limit: 1, offset: 0, }); @@ -133,14 +135,14 @@ const ShowRoutes: FastifyPluginCallback, Server, TypeBoxTyp ); fastify.get( - '/inscriptions/:inscription_id/content', + '/inscriptions/:id/content', { schema: { summary: 'Inscription content', description: 'Retrieves the contents of a single inscription', tags: ['Inscriptions'], params: Type.Object({ - inscription_id: InscriptionIdParam, + id: InscriptionIdentifierParam, }), response: { 200: Type.Uint8Array(), @@ -149,9 +151,10 @@ const ShowRoutes: FastifyPluginCallback, Server, TypeBoxTyp }, }, async (request, reply) => { - const inscription = await fastify.db.getInscriptionContent({ - inscription_id: request.params.inscription_id, - }); + const idArg = InscriptionIdParamCType.Check(request.params.id) + ? { genesis_id: request.params.id } + : { number: request.params.id }; + const inscription = await fastify.db.getInscriptionContent(idArg); if (inscription) { const bytes = hexToBuffer(inscription.content); await reply diff --git a/src/api/types.ts b/src/api/types.ts index bb33b289..a62fc9c9 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -1,4 +1,5 @@ import { Static, TSchema, Type } from '@sinclair/typebox'; +import { TypeCompiler } from '@sinclair/typebox/compiler'; import { SatoshiRarity, SAT_SUPPLY } from './util/ordinal-satoshi'; export const AddressParam = Type.String({ @@ -7,12 +8,22 @@ export const AddressParam = Type.String({ examples: ['bc1p8aq8s3z9xl87e74twfk93mljxq6alv4a79yheadx33t9np4g2wkqqt8kc5'], }); -export const InscriptionIdRegEx = /^[a-fA-F0-9]{64}i[0-9]+$/; -export const InscriptionIdParam = Type.RegEx(InscriptionIdRegEx, { +export const InscriptionIdParam = Type.RegEx(/^[a-fA-F0-9]{64}i[0-9]+$/, { title: 'Inscription ID', description: 'Inscription unique identifier', examples: ['38c46a8bf7ec90bc7f6b797e7dc84baa97f4e5fd4286b92fe1b50176d03b18dci0'], }); +export const InscriptionIdParamCType = TypeCompiler.Compile(InscriptionIdParam); + +export const InscriptionNumberParam = Type.Integer({ + minimum: 0, + title: 'Inscription Number', + description: 'Number of the inscription', + examples: ['10500'], +}); +export const InscriptionNumberParamCType = TypeCompiler.Compile(InscriptionNumberParam); + +export const InscriptionIdentifierParam = Type.Union([InscriptionIdParam, InscriptionNumberParam]); export const OrdinalParam = Type.Integer({ title: 'Ordinal Number', @@ -27,12 +38,14 @@ export const BlockHeightParam = Type.RegEx(/^[0-9]+$/, { description: 'Bitcoin block height', examples: [777678], }); +export const BlockHeightParamCType = TypeCompiler.Compile(BlockHeightParam); export const BlockHashParam = Type.RegEx(/^[0]{8}[a-fA-F0-9]{56}$/, { title: 'Block Hash', description: 'Bitcoin block hash', examples: ['0000000000000000000452773967cdd62297137cdaf79950c5e8bb0c62075133'], }); +export const BlockHashParamCType = TypeCompiler.Compile(BlockHashParam); export const MimeTypeParam = Type.RegEx(/^\w+\/[-.\w]+(?:\+[-.\w]+)?$/, { title: 'MIME Type', diff --git a/src/api/util/cache.ts b/src/api/util/cache.ts index aaaee1e9..e48c5cab 100644 --- a/src/api/util/cache.ts +++ b/src/api/util/cache.ts @@ -1,6 +1,6 @@ import { FastifyReply, FastifyRequest } from 'fastify'; import { logger } from '../../logger'; -import { InscriptionIdRegEx } from '../types'; +import { InscriptionIdParamCType, InscriptionNumberParamCType } from '../types'; export enum ETagType { chainTip, @@ -57,18 +57,16 @@ export function setReplyNonCacheable(reply: FastifyReply) { async function getInscriptionLocationEtag(request: FastifyRequest): Promise { try { const components = request.url.split('/'); - let inscription_id: string | undefined; do { const lastElement = components.pop(); if (lastElement && lastElement.length) { - if (InscriptionIdRegEx.test(lastElement)) { - inscription_id = lastElement; - break; + if (InscriptionIdParamCType.Check(lastElement)) { + return await request.server.db.getInscriptionETag({ genesis_id: lastElement }); + } else if (InscriptionNumberParamCType.Check(parseInt(lastElement))) { + return await request.server.db.getInscriptionETag({ number: lastElement }); } } } while (components.length); - if (!inscription_id) return; - return await request.server.db.getInscriptionETag({ inscription_id }); } catch (error) { return; } diff --git a/src/pg/pg-store.ts b/src/pg/pg-store.ts index 3b28663b..27374bd7 100644 --- a/src/pg/pg-store.ts +++ b/src/pg/pg-store.ts @@ -17,6 +17,8 @@ import { LOCATIONS_COLUMNS, } from './types'; +type InscriptionIdentifier = { genesis_id: string } | { number: number }; + export class PgStore extends BasePgStore { static async connect(opts?: { skipMigrations: boolean }): Promise { const pgConfig = { @@ -118,25 +120,33 @@ export class PgStore extends BasePgStore { return result[0]; } - async getInscriptionContent(args: { - inscription_id: string; - }): Promise { + async getInscriptionContent( + args: InscriptionIdentifier + ): Promise { const result = await this.sql` SELECT content, content_type, content_length FROM inscriptions - WHERE genesis_id = ${args.inscription_id} + WHERE ${ + 'genesis_id' in args + ? this.sql`genesis_id = ${args.genesis_id}` + : this.sql`number = ${args.number}` + } `; if (result.count > 0) { return result[0]; } } - async getInscriptionETag(args: { inscription_id: string }): Promise { + async getInscriptionETag(args: InscriptionIdentifier): Promise { const result = await this.sql<{ etag: string }[]>` SELECT date_part('epoch', l.timestamp)::text AS etag FROM locations AS l INNER JOIN inscriptions AS i ON l.inscription_id = i.id - WHERE i.genesis_id = ${args.inscription_id} + WHERE ${ + 'genesis_id' in args + ? this.sql`i.genesis_id = ${args.genesis_id}` + : this.sql`i.number = ${args.number}` + } AND l.current = TRUE `; if (result.count > 0) { @@ -148,6 +158,7 @@ export class PgStore extends BasePgStore { genesis_id?: string; genesis_block_height?: number; genesis_block_hash?: string; + number?: number; address?: string; mime_type?: string[]; output?: string; diff --git a/tests/cache.test.ts b/tests/cache.test.ts index d4395cd6..f9dc2768 100644 --- a/tests/cache.test.ts +++ b/tests/cache.test.ts @@ -55,6 +55,16 @@ describe('ETag cache', () => { expect(response.headers.etag).not.toBeUndefined(); const etag = response.headers.etag; + // Check on numbered id too + const nResponse = await fastify.inject({ + method: 'GET', + url: '/ordinals/v1/inscriptions/7', + }); + expect(nResponse.statusCode).toBe(200); + expect(nResponse.headers.etag).not.toBeUndefined(); + const nEtag = nResponse.headers.etag; + expect(nEtag).toBe(etag); + // Cached response const cached = await fastify.inject({ method: 'GET', @@ -62,6 +72,12 @@ describe('ETag cache', () => { headers: { 'if-none-match': etag }, }); expect(cached.statusCode).toBe(304); + const nCached = await fastify.inject({ + method: 'GET', + url: '/ordinals/v1/inscriptions/7', + headers: { 'if-none-match': etag }, + }); + expect(nCached.statusCode).toBe(304); // Simulate modified location and check status code await db.sql`UPDATE locations SET timestamp = NOW() WHERE true`; @@ -71,6 +87,12 @@ describe('ETag cache', () => { headers: { 'if-none-match': etag }, }); expect(cached2.statusCode).toBe(200); + const nCached2 = await fastify.inject({ + method: 'GET', + url: '/ordinals/v1/inscriptions/7', + headers: { 'if-none-match': etag }, + }); + expect(nCached2.statusCode).toBe(200); }); test('inscriptions index cache control', async () => { diff --git a/tests/inscriptions.test.ts b/tests/inscriptions.test.ts index 2552904f..82ef97bc 100644 --- a/tests/inscriptions.test.ts +++ b/tests/inscriptions.test.ts @@ -47,12 +47,7 @@ describe('/inscriptions', () => { current: true, }, }); - const response = await fastify.inject({ - method: 'GET', - url: '/ordinals/v1/inscriptions/38c46a8bf7ec90bc7f6b797e7dc84baa97f4e5fd4286b92fe1b50176d03b18dci0', - }); - expect(response.statusCode).toBe(200); - expect(response.json()).toStrictEqual({ + const expected = { address: 'bc1p3cyx5e2hgh53w7kpxcvm8s4kkega9gv5wfw7c4qxsvxl0u8x834qf0u2td', genesis_block_hash: '00000000000000000002a90330a99f67e3f01eb2ce070b45930581e82fb7a91d', genesis_block_height: 775617, @@ -69,7 +64,23 @@ describe('/inscriptions', () => { sat_rarity: 'common', timestamp: 1676913207000, genesis_tx_id: '38c46a8bf7ec90bc7f6b797e7dc84baa97f4e5fd4286b92fe1b50176d03b18dc', + }; + + // By inscription id + const response = await fastify.inject({ + method: 'GET', + url: '/ordinals/v1/inscriptions/38c46a8bf7ec90bc7f6b797e7dc84baa97f4e5fd4286b92fe1b50176d03b18dci0', + }); + expect(response.statusCode).toBe(200); + expect(response.json()).toStrictEqual(expected); + + // By inscription number + const response2 = await fastify.inject({ + method: 'GET', + url: '/ordinals/v1/inscriptions/7', }); + expect(response2.statusCode).toBe(200); + expect(response2.json()).toStrictEqual(expected); }); test('index filtered by mime type', async () => {