Skip to content

Commit 4473385

Browse files
GozalaAlan Shaw
andauthored
feat: new store method that uploads token with all the assets and metadata (#56)
* feat: store metadata in IPLD CBOR format * feat: store api * Apply suggestions from code review Co-authored-by: Alan Shaw <alan.shaw@protocol.ai> * chore: change Token.encode to return FormData * chore: remove CID dependency * chore: add comment for `embed` field * chore: rename setAt to setIn * chore: change how URLs are encoded/decoded * chore: refine store api * feat: implement backend part * fix: typo that cause type check to fail * fix: regression in pinata.js * fix: remaining issues on the backend * chore: update addresses * chore: update implementation to use a cluster * chore: remove redundunt code * chore: undo unintended test changes * fix: import * fix: leftovers from previous iteration * Apply suggestions from code review Co-authored-by: Alan Shaw <alan.shaw@protocol.ai> * chore: switch to just-safe-set * chore: Per review feedback remove CID dep * fix: pinByHash by providing content-type * chore: add comment to why skipLibCheck is enabled * fix: use latest multiformats to fix ts lint err Co-authored-by: Alan Shaw <alan.shaw@protocol.ai>
1 parent 35fe3ad commit 4473385

18 files changed

Lines changed: 978 additions & 48 deletions

client/package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,14 +50,16 @@
5050
"ipld-in-memory": "8.0.0",
5151
"mocha": "8.3.2",
5252
"multicodec": "3.0.1",
53-
"multiformats": "4.5.3",
53+
"multiformats": "^7.0.0",
54+
"@ipld/dag-cbor": "^5.0.0",
5455
"multihashing-async": "2.1.2",
5556
"nyc": "15.1.0",
5657
"playwright-test": "2.1.0",
5758
"rollup": "2.22.1",
5859
"rollup-plugin-multi-input": "1.1.1",
5960
"typedoc": "0.20.36",
60-
"uvu": "0.5.1"
61+
"uvu": "0.5.1",
62+
"just-safe-set": "^2.2.1"
6163
},
6264
"homepage": "https://github.com/ipfs-shipyard/nft.storage/tree/main/client",
6365
"bugs": "https://github.com/ipfs-shipyard/nft.storage/issues"

client/src/lib.js

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@
1313
* ```
1414
* @module
1515
*/
16-
1716
import * as API from './lib/interface.js'
17+
import * as Token from './token.js'
1818
import { fetch, File, Blob, FormData } from './platform.js'
1919

2020
/**
@@ -113,6 +113,64 @@ class NFTStorage {
113113
}
114114
}
115115

116+
/**
117+
* @template {API.TokenInput} T
118+
* @param {API.Service} service
119+
* @param {T} data
120+
* @returns {Promise<API.Token<T>>}
121+
*/
122+
static async store(
123+
{ endpoint, token },
124+
{ name, description, image, properties, decimals, localization }
125+
) {
126+
const url = new URL(`/store`, endpoint)
127+
// Just validate that expected field are present
128+
if (typeof name !== 'string') {
129+
throw new TypeError(
130+
'string property `name` identifying the asset is required'
131+
)
132+
}
133+
if (typeof description !== 'string') {
134+
throw new TypeError(
135+
'string property `description` describing asset is required'
136+
)
137+
}
138+
139+
if (!(image instanceof Blob) || !image.type.startsWith('image/')) {
140+
throw new TypeError(
141+
'proprety `image` must be a Blob or File object with `image/*` mime type'
142+
)
143+
}
144+
if (typeof decimals !== 'undefined' && typeof decimals !== 'number') {
145+
throw new TypeError('proprety `decimals` must be an integer value')
146+
}
147+
148+
const body = Token.encode({
149+
name,
150+
description,
151+
image,
152+
properties,
153+
decimals,
154+
localization,
155+
})
156+
const paths = new Set(body.keys())
157+
158+
const response = await fetch(url.toString(), {
159+
method: 'POST',
160+
headers: NFTStorage.auth(token),
161+
body,
162+
})
163+
164+
/** @type {API.StoreResponse<T>} */
165+
const result = await response.json()
166+
167+
if (result.ok === true) {
168+
const { value } = result
169+
return Token.decode(value, paths)
170+
} else {
171+
throw new Error(result.error.message)
172+
}
173+
}
116174
/**
117175
* @param {API.Service} service
118176
* @param {string} cid
@@ -261,6 +319,14 @@ class NFTStorage {
261319
check(cid) {
262320
return NFTStorage.check(this, cid)
263321
}
322+
/**
323+
* @template {API.TokenInput} T
324+
* @param {T} token
325+
* @returns {Promise<API.Token<T>>}
326+
*/
327+
store(token) {
328+
return NFTStorage.store(this, token)
329+
}
264330
}
265331

266332
/**
@@ -283,6 +349,8 @@ const decodeDeals = (deals) =>
283349
}
284350
})
285351

352+
const TokenModel = Token.Token
353+
export { TokenModel as Token }
286354
export { NFTStorage, File, Blob, FormData }
287355

288356
/**

client/src/lib/interface.ts

Lines changed: 176 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,12 @@
1+
import type { CID } from 'multiformats'
2+
3+
export type { CID }
4+
5+
/**
6+
* Define nominal type of U based on type of T. Similar to Opaque types in Flow
7+
*/
8+
export type Tagged<T, Tag> = T & { tag?: Tag }
9+
110
export interface Service {
211
endpoint: URL
312
token: string
@@ -10,9 +19,29 @@ export interface PublicService {
1019
/**
1120
* CID in string representation
1221
*/
13-
export type CIDString = string & {}
22+
export type CIDString = Tagged<string, CID>
1423

1524
export interface API {
25+
/**
26+
* Stores the given token and all resources it references (in the form of a
27+
* File or a Blob) along with a metadata JSON as specificed in ERC-1155. The
28+
* `token.image` must be either a `File` or a `Blob` instance, which will be
29+
* stored and the corresponding content address URL will be saved in the
30+
* metadata JSON file under `image` field.
31+
*
32+
* If `token.properties` contains properties with `File` or `Blob` values,
33+
* those also get stored and their URLs will be saved in the metadata JSON
34+
* file in their place.
35+
*
36+
* Note: URLs for `File` objects will retain file names e.g. in case of
37+
* `new File([bytes], 'cat.png', { type: 'image/png' })` will be transformed
38+
* into a URL that looks like `ipfs://bafy...hash/image/cat.png`. For `Blob`
39+
* objects, the URL will not have a file name name or mime type, instead it
40+
* will be transformed into a URL that looks like
41+
* `ipfs://bafy...hash/image/blob`.
42+
*/
43+
store<T extends TokenInput>(service: Service, token: T): Promise<Token<T>>
44+
1645
/**
1746
* Stores a single file and returns a corresponding CID.
1847
*/
@@ -137,3 +166,149 @@ export interface Pin {
137166
}
138167

139168
export type PinStatus = 'queued' | 'pinning' | 'pinned' | 'failed'
169+
170+
/**
171+
* This is an input used to construct the Token metadata as per EIP-1155
172+
* @see https://eips.ethereum.org/EIPS/eip-1155#metadata
173+
*/
174+
export interface TokenInput {
175+
/**
176+
* Identifies the asset to which this token represents
177+
*/
178+
name: string
179+
/**
180+
* Describes the asset to which this token represents
181+
*/
182+
description: string
183+
/**
184+
* A `File` with mime type `image/*` representing the asset this
185+
* token represents. Consider creating images with width between `320` and
186+
* `1080` pixels and aspect ratio between `1.91:1` and `4:5` inclusive.
187+
*
188+
* If a `File` object is used, the URL in the metadata will include a filename
189+
* e.g. `ipfs://bafy...hash/cat.png`. If a `Blob` is used, the URL in the
190+
* metadata will not include filename or extension e.g. `ipfs://bafy...img/`
191+
*/
192+
image: Blob | File
193+
194+
/**
195+
* The number of decimal places that the token amount should display - e.g.
196+
* `18`, means to divide the token amount by `1000000000000000000` to get its
197+
* user representation.
198+
*/
199+
decimals?: number
200+
201+
/**
202+
* Arbitrary properties. Values may be strings, numbers, nested objects or
203+
* arrays of values. It is possible to provide `File` or `Blob` instances
204+
* as property values, which will be stored on IPFS, and metadata will
205+
* contain URLs to them in form of `ipfs://bafy...hash/name.png` or
206+
* `ipfs://bafy...file/` respectively.
207+
*/
208+
properties?: Object
209+
210+
localization?: Localization
211+
}
212+
213+
interface Localization {
214+
/**
215+
* The URI pattern to fetch localized data from. This URI should contain the
216+
* substring `{locale}` which will be replaced with the appropriate locale
217+
* value before sending the request.
218+
*/
219+
uri: string
220+
/**
221+
* The locale of the default data within the base JSON
222+
*/
223+
default: string
224+
/**
225+
* The list of locales for which data is available. These locales should
226+
* conform to those defined in the Unicode Common Locale Data Repository
227+
* (http://cldr.unicode.org/).
228+
*/
229+
locales: string[]
230+
}
231+
232+
export interface Token<T extends TokenInput> {
233+
/**
234+
* CID for the token that encloses all of the files including metadata.json
235+
* for the stored token.
236+
*/
237+
ipnft: CIDString
238+
239+
/**
240+
* URL like `ipfs://bafy...hash/meta/data.json` for the stored token metadata.
241+
*/
242+
url: EncodedURL
243+
244+
/**
245+
* Actual token data in ERC-1155 format. It matches data passed as `token`
246+
* argument except Files/Blobs are substituted with corresponding `ipfs://`
247+
* URLs.
248+
*/
249+
data: Encoded<T, [[Blob, URL]]>
250+
251+
/**
252+
* Token data just like in `data` field except urls corresponding to
253+
* Files/Blobs are substituted with IPFS gateway URLs so they can be
254+
* embedded in browsers that do not support `ipfs://` protocol.
255+
*/
256+
embed(): Encoded<T, [[Blob, URL]]>
257+
}
258+
259+
export type EncodedError = {
260+
message: string
261+
}
262+
export type EncodedURL = Tagged<string, URL>
263+
264+
export type Result<X, T> = { ok: true; value: T } | { ok: false; error: X }
265+
266+
export interface EncodedToken<T extends TokenInput> {
267+
ipnft: CIDString
268+
url: EncodedURL
269+
data: Encoded<T, [[Blob, EncodedURL]]>
270+
}
271+
export type StoreResponse<T extends TokenInput> = Result<
272+
EncodedError,
273+
EncodedToken<T>
274+
>
275+
276+
/**
277+
* Represents `T` encoded with a given `Format`.
278+
* @example
279+
* ```ts
280+
* type Format = [
281+
* [URL, { type: 'URL', href: string }]
282+
* [CID, { type: 'CID', cid: string }]
283+
* [Blob, { type: 'Blob', href: string }]
284+
* ]
285+
*
286+
* type Response<T> = Encoded<T, Format>
287+
* ```
288+
*/
289+
export type Encoded<T, Format extends Pattern<any, any>[]> = MatchRecord<
290+
T,
291+
Rule<Format[number]>
292+
>
293+
294+
/**
295+
* Format consists of multiple encoding defines what input type `I` maps to what output type `O`. It
296+
* can be represented via function type or a [I, O] tuple.
297+
*/
298+
type Pattern<I, O> = ((input: I) => O) | [I, O]
299+
300+
export type MatchRecord<T, R extends Rule<any>> = {
301+
[K in keyof T]: MatchRule<T[K], R> extends never // R extends {I: T[K], O: infer O} ? O : MatchRecord<T[K], R> //Match<T[K], R>
302+
? MatchRecord<T[K], R>
303+
: MatchRule<T[K], R>
304+
}
305+
306+
type MatchRule<T, R extends Rule<any>> = R extends (input: T) => infer O
307+
? O
308+
: never
309+
310+
type Rule<Format extends Pattern<any, any>> = Format extends [infer I, infer O]
311+
? (input: I) => O
312+
: Format extends (input: infer I) => infer O
313+
? (input: I) => O
314+
: never

client/src/platform.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import fetch, { Request, Response, Headers } from '@web-std/fetch'
22
import { FormData } from '@web-std/form-data'
3-
import { Blob, ReadableStream } from '@web-std/blob'
4-
import { File } from '@web-std/file'
3+
import { ReadableStream } from '@web-std/blob'
4+
import { File, Blob } from '@web-std/file'
55

66
export {
77
fetch,

0 commit comments

Comments
 (0)