Skip to content

Update more public data structures to be versioned entities #4160

@jamesgeorge007

Description

@jamesgeorge007

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 collections HoppCollection, they are persisted in localStorage 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.

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.

#3779 added support for secret environment variables with a new secret field. Given below is the current schema for data maintained under localStorage.

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.

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()),
})

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.

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).

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.

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()

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 within packages/hoppscotch-data/src.
  • Definitions for settings should be created under a settings directory within packages/hoppscotch-data/src.
  • For history, there should be a history directory under packages/hoppscotch-data/src with nested directories for rest & gql.

Metadata

Metadata

Labels

CodeDayIssues and PRs associated with the CodeDay Labs partnershipfosshackRelegated issue for FOSS Hack 2024 Partner Projects Programmegood first issueGood for newcomers

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions