Skip to content

Support draft 2020 #157

@Zemnmez

Description

@Zemnmez

I have been trying to connect a zod-to-json-schema generated jsonschema up to Go via https://github.com/a-h/generate. When I generate, I get this error:

the JSON type 'array' cannot be converted into the Go 'Schema' type on struct 'Schema', field 'Properties.Properties.Properties.Items'. See input file bazel-out/darwin_arm64-fastbuild/bin/ts/twitter/json_schema.json line 82, character 1

The relevant lines generated:

            "coordinates": {
              "type": "array",
              "minItems": 2,
              "maxItems": 2,
              "items": [
                {
                  "type": "string"
                },
                {
                  "type": "string"
                }
              ]
            },

The corresponding zod type is:

/**
 * Schema for geographic coordinates.
 */
export const coordinatesSchema = z.strictObject({
  /**
   * An array of coordinates in [longitude, latitude] format.
   */
  coordinates: z.tuple([z.string(), z.string()]),
  /**
   * The type of geographic location, e.g., 'Point'.
   */
  type: z.literal("Point"),
});

As you can see, the 2-tuple of strings has been turned into an array with two items fields. The note in tuple validation describes "items" working as used by zod-to-json-schema only in the draft version of the spec (specifically draft 4).

I've put a jq script below that fixes the issue for me.

Full output JSON schema
{
  "type": "object",
  "properties": {
    "tweet": {
      "type": "object",
      "properties": {
        "edit_info": {
          "type": "object",
          "properties": {
            "initial": {
              "type": "object",
              "properties": {
                "editTweetIds": {
                  "type": "array",
                  "items": {
                    "type": "string"
                  }
                },
                "editableUntil": {
                  "type": "string",
                  "format": "date-time"
                },
                "editsRemaining": {
                  "type": "string"
                },
                "isEditEligible": {
                  "type": "boolean"
                }
              },
              "required": [
                "editTweetIds",
                "editableUntil",
                "editsRemaining",
                "isEditEligible"
              ],
              "additionalProperties": false
            }
          },
          "required": [
            "initial"
          ],
          "additionalProperties": false
        },
        "created_at": {
          "type": "string"
        },
        "id": {
          "type": "string"
        },
        "id_str": {
          "type": "string"
        },
        "full_text": {
          "type": "string"
        },
        "favorited": {
          "type": "boolean"
        },
        "favorite_count": {
          "type": "string"
        },
        "withheld_copyright": {
          "type": "boolean"
        },
        "withheld_in_countries": {
          "type": "array",
          "items": {
            "type": "string"
          }
        },
        "possibly_sensitive": {
          "type": "boolean"
        },
        "geo": {
          "type": "object",
          "properties": {
            "coordinates": {
              "type": "array",
              "minItems": 2,
              "maxItems": 2,
              "items": [
                {
                  "type": "string"
                },
                {
                  "type": "string"
                }
              ]
            },
            "type": {
              "type": "string",
              "const": "Point"
            }
          },
          "required": [
            "coordinates",
            "type"
          ],
          "additionalProperties": false
        },
        "retweeted": {
          "type": "boolean"
        },
        "lang": {
          "type": "string"
        },
        "extended_entities": {
          "type": "object",
          "properties": {
            "hashtags": {
              "type": "array",
              "items": {
                "type": "object",
                "properties": {
                  "text": {
                    "type": "string"
                  },
                  "indices": {
                    "type": "array",
                    "minItems": 2,
                    "maxItems": 2,
                    "items": [
                      {
                        "type": "string"
                      },
                      {
                        "type": "string"
                      }
                    ]
                  }
                },
                "required": [
                  "text"
                ],
                "additionalProperties": false
              }
            },
            "symbols": {
              "type": "array",
              "items": {
                "type": "object",
                "properties": {
                  "text": {
                    "type": "string"
                  },
                  "indices": {
                    "type": "array",
                    "minItems": 2,
                    "maxItems": 2,
                    "items": [
                      {
                        "type": "string"
                      },
                      {
                        "type": "string"
                      }
                    ]
                  }
                },
                "required": [
                  "text",
                  "indices"
                ],
                "additionalProperties": false
              }
            },
            "user_mentions": {
              "type": "array"
            },
            "urls": {
              "type": "array",
              "items": {
                "type": "object",
                "properties": {
                  "url": {
                    "type": "string"
                  },
                  "expanded_url": {
                    "type": "string"
                  },
                  "display_url": {
                    "type": "string"
                  },
                  "indices": {
                    "type": "array",
                    "minItems": 2,
                    "maxItems": 2,
                    "items": [
                      {
                        "type": "string"
                      },
                      {
                        "type": "string"
                      }
                    ]
                  }
                },
                "required": [
                  "url",
                  "expanded_url",
                  "display_url",
                  "indices"
                ],
                "additionalProperties": false
              }
            },
            "media": {
              "type": "array"
            },
            "polls": {
              "type": "array"
            }
          },
          "additionalProperties": false
        },
        "display_text_range": {
          "type": "array",
          "minItems": 2,
          "maxItems": 2,
          "items": [
            {
              "type": "string"
            },
            {
              "type": "string"
            }
          ]
        },
        "retweet_count": {
          "type": "string"
        },
        "source": {
          "type": "string"
        },
        "truncated": {
          "type": "boolean"
        },
        "in_reply_to_status_id": {
          "type": "string"
        },
        "in_reply_to_status_id_str": {
          "type": "string"
        },
        "in_reply_to_user_id": {
          "type": "string"
        },
        "in_reply_to_user_id_str": {
          "type": "string"
        },
        "in_reply_to_screen_name": {
          "type": "string"
        },
        "user": {
          "type": "object",
          "properties": {
            "id": {
              "type": "number"
            },
            "id_str": {
              "type": "string"
            },
            "name": {
              "type": "string"
            },
            "screen_name": {
              "type": "string"
            },
            "location": {
              "type": "string"
            },
            "url": {
              "type": "string"
            },
            "description": {
              "type": "string"
            },
            "verified": {
              "type": "boolean"
            },
            "followers_count": {
              "type": "number"
            },
            "friends_count": {
              "type": "number"
            },
            "listed_count": {
              "type": "number"
            },
            "favourites_count": {
              "type": "number"
            },
            "statuses_count": {
              "type": "number"
            },
            "created_at": {
              "type": "string"
            },
            "utc_offset": {
              "type": "number"
            },
            "time_zone": {
              "type": "string"
            },
            "geo_enabled": {
              "type": "boolean"
            },
            "lang": {
              "type": "string"
            },
            "contributors_enabled": {
              "type": "boolean"
            },
            "is_translator": {
              "type": "boolean"
            },
            "profile_image_url": {
              "type": "string"
            },
            "profile_image_url_https": {
              "type": "string"
            },
            "profile_banner_url": {
              "type": "string"
            },
            "default_profile": {
              "type": "boolean"
            },
            "default_profile_image": {
              "type": "boolean"
            },
            "following": {
              "type": "boolean"
            },
            "follow_request_sent": {
              "type": "boolean"
            },
            "notifications": {
              "type": "boolean"
            }
          },
          "required": [
            "id",
            "id_str",
            "name",
            "screen_name",
            "verified",
            "followers_count",
            "friends_count",
            "listed_count",
            "favourites_count",
            "statuses_count",
            "created_at",
            "geo_enabled",
            "contributors_enabled",
            "is_translator",
            "profile_image_url_https",
            "default_profile",
            "default_profile_image"
          ],
          "additionalProperties": false
        },
        "coordinates": {
          "$ref": "#/properties/tweet/properties/geo"
        },
        "place": {
          "type": "object",
          "properties": {
            "attributes": {
              "type": "object",
              "additionalProperties": {}
            },
            "bounding_box": {
              "type": "object",
              "properties": {
                "coordinates": {
                  "type": "array",
                  "items": {
                    "type": "array",
                    "items": {
                      "type": "array",
                      "minItems": 2,
                      "maxItems": 2,
                      "items": [
                        {
                          "type": "number"
                        },
                        {
                          "type": "number"
                        }
                      ]
                    }
                  }
                },
                "type": {
                  "type": "string"
                }
              },
              "required": [
                "coordinates",
                "type"
              ],
              "additionalProperties": false
            },
            "country": {
              "type": "string"
            },
            "country_code": {
              "type": "string"
            },
            "full_name": {
              "type": "string"
            },
            "id": {
              "type": "string"
            },
            "name": {
              "type": "string"
            },
            "place_type": {
              "type": "string"
            },
            "url": {
              "type": "string"
            }
          },
          "required": [
            "attributes",
            "bounding_box",
            "country",
            "country_code",
            "full_name",
            "id",
            "name",
            "place_type",
            "url"
          ],
          "additionalProperties": false
        },
        "entities": {
          "$ref": "#/properties/tweet/properties/extended_entities"
        }
      },
      "required": [
        "edit_info",
        "created_at",
        "id",
        "id_str",
        "full_text",
        "favorited",
        "source",
        "truncated",
        "entities"
      ],
      "additionalProperties": false
    }
  },
  "required": [
    "tweet"
  ],
  "additionalProperties": false,
  "$schema": "http://json-schema.org/draft-07/schema#"
}
Full Input Zod Schema
import { z } from "zod";

export const tweetId = z.string();
export const date = z.string().datetime();

/**
 * Schema for geographic coordinates.
 */
export const coordinatesSchema = z.strictObject({
  /**
   * An array of coordinates in [longitude, latitude] format.
   */
  coordinates: z.tuple([z.string(), z.string()]),
  /**
   * The type of geographic location, e.g., 'Point'.
   */
  type: z.literal("Point"),
});

/**
 * Schema for place information associated with a post.
 */
export const placeSchema = z.strictObject({
  /**
   * A record of additional attributes about the place.
   */
  attributes: z.record(z.any()),
  /**
   * The bounding box for the place, defining its coordinates.
   */
  bounding_box: z.strictObject({
    /**
     * An array of arrays defining the bounding box. Each sub-array contains [longitude, latitude].
     */
    coordinates: z.array(z.array(z.tuple([z.number(), z.number()]))),
    /**
     * Type of bounding box geometry, e.g., 'Polygon'.
     */
    type: z.string(),
  }),
  /**
   * The country name.
   */
  country: z.string(),
  /**
   * The ISO country code.
   */
  country_code: z.string(),
  /**
   * The full name of the place.
   */
  full_name: z.string(),
  /**
   * The unique identifier for the place.
   */
  id: z.string(),
  /**
   * The short name of the place.
   */
  name: z.string(),
  /**
   * The type of place, e.g., 'city'.
   */
  place_type: z.string(),
  /**
   * A URL providing more details about the place.
   */
  url: z.string(),
});

/**
 * Schema for user information.
 */
export const userSchema = z.strictObject({
  /**
   * The unique identifier for the user.
   */
  id: z.number(),
  /**
   * The string representation of the user ID.
   */
  id_str: z.string(),
  /**
   * The name of the user.
   */
  name: z.string(),
  /**
   * The screen name (handle) of the user.
   */
  screen_name: z.string(),
  /**
   * The user's location, if provided.
   */
  location: z.string().optional(),
  /**
   * The URL associated with the user, if provided.
   */
  url: z.string().optional(),
  /**
   * A short bio or description of the user.
   */
  description: z.string().optional(),
  /**
   * Indicates whether the user is verified.
   */
  verified: z.boolean(),
  /**
   * The number of followers the user has.
   */
  followers_count: z.number(),
  /**
   * The number of accounts the user is following.
   */
  friends_count: z.number(),
  /**
   * The number of public lists that include the user.
   */
  listed_count: z.number(),
  /**
   * The number of posts the user has liked.
   */
  favourites_count: z.number(),
  /**
   * The total number of posts the user has made.
   */
  statuses_count: z.number(),
  /**
   * The date and time when the user account was created.
   */
  created_at: z.string(),
  /**
   * The user's UTC offset, if available.
   */
  utc_offset: z.number().optional(),
  /**
   * The user's time zone, if available.
   */
  time_zone: z.string().optional(),
  /**
   * Indicates whether the user has enabled geotagging.
   */
  geo_enabled: z.boolean(),
  /**
   * The language of the user's interface, if specified (BCP 47 format).
   */
  lang: z.string().optional(),
  /**
   * Indicates if the account supports contributors.
   */
  contributors_enabled: z.boolean(),
  /**
   * Indicates whether the user is marked as a translator.
   */
  is_translator: z.boolean(),
  /**
   * The URL of the user's profile image.
   */
  profile_image_url: z.string().optional(),
  /**
   * The HTTPS URL of the user's profile image.
   */
  profile_image_url_https: z.string(),
  /**
   * The URL of the user's profile banner image, if available.
   */
  profile_banner_url: z.string().optional(),
  /**
   * Indicates whether the user uses the default profile.
   */
  default_profile: z.boolean(),
  /**
   * Indicates whether the user uses the default profile image.
   */
  default_profile_image: z.boolean(),
  /**
   * Indicates whether the authenticating user follows this user.
   */
  following: z.boolean().optional(),
  /**
   * Indicates whether a follow request has been sent to this user.
   */
  follow_request_sent: z.boolean().optional(),
  /**
   * Indicates whether the authenticating user has notifications enabled for this user.
   */
  notifications: z.boolean().optional(),
});

/**
 * Schema for entities extracted from post text.
 */
export const entitiesSchema = z.strictObject({
  /**
   * Array of hashtags included in the post.
   */
	hashtags: z.strictObject({
		text: z.string(),
		indices: z.tuple([
			z.string(),
			z.string()
		]).optional(),
  }).array().optional(),
  /**
   * Array of symbols mentioned in the post.
   */
	symbols: z.strictObject({
	  text: z.string(),
		indices: z.tuple([
			z.string(),
			z.string()
	  ])
  }).array().optional(),
  /**
   * Array of user mentions in the post.
   */
  user_mentions: z.array(z.any()).optional(),
  /**
   * Array of URLs included in the post.
   */
  urls: z.array(
    z.strictObject({
      /**
       * The URL as it appears in the post.
       */
      url: z.string(),
      /**
       * The fully resolved URL.
       */
      expanded_url: z.string(),
      /**
       * The shortened display version of the URL.
       */
      display_url: z.string(),
      /**
       * Start and end indices of the URL in the post text.
       */
      indices: z.tuple([z.string(), z.string()]),
    })
  ).optional(),
  /**
   * Array of media objects included in the post, if any.
   */
  media: z.array(z.any()).optional(),
  /**
   * Array of polls included in the post, if any.
   */
  polls: z.array(z.any()).optional(),
});

/**
 * Schema for a post object.
 */
export const postSchema = z.strictObject({
	edit_info: z.strictObject({
		initial: z.strictObject({
			editTweetIds: z.string().array(),
			editableUntil: date,
			editsRemaining: z.string(),
			isEditEligible: z.boolean()
		})
	}),
  /**
   * The UTC time when the post was created.
   */
  created_at: z.string(),
  /**
   * The unique identifier for the post (integer format).
   */
  id: z.string(),
  /**
   * The unique identifier for the post (string format).
   */
  id_str: z.string(),
  /**
   * The actual text content of the post.
   */
  full_text: z.string(),
  favorited: z.boolean(),
  favorite_count: z.string().optional(),
  /**
   * Whether the tweet was withheld in some
   * countries for copyright reasons.
   *
   * See withheld_in_countries.
   */
  withheld_copyright: z.boolean().optional(),
  withheld_in_countries: z.string().array().optional(),
  possibly_sensitive: z.boolean().optional(),
	geo: coordinatesSchema.optional(),
  retweeted: z.boolean().optional(),
  lang: z.string().optional(),
  extended_entities: entitiesSchema.optional(),
	display_text_range: z.tuple([
		z.string(),
		z.string()
  ]).optional(),
  retweet_count: z.string().optional(),
  /**
   * The source utility used to post the content.
   */
  source: z.string(),
  /**
   * Indicates if the text was truncated due to length limits.
   */
  truncated: z.boolean(),
  /**
   * The ID of the original post this post is replying to (integer format).
   */
  in_reply_to_status_id: z.string().optional(),
  /**
   * The ID of the original post this post is replying to (string format).
   */
  in_reply_to_status_id_str: z.string().optional(),
  /**
   * The ID of the user this post is replying to (integer format).
   */
  in_reply_to_user_id: z.string().optional(),
  /**
   * The ID of the user this post is replying to (string format).
   */
  in_reply_to_user_id_str: z.string().optional(),
  /**
   * The screen name of the user this post is replying to.
   */
  in_reply_to_screen_name: z.string().optional(),
  /**
   * The user who posted this content.
   */
  user: userSchema.optional(),
  /**
   * Geographic location of the post, if available.
   */
  coordinates: coordinatesSchema.optional(),
  /**
   * Place information associated with the post.
   */
  place: placeSchema.optional(),
  /**
   * Entities parsed from the post text.
   */
  entities: entitiesSchema,
});

export const archivedTweetSchema = z.strictObject({
	tweet: postSchema
})
jq script to fix
# Define a recursive function to process JSON schemas.
# This function transforms schemas with `items` arrays (used for tuple validation in older drafts)
# into schemas using `prefixItems`, which is the modern approach for tuple validation.

def process_schema:
  # Bind the current schema to $schema for ease of reference
  . as $schema
  | if type == "object" then
      # Iterate over all keys in the current schema
      reduce keys[] as $key (
        .;
        # Recursively process each value in the schema
        .[$key] = (.[$key] | process_schema)
      )
      # Check if the current schema is an array type
      # and if `items` is defined as an array (indicating tuple validation in older drafts)
      | if .type == "array" and (.items | type == "array") then
          # Replace `items` with `prefixItems` for compatibility with modern drafts
          .prefixItems = .items
          # Remove the legacy `items` field
          | del(.items)
        else
          # Leave the schema unchanged if no transformation is needed
          .
        end
    elif type == "array" then
      # If the current schema is an array of schemas, recursively process each element
      map(process_schema)
    else
      # For all other types, return the schema unchanged
      .
    end;

# Apply the transformation function to the root schema
process_schema

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions