Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 87 additions & 5 deletions apps/api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,15 +113,97 @@ GET /

Returns service name.

### Signed Price Feeds

#### Get latest prices for all symbols

```bash
GET /prices/latest
```

Returns an array of the latest signed price proofs for every configured symbol.

**Response `200`:**
```json
[
{
"data": {
"symbol": "AAPL",
"price": 189.45,
"timestamp": 1719500000000,
"source": "aggregator"
},
"signature": "3045022100...",
"publicKey": "04abc123...",
"timestamp": 1719500000000
}
]
```

**Response `503`** — returned when the aggregator has not yet published any signed proofs:
```json
{
"statusCode": 503,
"message": "Price data is not available yet. The aggregator has not published any signed proofs."
}
```

#### Get latest price for a single symbol

```bash
GET /prices/latest/:symbol
```

Returns the latest signed price proof for the requested symbol (case-insensitive).

**Response `200`:**
```json
{
"data": {
"symbol": "AAPL",
"price": 189.45,
"timestamp": 1719500000000,
"source": "aggregator"
},
"signature": "3045022100...",
"publicKey": "04abc123...",
"timestamp": 1719500000000
}
```

**Response `404`** — returned when the requested symbol has no data:
```json
{
"statusCode": 404,
"message": "No price data found for symbol \"XYZ\""
}
```

**Response `503`** — same as above (no data available at all).

### How price data is populated

The `PricesService` exposes an `updatePrice(proof: SignedPriceProof)` method.
In production this will be called by a background job or internal handler that
receives freshly signed proofs from the aggregator/signer pipeline. For local
development the store starts empty; you can seed it programmatically or via
a future admin endpoint.

## Project Structure

```
apps/api/
├── src/
│ ├── main.ts # Application entry point
│ ├── app.module.ts # Root module
│ ├── app.controller.ts # Main controller
│ └── app.service.ts # Main service
│ ├── main.ts # Application entry point
│ ├── app.module.ts # Root module
│ ├── app.controller.ts # Main controller (root + health)
│ ├── app.service.ts # Main service
│ └── prices/
│ ├── prices.module.ts # Prices feature module
│ ├── prices.controller.ts # REST endpoints for signed feeds
│ ├── prices.controller.spec.ts # Controller tests
│ ├── prices.service.ts # In-memory price store
│ └── prices.service.spec.ts # Service tests
├── .env.example # Example environment variables
├── nest-cli.json # NestJS CLI configuration
├── package.json # Dependencies and scripts
Expand All @@ -131,4 +213,4 @@ apps/api/

## Status

🚧 Under construction - REST and WebSocket endpoints will be implemented in subsequent issues.
🚧 Under construction — signed price feed endpoints are available; WebSocket subscriptions and real-time updates will be implemented in subsequent issues.
1 change: 1 addition & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"@nestjs/common": "^10.0.0",
"@nestjs/core": "^10.0.0",
"@nestjs/platform-express": "^10.0.0",
"@oracle-stocks/signer": "*",
"reflect-metadata": "^0.1.13",
"rxjs": "^7.8.1"
},
Expand Down
3 changes: 2 additions & 1 deletion apps/api/src/app.module.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { PricesModule } from './prices/prices.module';

@Module({
imports: [],
imports: [PricesModule],
controllers: [AppController],
providers: [AppService],
})
Expand Down
78 changes: 78 additions & 0 deletions apps/api/src/prices/prices.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { Test, TestingModule } from '@nestjs/testing';
import { NotFoundException, ServiceUnavailableException } from '@nestjs/common';
import { PricesController } from './prices.controller';
import { PricesService } from './prices.service';
import { SignedPriceProof } from '@oracle-stocks/signer';

const mockProof = (symbol: string, price: number): SignedPriceProof => ({
data: { symbol, price, timestamp: Date.now(), source: 'test' },
signature: 'sig_' + symbol,
publicKey: 'pk_test',
timestamp: Date.now(),
});

describe('PricesController', () => {
let controller: PricesController;
let service: PricesService;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [PricesController],
providers: [PricesService],
}).compile();

controller = module.get<PricesController>(PricesController);
service = module.get<PricesService>(PricesService);
});

it('should be defined', () => {
expect(controller).toBeDefined();
});

describe('GET /prices/latest', () => {
it('should throw 503 when no data is available', () => {
expect(() => controller.getLatestAll()).toThrow(
ServiceUnavailableException,
);
});

it('should return all signed proofs', () => {
service.updatePrice(mockProof('AAPL', 150));
service.updatePrice(mockProof('GOOGL', 2800));

const result = controller.getLatestAll();
expect(result).toHaveLength(2);
});
});

describe('GET /prices/latest/:symbol', () => {
it('should throw 503 when no data is available', () => {
expect(() => controller.getLatestBySymbol('AAPL')).toThrow(
ServiceUnavailableException,
);
});

it('should throw 404 for unknown symbol', () => {
service.updatePrice(mockProof('AAPL', 150));

expect(() => controller.getLatestBySymbol('UNKNOWN')).toThrow(
NotFoundException,
);
});

it('should return the signed proof for a known symbol', () => {
const proof = mockProof('AAPL', 150);
service.updatePrice(proof);

const result = controller.getLatestBySymbol('AAPL');
expect(result).toEqual(proof);
});

it('should be case-insensitive', () => {
service.updatePrice(mockProof('AAPL', 150));

expect(controller.getLatestBySymbol('aapl')).toBeDefined();
expect(controller.getLatestBySymbol('Aapl')).toBeDefined();
});
});
});
41 changes: 41 additions & 0 deletions apps/api/src/prices/prices.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import {
Controller,
Get,
Param,
NotFoundException,
ServiceUnavailableException,
} from '@nestjs/common';
import { PricesService } from './prices.service';
import { SignedPriceProof } from '@oracle-stocks/signer';

@Controller('prices')
export class PricesController {
constructor(private readonly pricesService: PricesService) {}

@Get('latest')
getLatestAll(): SignedPriceProof[] {
if (!this.pricesService.isReady()) {
throw new ServiceUnavailableException(
'Price data is not available yet. The aggregator has not published any signed proofs.',
);
}
return this.pricesService.getAllLatestPrices();
}

@Get('latest/:symbol')
getLatestBySymbol(@Param('symbol') symbol: string): SignedPriceProof {
if (!this.pricesService.isReady()) {
throw new ServiceUnavailableException(
'Price data is not available yet. The aggregator has not published any signed proofs.',
);
}

const proof = this.pricesService.getLatestPrice(symbol);
if (!proof) {
throw new NotFoundException(
`No price data found for symbol "${symbol.toUpperCase()}"`,
);
}
return proof;
}
}
10 changes: 10 additions & 0 deletions apps/api/src/prices/prices.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { PricesController } from './prices.controller';
import { PricesService } from './prices.service';

@Module({
controllers: [PricesController],
providers: [PricesService],
exports: [PricesService],
})
export class PricesModule {}
92 changes: 92 additions & 0 deletions apps/api/src/prices/prices.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { Test, TestingModule } from '@nestjs/testing';
import { PricesService } from './prices.service';
import { SignedPriceProof } from '@oracle-stocks/signer';

const mockProof = (symbol: string, price: number): SignedPriceProof => ({
data: { symbol, price, timestamp: Date.now(), source: 'test' },
signature: 'sig_' + symbol,
publicKey: 'pk_test',
timestamp: Date.now(),
});

describe('PricesService', () => {
let service: PricesService;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [PricesService],
}).compile();

service = module.get<PricesService>(PricesService);
});

it('should be defined', () => {
expect(service).toBeDefined();
});

describe('isReady', () => {
it('should return false when the store is empty', () => {
expect(service.isReady()).toBe(false);
});

it('should return true after a price is added', () => {
service.updatePrice(mockProof('AAPL', 150));
expect(service.isReady()).toBe(true);
});
});

describe('updatePrice / getLatestPrice', () => {
it('should store and retrieve a price by symbol', () => {
const proof = mockProof('AAPL', 150);
service.updatePrice(proof);

const result = service.getLatestPrice('AAPL');
expect(result).toEqual(proof);
});

it('should normalise symbols to uppercase', () => {
service.updatePrice(mockProof('aapl', 150));
expect(service.getLatestPrice('AAPL')).toBeDefined();
expect(service.getLatestPrice('aapl')).toBeDefined();
});

it('should overwrite previous value for the same symbol', () => {
service.updatePrice(mockProof('AAPL', 100));
service.updatePrice(mockProof('AAPL', 200));

expect(service.getLatestPrice('AAPL')!.data.price).toBe(200);
});

it('should return undefined for unknown symbols', () => {
expect(service.getLatestPrice('UNKNOWN')).toBeUndefined();
});
});

describe('getAllLatestPrices', () => {
it('should return an empty array when store is empty', () => {
expect(service.getAllLatestPrices()).toEqual([]);
});

it('should return all stored proofs', () => {
service.updatePrice(mockProof('AAPL', 150));
service.updatePrice(mockProof('GOOGL', 2800));

const all = service.getAllLatestPrices();
expect(all).toHaveLength(2);

const symbols = all.map((p) => p.data.symbol);
expect(symbols).toContain('AAPL');
expect(symbols).toContain('GOOGL');
});
});

describe('clearAll', () => {
it('should remove all stored proofs', () => {
service.updatePrice(mockProof('AAPL', 150));
service.clearAll();

expect(service.isReady()).toBe(false);
expect(service.getAllLatestPrices()).toEqual([]);
});
});
});
35 changes: 35 additions & 0 deletions apps/api/src/prices/prices.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { Injectable } from '@nestjs/common';
import { SignedPriceProof } from '@oracle-stocks/signer';

/**
* In-memory store for signed price proofs.
*
* Intended to be populated by the aggregator/signer pipeline via
* `updatePrice()`. In production this will be called from a background
* job or internal gRPC/HTTP handler that receives freshly signed proofs
* from the aggregator service. For now it acts as a simple stub store.
*/
@Injectable()
export class PricesService {
private readonly store = new Map<string, SignedPriceProof>();

updatePrice(proof: SignedPriceProof): void {
this.store.set(proof.data.symbol.toUpperCase(), proof);
}

getLatestPrice(symbol: string): SignedPriceProof | undefined {
return this.store.get(symbol.toUpperCase());
}

getAllLatestPrices(): SignedPriceProof[] {
return Array.from(this.store.values());
}

isReady(): boolean {
return this.store.size > 0;
}

clearAll(): void {
this.store.clear();
}
}
Loading