-
-
Notifications
You must be signed in to change notification settings - Fork 5.1k
Description
Versioned entities
#3457 introduced the concept of versioned entities for validating incoming data corresponding to public data structures against maintained Zod schemas and migrating them to the latest version before further consumption. This is achieved via verzod.
The existing versioned entities are:
Generally, data that is persisted in
localStorage
and can evolve are potential candidates to be versioned entities. For instance, in the case of collectionsHoppCollection
, they are persisted inlocalStorage
under the personal workspace and can evolve with new field additions. #3505 added support for specifying authorization/headers at the collection level which introduced the same as a versioned entity. For entities like collections, environments, etc which can be imported/exported, importing a file conforming to an older version of the versioned entity is migrated to the latest version before consumption.
Data persisted under localStorage
is migrated at the persistence service. Few methods are exposed on the versioned entity, safeParse
being the most commonly used for validation/migration.
const result = HoppRESTRequest.safeParse(data)
if (result.type === "ok") {
// Incoming data migrated to the latest version
return result.value
}
// Validation failed, handle error.
The schemas conforming to different versions for an entity can be found under the v
directory. For instance, different schemas corresponding to HoppRESTRequest
can be found here.
Versioned entity definitions are kept in dedicated directories under the @hoppscotch/data
package. An entity is created via the createVersionedEntitiy
function from verzod
, supplying the latest version latestVersion
, a map of the various schemas versionMap
and a definition for the getVersion()
method.
The corresponding schema to validate against the incoming data is determined via the definition supplied for the getVersion
method above. The v
field is taken into account in such a case.
An example corresponding to HoppRESTRequest
is given below.
hoppscotch/packages/hoppscotch-data/src/rest/index.ts
Lines 53 to 74 in 2917d50
export const HoppRESTRequest = createVersionedEntity({ | |
latestVersion: 5, | |
versionMap: { | |
0: V0_VERSION, | |
1: V1_VERSION, | |
2: V2_VERSION, | |
3: V3_VERSION, | |
4: V4_VERSION, | |
5: V5_VERSION, | |
}, | |
getVersion(data) { | |
// For V1 onwards we have the v string storing the number | |
const versionCheck = versionedObject.safeParse(data) | |
if (versionCheck.success) return versionCheck.data.v | |
// For V0 we have to check the schema | |
const result = V0_VERSION.schema.safeParse(data) | |
return result.success ? 0 : null | |
}, | |
}) |
Please take a look at the verzod documentation for more context regarding the usage
Proposal
The following are potential candidates to be made versioned entities.
- Global environment - Done in refactor: make global environment a versioned entity #4216.
#3779 added support for secret environment variables with a new secret
field. Given below is the current schema for data maintained under localStorage
.
hoppscotch/packages/hoppscotch-common/src/services/persistence/validation-schemas/index.ts
Lines 235 to 249 in 2917d50
const EnvironmentVariablesSchema = z.union([ | |
z.object({ | |
key: z.string(), | |
value: z.string(), | |
secret: z.literal(false).catch(false), | |
}), | |
z.object({ | |
key: z.string(), | |
secret: z.literal(true), | |
}), | |
z.object({ | |
key: z.string(), | |
value: z.string(), | |
}), | |
]) |
Below is a sample versionMap
definition with the expected schema variants.
const V0_SCHEMA = z.array(
z.union([
z.object({
key: z.string(),
value: z.string(),
secret: z.literal(false),
}),
z.object({
key: z.string(),
secret: z.literal(true),
}),
z.object({
key: z.string(),
value: z.string(),
}),
])
)
const V1_SCHEMA = z.object({
v: z.literal(1),
variables: z.array(
z.union([
z.object({
key: z.string(),
secret: z.literal(true),
}),
z.object({
key: z.string(),
value: z.string(),
secret: z.literal(false),
}),
])
),
})
- Settings
Given below is the expected schema. The v0
schema shouldn't have a v
field similar to global-environment
above to conform with the existing shape. There isn't a need for a v1
schema entry and can be added down the line with new field additions, although could optionally add it in with the v
field addition alongside relevant migrations being made a versioned entity.
hoppscotch/packages/hoppscotch-common/src/services/persistence/validation-schemas/index.ts
Lines 28 to 71 in 2917d50
const SettingsDefSchema = z.object({ | |
syncCollections: z.boolean(), | |
syncHistory: z.boolean(), | |
syncEnvironments: z.boolean(), | |
PROXY_URL: z.string(), | |
CURRENT_INTERCEPTOR_ID: z.string(), | |
URL_EXCLUDES: z.object({ | |
auth: z.boolean(), | |
httpUser: z.boolean(), | |
httpPassword: z.boolean(), | |
bearerToken: z.boolean(), | |
oauth2Token: z.optional(z.boolean()), | |
}), | |
THEME_COLOR: ThemeColorSchema, | |
BG_COLOR: BgColorSchema, | |
TELEMETRY_ENABLED: z.boolean(), | |
EXPAND_NAVIGATION: z.boolean(), | |
SIDEBAR: z.boolean(), | |
SIDEBAR_ON_LEFT: z.boolean(), | |
COLUMN_LAYOUT: z.boolean(), | |
WRAP_LINES: z.optional( | |
z.object({ | |
httpRequestBody: z.boolean().catch(true), | |
httpResponseBody: z.boolean().catch(true), | |
httpHeaders: z.boolean().catch(true), | |
httpParams: z.boolean().catch(true), | |
httpUrlEncoded: z.boolean().catch(true), | |
httpPreRequest: z.boolean().catch(true), | |
httpTest: z.boolean().catch(true), | |
httpRequestVariables: z.boolean().catch(true), | |
graphqlQuery: z.boolean().catch(true), | |
graphqlResponseBody: z.boolean().catch(true), | |
graphqlHeaders: z.boolean().catch(false), | |
graphqlVariables: z.boolean().catch(false), | |
graphqlSchema: z.boolean().catch(true), | |
importCurl: z.boolean().catch(true), | |
codeGen: z.boolean().catch(true), | |
cookie: z.boolean().catch(true), | |
}) | |
), | |
HAS_OPENED_SPOTLIGHT: z.optional(z.boolean()), | |
}) |
hoppscotch/packages/hoppscotch-common/src/services/persistence/validation-schemas/index.ts
Lines 129 to 132 in 2917d50
export const SETTINGS_SCHEMA = SettingsDefSchema.extend({ | |
EXTENSIONS_ENABLED: z.optional(z.boolean()), | |
PROXY_ENABLED: z.optional(z.boolean()), | |
}) |
Consumed in the persistence service where the data read from localStorage
is validated and necessary migrations are to be applied via the versioned entity.
hoppscotch/packages/hoppscotch-common/src/services/persistence/index.ts
Lines 246 to 266 in 2917d50
private setupSettingsPersistence() { | |
const settingsKey = "settings" | |
let settingsData = JSON.parse( | |
window.localStorage.getItem(settingsKey) ?? "null" | |
) | |
if (!settingsData) { | |
settingsData = getDefaultSettings() | |
} | |
// Validate data read from localStorage | |
const result = SETTINGS_SCHEMA.safeParse(settingsData) | |
if (result.success) { | |
settingsData = result.data | |
} else { | |
this.showErrorToast(settingsKey) | |
window.localStorage.setItem( | |
`${settingsKey}-backup`, | |
JSON.stringify(settingsData) | |
) | |
} |
- History (REST/GQL) variants
Given below are the expected schemas (v1
). It can have different variants REST/GQL
similar to the case of requests (HoppRESTRequest
/ HoppGQLRequest
).
hoppscotch/packages/hoppscotch-common/src/services/persistence/validation-schemas/index.ts
Lines 134 to 161 in 2917d50
export const REST_HISTORY_ENTRY_SCHEMA = z | |
.object({ | |
v: z.number(), | |
//! Versioned entity | |
request: HoppRESTRequestSchema, | |
responseMeta: z | |
.object({ | |
duration: z.nullable(z.number()), | |
statusCode: z.nullable(z.number()), | |
}) | |
.strict(), | |
star: z.boolean(), | |
id: z.optional(z.string()), | |
updatedOn: z.optional(z.union([z.date(), z.string()])), | |
}) | |
.strict() | |
export const GQL_HISTORY_ENTRY_SCHEMA = z | |
.object({ | |
v: z.number(), | |
//! Versioned entity | |
request: HoppGQLRequestSchema, | |
response: z.string(), | |
star: z.boolean(), | |
id: z.optional(z.string()), | |
updatedOn: z.optional(z.union([z.date(), z.string()])), | |
}) | |
.strict() |
Consumed in the persistence service where the data read from localStorage
is validated and necessary migrations are to be applied via the versioned entity.
hoppscotch/packages/hoppscotch-common/src/services/persistence/index.ts
Lines 281 to 319 in 2917d50
private setupHistoryPersistence() { | |
const restHistoryKey = "history" | |
let restHistoryData = JSON.parse( | |
window.localStorage.getItem(restHistoryKey) || "[]" | |
) | |
const graphqlHistoryKey = "graphqlHistory" | |
let graphqlHistoryData = JSON.parse( | |
window.localStorage.getItem(graphqlHistoryKey) || "[]" | |
) | |
// Validate data read from localStorage | |
const restHistorySchemaParsedresult = z | |
.array(REST_HISTORY_ENTRY_SCHEMA) | |
.safeParse(restHistoryData) | |
if (restHistorySchemaParsedresult.success) { | |
restHistoryData = restHistorySchemaParsedresult.data | |
} else { | |
this.showErrorToast(restHistoryKey) | |
window.localStorage.setItem( | |
`${restHistoryKey}-backup`, | |
JSON.stringify(restHistoryData) | |
) | |
} | |
const gqlHistorySchemaParsedresult = z | |
.array(GQL_HISTORY_ENTRY_SCHEMA) | |
.safeParse(graphqlHistoryData) | |
if (gqlHistorySchemaParsedresult.success) { | |
graphqlHistoryData = gqlHistorySchemaParsedresult.data | |
} else { | |
this.showErrorToast(graphqlHistoryKey) | |
window.localStorage.setItem( | |
`${graphqlHistoryKey}-backup`, | |
JSON.stringify(graphqlHistoryData) | |
) | |
} |
Notes
The setupLocalPersistence
method from the persistence service is invoked while the app boots up where data persisted in localStorage
is validated and necessary migrations are applied.
getService(PersistenceService).setupLocalPersistence() |
hoppscotch/packages/hoppscotch-common/src/services/persistence/index.ts
Lines 737 to 757 in 2917d50
public setupLocalPersistence() { | |
this.checkAndMigrateOldSettings() | |
this.setupLocalStatePersistence() | |
this.setupSettingsPersistence() | |
this.setupRESTTabsPersistence() | |
this.setupGQLTabsPersistence() | |
this.setupHistoryPersistence() | |
this.setupCollectionsPersistence() | |
this.setupGlobalEnvsPersistence() | |
this.setupEnvironmentsPersistence() | |
this.setupSelectedEnvPersistence() | |
this.setupWebsocketPersistence() | |
this.setupSocketIOPersistence() | |
this.setupSSEPersistence() | |
this.setupMQTTPersistence() | |
this.setupSecretEnvironmentsPersistence() | |
} |
Please ensure to follow the below convention:
- Definitions for global environment should be created under a
global-environment
directory withinpackages/hoppscotch-data/src
. - Definitions for settings should be created under a
settings
directory withinpackages/hoppscotch-data/src
. - For history, there should be a
history
directory underpackages/hoppscotch-data/src
with nested directories for rest & gql.