8000 feat(store): new OpenAPI-based client store by hanspagel · Pull Request #5529 · scalar/scalar · GitHub
[go: up one dir, main page]
More Web Proxy on the site http://driver.im/
Skip to content

feat(store): new OpenAPI-based client store #5529

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 101 commits into
8000 base: main
Choose a base branch
from
Draft

feat(store): new OpenAPI-based client store #5529

wants to merge 101 commits into from

Conversation

hanspagel
Copy link
Member
@hanspagel hanspagel commented Apr 30, 2025

Problem

We’re using a reactive data store (mostly in @scalar/api-client), which helped us to learn a lot. We hit a few limitations, though:

  • Updates to the document (with multi documents, watch mode and other features) isn’t easy
  • Exporting data isn’t easy (we lose the structure of the original document)
  • All the heavy work is done before rendering, which adds a waiting time for big documents before rendering (not an issue for the client, but for the reference)
  • Rendering can become tricky, as we need to take the whole workspace structure into account everytime we interact with the store (e.g. showing all servers requires you to filter for the ones for the “active” collection).

Solution

With this PR we’re introducing a new, fully OpenAPI-based reactive data store. :) Here are the main ideas:

  • It’s resolving $ref’s on the fly (not all at once)
  • The data structure is 1:1 the OpenAPI 3.1 specification, exporting is easy now and updating is easy, too (no UUIDs)
  • It has a few additional cache layers to speed things up
  • You only need to interact with the whole workspace where you need it (e.g. to select a document), everywhere else it’s just the one collection that’s selected.

Usage

A whole workspace:

import { createWorkspace, localStoragePlugin } from '@scalar/store'

// Create a workspace with localStorage persistence
const workspace = createWorkspace({
  plugins: [
    localStoragePlugin(),
  ]
})

// Load data asynchronously
await workspace.load('myCollection', async () => {
  const response = await fetch('https://example.com/openapi.json')

  return response.json()
})

A workspace has a collection for every document. And this how a single collection works:

import { createCollection } from '@scalar/store'

// Create a collection from an OpenAPI document containing `$ref`s
const collection = createCollection({
  openapi: '3.1.1',
  info: {
    title: 'Hello World',
    version: '1.0.0',
  },
  components: {
    schemas: {
      Person: {
        type: 'object',
        properties: {
          name: { type: 'string' },
        },
      },
      User: {
        $ref: '#/components/schemas/Person',
      },
    },
  },
})

// Access the data without caring about $ref's
console.log(collection.document.components.schemas.User)

// Output: { type: 'object', properties: { name: { type: 'string' } } }

Performance

  • benchmark for Stripe
  • fetch all top-level keys, but paths
    • "paginate" through paths
  • test editor performance
  • fix reactive proxy bottleneck
  • test update/merge perf (just replace the area that has changed)
  • start with just a reactive proxy, add the magic proxy later?

Tasks/Ideas

Not all are necessary to get the store in. We need to identify the critical ones.

  • createMagicProxy doesn’t work inside arrays!!
  • load should not just allow async functions, but also regular functions or even just the value
  • meta data for workspaces (theme or something)
  • meta data for collections
  • remove @scalar/openapi-parser dependency? it’s just there for upgrading
  • remove @scalar/openapi-types dependency? it’s just there for the Zod schema, which is too strict anyway
  • watch Ref’s
  • add an IndexDB plugin (use idb?), we change the data source anyway, so it might be worth to introduce it now
  • add more hooks, and:
  • add a plugin to get performance metrics
  • add a logging plugin (for the browser console)
  • write a test that loads up 100 real-world examples to verify
    • we’re fast (requires the perf plugin)
    • they all pass, nothing gets lost
  • workspace.create('myCollection', data) (data can be a string, object, ref, function, (async) function )
  • workspace.delete(myCollection)
  • manually update a collection: workspace.update(myCollection, newValue)
  • persist changes (currently, we only persist the initial state)
  • find a way to run the old store and the helper/parse.ts in parallel
  • proof of concept for migrating @scalar/api-reference
  • add “views” (a computed()) on top of the data store: to retrieve all tags, with a default tag, with tag groups, tagsSorter …
  • the OpenAPI Zod schemas are a 1:1 translation of the specification, but too strict for real-world documents, how do we deal with that? copy the schemas and add optional() and catch() everywhere?
  • We may want a createWorkspace({ readOnly: true })
  • do we need/want explicit mutations again?
  • what’s the migration strategy for the existing data? fun fact: to properly migrate we need an export for the old store. which was the initial problem we tried to solve with the new store. even if we don’t migrate, we need to communicate the change.
  • write awesome docs, so we all have place to learn about the features before implementing it
  • When a collection is imported asynchronously, we should already add a collection with a loading state or something, so we can render it already.
  • While we’re at it, should we add super basic support for other formats like AsyncAPI? (no validation or such, just keeping it in mind for the structure of the store)
  • move the code to more atomic files
  • make all types are good and strict (no any, no @ts-expect-error, no type-casting)

From the feedback:

  • we should have tests for full reactivity for live sync, handle the whole spec + some conflict resolution (already works apparently 🥳)
  • figure out how we want to do additional data like auth
  • figure out if we want to support overlays
  • explore how it would look with client lite + openapi blocks
  • lets do indexdb from the beginning instead of local storage, so we don't hit the same limitations
  • okay so we probably don't want to do this, make it immutable and use mutators instead
  • We probably don't want to allow just any private _properties. For the auth stuff for sure OR we just use an overlay to store that kind of info. Open to discussions on this one

Not sure, but I think we said we have to (= it’s required) to:

  • rewrite the sidebar to the new data structure (harder than it might sound)

Super early ideas:

  • createPartialCollection<Operation>({ summary: 'This is the same reactive collection, but just for a specific OpenAPI object' }) (helps with the transition? might be cool for Scalar Blocks?)
  • the config object should just be overlays, like if config.servers exists then that is overlaid over the spec.servers and we use those instead. if the config.servers are removed, it just goes back to using spec servers :)

Checklist

I’ve gone through the following:

  • I’ve added an explanation why this change is needed.
  • I’ve added a changeset (pnpm changeset).
  • I’ve added tests for the regression or new feature.
  • I’ve updated the documentation.

Copy link
changeset-bot bot commented Apr 30, 2025

⚠️ No Changeset found

Latest commit: ff8f93b

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@hanspagel hanspagel changed the title Feat/store feat(store): new OpenAPI-based reactive store Apr 30, 2025
Copy link
relativeci bot commented Apr 30, 2025

#9780 Bundle Size — 2.58MiB (+5.31%).

332a7fc(current) vs ab0d3a3 main#9775(baseline)

Important

Bundle introduced 2 duplicate packages – View changed duplicate packages

Bundle metrics  Change 4 changes Regression 3 regressions
                 Current
#9780
     Baseline
#9775
Regression  Initial JS 2.58MiB(+5.31%) 2.45MiB
No change  Initial CSS 0B 0B
No change  Cache Invalidation 100% 100%
No change  Chunks 1 1
No change  Assets 1 1
Change  Modules 1498(+0.27%) 1494
No change  Duplicate Modules 0 0
No change  Duplicate Code 0% 0%
Regression  Packages 174(+1.16%) 172
Regression  Duplicate Packages 8(+33.33%) 6
Bundle size by type  Change 1 change Regression 1 regression
                 Current
#9780
     Baseline
#9775
Regression  JS 2.58MiB (+5.31%) 2.45MiB

Bundle analysis reportBranch feat/storeProject dashboard


Generated by RelativeCIDocumentationReport issue

Copy link
Contributor
github-actions bot commented Apr 30, 2025

Cloudflare Preview for the API Client

https://0078a77c.client-staging-7m0.pages.dev

Copy link
Contributor
github-actions bot commented Apr 30, 2025

@amritk
Copy link
Member
amritk commented Apr 30, 2025

new store whooooooooooooooooooooooooo

Copy link
Member
@amritk amritk left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Didn't go too deep but glanced over and left some comments. Pretty sweet!. We should iron a couple things out.

  • we should have tests for full reactivity for live sync, handle the whole spec + some conflict resolution already works apparently 🥳
  • figure out how we want to do additional data like auth
  • figure out if we want to support overlays
  • explore how it would look with client lite + openapi blocks
  • lets do indexdb from the beginning instead of local storage, so we don't hit the same limitations


// Both paths access the same data
const person = collection.document.components.schemas.Person
const user = collection.document.components.schemas.User
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Super cool

const user = collection.document.components.schemas.User

// Changes through either path update the source
user.properties.age = { type: 'number' }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

okay so we probably don't want to do this, make it immutable and use mutators instead

},
})

// Vue reactivity works through references
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

love it!

})

// Prefix temporary properties with _ and they won’t be exported.
collection.document.components.schemas.Person._selected = true
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We probably don't want to allow just any private properties. For the auth stuff for sure OR we just use an overlay to store that kind of info. Open to discussions on this one

localStoragePlugin(),

// Or specify a custom storage key (default: 'state')
localStoragePlugin({ key: 'my-state' })
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yea we should do this regardless, our own apps might start conflicting too

8000
if (isRef(content)) {
workspace.state.collections[collectionId] = createCollection(content)
} else {
Object.assign(workspace.state.collections[collectionId], content)

Check warning

Code scanning / CodeQL

Prototype-polluting assignment Medium

This assignment may alter Object.prototype if a malicious '__proto__' string is injected from
library input
.

Copilot Autofix

AI 11 days ago

To fix the issue, we need to prevent prototype pollution by ensuring that collectionId cannot be a dangerous value like __proto__, constructor, or prototype. This can be achieved by validating collectionId before using it as a key in workspace.state.collections. If collectionId matches any of these reserved property names, we should throw an error or handle it appropriately.

The best approach is to add a validation step at the beginning of the load method to reject unsafe keys. This ensures that the rest of the method operates on safe input.


Suggested changeset 1
packages/store/src/create-workspace.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/packages/store/src/create-workspace.ts b/packages/store/src/create-workspace.ts
--- a/packages/store/src/create-workspace.ts
+++ b/packages/store/src/create-workspace.ts
@@ -45,2 +45,7 @@
     ) {
+      // Validate collectionId to prevent prototype pollution
+      if (['__proto__', 'constructor', 'prototype'].includes(collectionId)) {
+        throw new Error(`Invalid collectionId: "${collectionId}" is not allowed.`);
+      }
+
       const content =
@@ -48,4 +53,2 @@
 
-      // TODO: Validate content
-
       if (typeof workspace.state.collections[collectionId] === 'undefined') {
EOF
@@ -45,2 +45,7 @@
) {
// Validate collectionId to prevent prototype pollution
if (['__proto__', 'constructor', 'prototype'].includes(collectionId)) {
throw new Error(`Invalid collectionId: "${collectionId}" is not allowed.`);
}

const content =
@@ -48,4 +53,2 @@

// TODO: Validate content

if (typeof workspace.state.collections[collectionId] === 'undefined') {
Copilot is powered by AI and may make mistakes. Always verify output.
@hanspagel hanspagel self-assigned this May 7, 2025
@hanspagel hanspagel changed the title feat(store): new OpenAPI-based reactive store feat(store): new OpenAPI-based client store May 13, 2025
}
}
} else if (action.update && typeof target === 'object' && target !== null) {
Object.assign(target, action.update)

Check warning

Code scanning / CodeQL

Prototype-polluting assignment Medium

This assignment may alter Object.prototype if a malicious '__proto__' string is injected from
library input
.

Copilot Autofix

AI 2 days ago

To fix the issue, we need to ensure that the Object.assign operation on line 451 does not allow prototype-polluting keys like __proto__, constructor, or prototype to be added to the target object. This can be achieved by validating and filtering the keys in action.update before applying them to target. Specifically, we can use a utility function to sanitize the action.update object by removing any prototype-polluting keys.

The fix involves:

  1. Adding a utility function sanitizeObject to filter out dangerous keys (__proto__, constructor, prototype) from an object.
  2. Using this utility function to sanitize action.update before calling Object.assign.
Suggested changeset 1
packages/store/src/create-collection.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/packages/store/src/create-collection.ts b/packages/store/src/create-collection.ts
--- a/packages/store/src/create-collection.ts
+++ b/packages/store/src/create-collection.ts
@@ -414,2 +414,12 @@
  */
+function sanitizeObject(obj: UnknownObject): UnknownObject {
+  const dangerousKeys = ['__proto__', 'constructor', 'prototype'];
+  return Object.keys(obj).reduce((sanitized, key) => {
+    if (!dangerousKeys.includes(key)) {
+      sanitized[key] = obj[key];
+    }
+    return sanitized;
+  }, {} as UnknownObject);
+}
+
 function applyOverlay(document: UnknownObject, overlay: UnknownObject) {
@@ -450,3 +460,4 @@
       } else if (action.update && typeof target === 'object' && target !== null) {
-        Object.assign(target, action.update)
+        const sanitizedUpdate = sanitizeObject(action.update);
+        Object.assign(target, sanitizedUpdate);
       }
EOF
@@ -414,2 +414,12 @@
*/
function sanitizeObject(obj: UnknownObject): UnknownObject {
const dangerousKeys = ['__proto__', 'constructor', 'prototype'];
return Object.keys(obj).reduce((sanitized, key) => {
if (!dangerousKeys.includes(key)) {
sanitized[key] = obj[key];
}
return sanitized;
}, {} as UnknownObject);
}

function applyOverlay(document: UnknownObject, overlay: UnknownObject) {
@@ -450,3 +460,4 @@
} else if (action.update && typeof target === 'object' && target !== null) {
Object.assign(target, action.update)
const sanitizedUpdate = sanitizeObject(action.update);
Object.assign(target, sanitizedUpdate);
}
Copilot is powered by AI and may make mistakes. Always verify output.
target[key] = { ...sourceValue }
} else {
// For primitive values, just assign
target[key] = sourceValue

Check warning

Code scanning / CodeQL

Prototype-polluting function Medium

Properties are copied from
source
to
target
without guarding against prototype pollution.

Copilot Autofix

AI 2 days ago

To fix the issue, we will add safeguards to the mergeDocuments function to prevent prototype pollution. Specifically:

  1. Block the special keys __proto__ and constructor from being merged into the target object.
  2. Ensure that only own properties of the source object are processed during the merge.

This fix will involve:

  • Adding a check to skip keys that are __proto__ or constructor.
  • Using Object.prototype.hasOwnProperty.call(source, key) to ensure that only own properties of source are considered.

Suggested changeset 1
packages/store/src/create-collection.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/packages/store/src/create-collection.ts b/packages/store/src/create-collection.ts
--- a/packages/store/src/create-collection.ts
+++ b/packages/store/src/create-collection.ts
@@ -592,2 +592,12 @@
   for (const key in source) {
+    // Skip prototype-polluting keys
+    if (key === "__proto__" || key === "constructor") {
+      continue
+    }
+
+    // Ensure the key is an own property of the source object
+    if (!Object.prototype.hasOwnProperty.call(source, key)) {
+      continue
+    }
+
     const sourceValue = source[key]
EOF
@@ -592,2 +592,12 @@
for (const key in source) {
// Skip prototype-polluting keys
if (key === "__proto__" || key === "constructor") {
continue
}

// Ensure the key is an own property of the source object
if (!Object.prototype.hasOwnProperty.call(source, key)) {
continue
}

const sourceValue = source[key]
Copilot is powered by AI and may make mistakes. Always verify output.
target[key] = { ...sourceValue }
} else {
// For primitive values, just assign
target[key] = sourceValue

Check warning

Code scanning / CodeQL

Prototype-polluting function Medium

Properties are copied from
source
to
target
without guarding against prototype pollution.

Copilot Autofix

AI 2 days ago

To fix the issue, we will:

  1. Block the special keys __proto__ and constructor from being copied from source to target. This prevents prototype pollution.
  2. Ensure that only own properties of the source object are copied to the target object. This avoids copying inherited properties that could lead to unexpected behavior.

The changes will be made in the mergeDocuments function, specifically in the loop that iterates over the keys of the source object.


Suggested changeset 1
packages/store/src/slow/create-collection.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/packages/store/src/slow/create-collection.ts b/packages/store/src/slow/create-collection.ts
--- a/packages/store/src/slow/create-collection.ts
+++ b/packages/store/src/slow/create-collection.ts
@@ -592,2 +592,7 @@
   for (const key in source) {
+    // Skip inherited properties and prototype-polluting keys
+    if (!Object.prototype.hasOwnProperty.call(source, key) || key === "__proto__" || key === "constructor") {
+      continue
+    }
+
     const sourceValue = source[key]
EOF
@@ -592,2 +592,7 @@
for (const key in source) {
// Skip inherited properties and prototype-polluting keys
if (!Object.prototype.hasOwnProperty.call(source, key) || key === "__proto__" || key === "constructor") {
continue
}

const sourceValue = source[key]
Copilot is powered by AI and may make mistakes. Always verify output.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants
0