diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 3e588d9..3c9703b 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -27,5 +27,11 @@ jobs: - name: Build project run: npm run build + - name: Generate OpenAPI schema + run: npm run generate + + - name: Validate OpenAPI schema + run: npm run validate + - name: Run tests run: npm test diff --git a/dist/schema.json b/dist/schema.json index 0a8fc46..ba77098 100644 --- a/dist/schema.json +++ b/dist/schema.json @@ -4630,11 +4630,13 @@ }, "created_at": { "description": "When the account was created.", - "$ref": "#/components/schemas/Datetime" + "type": "string", + "format": "date-time" }, "last_status_at": { "description": "When the most recent status was posted.", - "$ref": "#/components/schemas/Date" + "type": "string", + "format": "date-time" }, "statuses_count": { "description": "How many statuses are attached to this account.", @@ -4679,6 +4681,200 @@ "hide_collections" ] }, + "CredentialAccount": { + "type": "object", + "description": "Additional entity definition for CredentialAccount", + "properties": { + "source": { + "description": "An extra attribute that contains source values to be used with API methods that [verify credentials]({{< relref \"methods/accounts#verify_credentials\" >}}) and [update credentials]({{< relref \"methods/accounts#update_credentials\" >}}).", + "type": "object" + }, + "source[attribution_domains]": { + "description": "Domains of websites allowed to credit the account.", + "type": "array", + "items": { + "type": "string" + } + }, + "source[note]": { + "description": "Profile bio, in plain-text instead of in HTML.", + "type": "string" + }, + "source[fields]": { + "description": "Metadata about the account.", + "type": "array", + "items": { + "$ref": "#/components/schemas/Field" + } + }, + "source[privacy]": { + "description": "The default post privacy to be used for new statuses.", + "type": "string" + }, + "source[sensitive]": { + "description": "Whether new statuses should be marked sensitive by default.", + "type": "boolean" + }, + "source[language]": { + "description": "The default posting language for new statuses.", + "type": "string" + }, + "source[follow_requests_count]": { + "description": "The number of pending follow requests.", + "type": "integer" + }, + "role": { + "description": "The role assigned to the currently authorized user.", + "$ref": "#/components/schemas/Role" + } + }, + "required": [ + "source", + "source[attribution_domains]", + "source[note]", + "source[fields]", + "source[privacy]", + "source[sensitive]", + "source[language]", + "source[follow_requests_count]", + "role" + ] + }, + "MutedAccount": { + "type": "object", + "description": "Additional entity definition for MutedAccount", + "properties": { + "mute_expires_at": { + "description": "When a timed mute will expire, if applicable.", + "type": "string", + "format": "date-time" + } + }, + "required": [ + "mute_expires_at" + ] + }, + "Field": { + "type": "object", + "description": "Additional entity definition for Field", + "properties": { + "name": { + "description": "The key of a given field's key-value pair.", + "type": "string" + }, + "value": { + "description": "The value associated with the `name` key.", + "type": "string" + }, + "verified_at": { + "description": "Timestamp of when the server verified a URL value for a rel=\"me\" link.", + "type": "string", + "format": "uri" + } + }, + "required": [ + "name", + "value", + "verified_at" + ] + }, + "CredentialAccount entity": { + "type": "object", + "description": "Additional entity definition for CredentialAccount entity", + "properties": { + "source": { + "description": "An extra attribute that contains source values to be used with API methods that [verify credentials]({{< relref \"methods/accounts#verify_credentials\" >}}) and [update credentials]({{< relref \"methods/accounts#update_credentials\" >}}).", + "type": "object" + }, + "source[attribution_domains]": { + "description": "Domains of websites allowed to credit the account.", + "type": "array", + "items": { + "type": "string" + } + }, + "source[note]": { + "description": "Profile bio, in plain-text instead of in HTML.", + "type": "string" + }, + "source[fields]": { + "description": "Metadata about the account.", + "type": "array", + "items": { + "$ref": "#/components/schemas/Field" + } + }, + "source[privacy]": { + "description": "The default post privacy to be used for new statuses.", + "type": "string" + }, + "source[sensitive]": { + "description": "Whether new statuses should be marked sensitive by default.", + "type": "boolean" + }, + "source[language]": { + "description": "The default posting language for new statuses.", + "type": "string" + }, + "source[follow_requests_count]": { + "description": "The number of pending follow requests.", + "type": "integer" + }, + "role": { + "description": "The role assigned to the currently authorized user.", + "$ref": "#/components/schemas/Role" + } + }, + "required": [ + "source", + "source[attribution_domains]", + "source[note]", + "source[fields]", + "source[privacy]", + "source[sensitive]", + "source[language]", + "source[follow_requests_count]", + "role" + ] + }, + "MutedAccount entity": { + "type": "object", + "description": "Additional entity definition for MutedAccount entity", + "properties": { + "mute_expires_at": { + "description": "When a timed mute will expire, if applicable.", + "type": "string", + "format": "date-time" + } + }, + "required": [ + "mute_expires_at" + ] + }, + "Field entity": { + "type": "object", + "description": "Additional entity definition for Field entity", + "properties": { + "name": { + "description": "The key of a given field's key-value pair.", + "type": "string" + }, + "value": { + "description": "The value associated with the `name` key.", + "type": "string" + }, + "verified_at": { + "description": "Timestamp of when the server verified a URL value for a rel=\"me\" link.", + "type": "string", + "format": "uri" + } + }, + "required": [ + "name", + "value", + "verified_at" + ] + }, "AccountWarning": { "type": "object", "description": "Moderation warning against a particular account.", @@ -4712,7 +4908,8 @@ }, "created_at": { "description": "When the event took place.", - "$ref": "#/components/schemas/Datetime" + "type": "string", + "format": "date-time" } }, "required": [ @@ -4743,7 +4940,8 @@ }, "created_at": { "description": "When the account was first discovered.", - "$ref": "#/components/schemas/Datetime" + "type": "string", + "format": "date-time" }, "email": { "description": "The email address associated with the account.", @@ -4848,7 +5046,8 @@ "properties": { "period": { "description": "The timestamp for the start of the period, at midnight.", - "$ref": "#/components/schemas/Datetime" + "type": "string", + "format": "date-time" }, "frequency": { "description": "The size of the bucket for the returned data.", @@ -4868,6 +5067,54 @@ "data" ] }, + "CohortData": { + "type": "object", + "description": "Additional entity definition for CohortData", + "properties": { + "date": { + "description": "The timestamp for the start of the bucket, at midnight.", + "type": "string", + "format": "date-time" + }, + "rate": { + "description": "The percentage rate of users who registered in the specified `period` and were active for the given `date` bucket.", + "type": "number" + }, + "value": { + "description": "How many users registered in the specified `period` and were active for the given `date` bucket.", + "type": "string" + } + }, + "required": [ + "date", + "rate", + "value" + ] + }, + "CohortData entity": { + "type": "object", + "description": "Additional entity definition for CohortData entity", + "properties": { + "date": { + "description": "The timestamp for the start of the bucket, at midnight.", + "type": "string", + "format": "date-time" + }, + "rate": { + "description": "The percentage rate of users who registered in the specified `period` and were active for the given `date` bucket.", + "type": "number" + }, + "value": { + "description": "How many users registered in the specified `period` and were active for the given `date` bucket.", + "type": "string" + } + }, + "required": [ + "date", + "rate", + "value" + ] + }, "Admin::Dimension": { "type": "object", "description": "Represents qualitative data about the server.", @@ -4903,7 +5150,8 @@ }, "created_at": { "description": "When the domain was allowed to federate.", - "$ref": "#/components/schemas/Datetime" + "type": "string", + "format": "date-time" } }, "required": [ @@ -4930,7 +5178,8 @@ }, "created_at": { "description": "When the domain was blocked from federating.", - "$ref": "#/components/schemas/Datetime" + "type": "string", + "format": "date-time" }, "severity": { "description": "The policy to be applied by this domain block.", @@ -4984,7 +5233,8 @@ }, "created_at": { "description": "When the email domain was disallowed from signups.", - "$ref": "#/components/schemas/Datetime" + "type": "string", + "format": "date-time" }, "history": { "description": "Usage statistics for given days (typically the past week).", @@ -5026,7 +5276,8 @@ }, "used_at": { "description": "The timestamp of when the IP address was last used for this account.", - "$ref": "#/components/schemas/Datetime" + "type": "string", + "format": "date-time" } }, "required": [ @@ -5056,11 +5307,13 @@ }, "created_at": { "description": "When the IP block was created.", - "$ref": "#/components/schemas/Datetime" + "type": "string", + "format": "date-time" }, "expires_at": { "description": "When the IP block will expire.", - "$ref": "#/components/schemas/Datetime" + "type": "string", + "format": "date-time" } }, "required": [ @@ -5105,7 +5358,8 @@ }, "data[][date]": { "description": "Midnight on the requested day in the time period.", - "$ref": "#/components/schemas/Datetime" + "type": "string", + "format": "date-time" }, "data[][value]": { "description": "The numeric value for the requested measure.", @@ -5135,7 +5389,8 @@ }, "action_taken_at": { "description": "When an action was taken, if this report is currently resolved.", - "$ref": "#/components/schemas/Datetime" + "type": "string", + "format": "date-time" }, "category": { "description": "The category under which the report is classified.", @@ -5151,11 +5406,13 @@ }, "created_at": { "description": "The time the report was filed.", - "$ref": "#/components/schemas/Datetime" + "type": "string", + "format": "date-time" }, "updated_at": { "description": "The time of last action on this report.", - "$ref": "#/components/schemas/Datetime" + "type": "string", + "format": "date-time" }, "account": { "description": "The account which filed the report.", @@ -5219,11 +5476,13 @@ }, "starts_at": { "description": "When the announcement will start.", - "$ref": "#/components/schemas/Datetime" + "type": "string", + "format": "date-time" }, "ends_at": { "description": "When the announcement will end.", - "$ref": "#/components/schemas/Datetime" + "type": "string", + "format": "date-time" }, "published": { "description": "Whether the announcement is currently active.", @@ -5235,11 +5494,13 @@ }, "published_at": { "description": "When the announcement was published.", - "$ref": "#/components/schemas/Datetime" + "type": "string", + "format": "date-time" }, "updated_at": { "description": "When the announcement was last updated.", - "$ref": "#/components/schemas/Datetime" + "type": "string", + "format": "date-time" }, "read": { "description": "Whether the announcement has been read by the current user.", @@ -5297,6 +5558,54 @@ "reactions" ] }, + "Announcement::Account": { + "type": "object", + "description": "Additional entity definition for Announcement::Account", + "properties": { + "id": { + "description": "The account ID of the mentioned user.", + "type": "string" + }, + "username": { + "description": "The username of the mentioned user.", + "type": "string" + }, + "url": { + "description": "The location of the mentioned user's profile.", + "type": "string", + "format": "uri" + }, + "acct": { + "description": "The webfinger acct: URI of the mentioned user. Equivalent to `username` for local users, or `username@domain` for remote users.", + "type": "string" + } + }, + "required": [ + "id", + "username", + "url", + "acct" + ] + }, + "Announcement::Status": { + "type": "object", + "description": "Additional entity definition for Announcement::Status", + "properties": { + "id": { + "description": "The ID of an attached Status in the database.", + "type": "string" + }, + "url": { + "description": "The URL of an attached Status.", + "type": "string", + "format": "uri" + } + }, + "required": [ + "id", + "url" + ] + }, "Appeal": { "type": "object", "description": "Appeal against a moderation action.", @@ -5355,6 +5664,29 @@ "vapid_key" ] }, + "CredentialApplication": { + "type": "object", + "description": "Additional entity definition for CredentialApplication", + "properties": { + "client_id": { + "description": "Client ID key, to be used for obtaining OAuth tokens", + "type": "string" + }, + "client_secret": { + "description": "Client secret key, to be used for obtaining OAuth tokens", + "type": "string" + }, + "client_secret_expires_at": { + "description": "When the client secret key will expire at, presently this always returns `0` indicating that OAuth Clients do not expire", + "type": "string" + } + }, + "required": [ + "client_id", + "client_secret", + "client_secret_expires_at" + ] + }, "Context": { "type": "object", "description": "Represents the tree around a given status. Used for reconstructing threads of statuses.", @@ -5506,7 +5838,8 @@ }, "created_at": { "description": "A timestamp for when the message was created.", - "$ref": "#/components/schemas/Datetime" + "type": "string", + "format": "date-time" } }, "required": [ @@ -5543,7 +5876,8 @@ "properties": { "updated_at": { "description": "A timestamp of when the extended description was last updated.", - "$ref": "#/components/schemas/Datetime" + "type": "string", + "format": "date-time" }, "content": { "description": "The rendered HTML content of the extended description.", @@ -5599,7 +5933,8 @@ }, "last_status_at": { "description": "The date of the last authored status containing this hashtag.", - "$ref": "#/components/schemas/Date" + "type": "string", + "format": "date-time" } }, "required": [ @@ -5631,7 +5966,8 @@ }, "expires_at": { "description": "When the filter should no longer be applied.", - "$ref": "#/components/schemas/Datetime" + "type": "string", + "format": "date-time" }, "filter_action": { "description": "The action to be taken when a status matches this filter.", @@ -5744,7 +6080,8 @@ }, "updated_at": { "description": "When the identity proof was last updated.", - "$ref": "#/components/schemas/Datetime" + "type": "string", + "format": "date-time" }, "proof_url": { "description": "A link to a statement of identity proof, hosted by the identity provider.", @@ -6047,6 +6384,24 @@ "rules" ] }, + "InstanceIcon": { + "type": "object", + "description": "Additional entity definition for InstanceIcon", + "properties": { + "src": { + "description": "The URL of this icon.", + "type": "string" + }, + "size": { + "description": "The size of this icon.", + "type": "string" + } + }, + "required": [ + "src", + "size" + ] + }, "List": { "type": "object", "description": "Represents a list of some users that the authenticated user follows.", @@ -6084,7 +6439,8 @@ }, "updated_at": { "description": "The timestamp of when the marker was set.", - "$ref": "#/components/schemas/Datetime" + "type": "string", + "format": "date-time" } }, "required": [ @@ -6168,7 +6524,8 @@ }, "created_at": { "description": "The timestamp of the notification.", - "$ref": "#/components/schemas/Datetime" + "type": "string", + "format": "date-time" }, "account": { "description": "The account that performed the action that generated the notification.", @@ -6257,11 +6614,13 @@ }, "created_at": { "description": "The timestamp of the notification request, i.e. when the first filtered notification from that user was created.", - "$ref": "#/components/schemas/Datetime" + "type": "string", + "format": "date-time" }, "updated_at": { "description": "The timestamp of when the notification request was last updated.", - "$ref": "#/components/schemas/Datetime" + "type": "string", + "format": "date-time" }, "account": { "description": "The account that performed the action that generated the filtered notifications.", @@ -6294,7 +6653,8 @@ }, "expires_at": { "description": "When the poll ends.", - "$ref": "#/components/schemas/Datetime" + "type": "string", + "format": "date-time" }, "expired": { "description": "Is the poll currently expired?", @@ -6349,6 +6709,24 @@ "emojis" ] }, + "Poll::Option": { + "type": "object", + "description": "Additional entity definition for Poll::Option", + "properties": { + "title": { + "description": "The text value of the poll option.", + "type": "string" + }, + "votes_count": { + "description": "The total number of received votes for this option.", + "type": "integer" + } + }, + "required": [ + "title", + "votes_count" + ] + }, "Preferences": { "type": "object", "description": "Represents a user's preferences.", @@ -6473,6 +6851,68 @@ "blurhash" ] }, + "Trends::Link": { + "type": "object", + "description": "Additional entity definition for Trends::Link", + "properties": { + "history": { + "description": "Usage statistics for given days (typically the past week).", + "type": "array", + "items": { + "type": "object" + } + }, + "history[][day]": { + "description": "UNIX timestamp on midnight of the given day.", + "type": "string" + }, + "history[][accounts]": { + "description": "The counted accounts using the link within that day.", + "type": "string" + }, + "history[][uses]": { + "description": "The counted statuses using the link within that day.", + "type": "string" + } + }, + "required": [ + "history", + "history[][day]", + "history[][accounts]", + "history[][uses]" + ] + }, + "Trends::Link entity": { + "type": "object", + "description": "Additional entity definition for Trends::Link entity", + "properties": { + "history": { + "description": "Usage statistics for given days (typically the past week).", + "type": "array", + "items": { + "type": "object" + } + }, + "history[][day]": { + "description": "UNIX timestamp on midnight of the given day.", + "type": "string" + }, + "history[][accounts]": { + "description": "The counted accounts using the link within that day.", + "type": "string" + }, + "history[][uses]": { + "description": "The counted statuses using the link within that day.", + "type": "string" + } + }, + "required": [ + "history", + "history[][day]", + "history[][accounts]", + "history[][uses]" + ] + }, "PreviewCardAuthor": { "type": "object", "description": "Represents an author in a rich preview card.", @@ -6503,7 +6943,8 @@ "properties": { "updated_at": { "description": "A timestamp of when the privacy policy was last updated.", - "$ref": "#/components/schemas/Datetime" + "type": "string", + "format": "date-time" }, "content": { "description": "The rendered HTML content of the privacy policy.", @@ -6681,7 +7122,8 @@ }, "created_at": { "description": "When the event took place.", - "$ref": "#/components/schemas/Datetime" + "type": "string", + "format": "date-time" } }, "required": [ @@ -6708,7 +7150,8 @@ }, "action_taken_at": { "description": "When an action was taken against the report.", - "$ref": "#/components/schemas/Datetime" + "type": "string", + "format": "date-time" }, "category": { "description": "The generic reason for the report.", @@ -6724,7 +7167,8 @@ }, "created_at": { "description": "When the report was created.", - "$ref": "#/components/schemas/Datetime" + "type": "string", + "format": "date-time" }, "status_ids": { "description": "IDs of statuses that have been attached to this report for additional context.", @@ -6829,7 +7273,8 @@ }, "scheduled_at": { "description": "The timestamp for when the status will be posted.", - "$ref": "#/components/schemas/Datetime" + "type": "string", + "format": "date-time" }, "params": { "description": "The parameters that were used when scheduling the status, to be used when the status is posted.", @@ -7002,7 +7447,8 @@ }, "created_at": { "description": "The date when this status was created.", - "$ref": "#/components/schemas/Datetime" + "type": "string", + "format": "date-time" }, "account": { "description": "The account that authored this status.", @@ -7112,7 +7558,8 @@ }, "edited_at": { "description": "Timestamp of when the status was last edited.", - "$ref": "#/components/schemas/Datetime" + "type": "string", + "format": "date-time" }, "favourited": { "description": "If the current token has an authorized user: Have you favourited this status?", @@ -7171,6 +7618,54 @@ "edited_at" ] }, + "Status::Mention": { + "type": "object", + "description": "Additional entity definition for Status::Mention", + "properties": { + "id": { + "description": "The account ID of the mentioned user.", + "type": "string" + }, + "username": { + "description": "The username of the mentioned user.", + "type": "string" + }, + "url": { + "description": "The location of the mentioned user's profile.", + "type": "string", + "format": "uri" + }, + "acct": { + "description": "The webfinger acct: URI of the mentioned user. Equivalent to `username` for local users, or `username@domain` for remote users.", + "type": "string" + } + }, + "required": [ + "id", + "username", + "url", + "acct" + ] + }, + "Status::Tag": { + "type": "object", + "description": "Additional entity definition for Status::Tag", + "properties": { + "name": { + "description": "The value of the hashtag after the # sign.", + "type": "string" + }, + "url": { + "description": "A link to the hashtag on the instance.", + "type": "string", + "format": "uri" + } + }, + "required": [ + "name", + "url" + ] + }, "StatusEdit": { "type": "object", "description": "Represents a revision of a status that has been edited.", @@ -7189,7 +7684,8 @@ }, "created_at": { "description": "The timestamp of when the revision was published.", - "$ref": "#/components/schemas/Datetime" + "type": "string", + "format": "date-time" }, "account": { "description": "The account that published this revision.", @@ -7336,13 +7832,42 @@ "history[][accounts]" ] }, + "Admin::Tag": { + "type": "object", + "description": "Additional entity definition for Admin::Tag", + "properties": { + "id": { + "description": "The ID of the Tag in the database.", + "type": "string" + }, + "trendable": { + "description": "Whether the hashtag has been approved to trend.", + "type": "boolean" + }, + "usable": { + "description": "Whether the hashtag has not been disabled from auto-linking.", + "type": "boolean" + }, + "requires_review": { + "description": "Whether the hashtag has not been reviewed yet to approve or deny its trending.", + "type": "boolean" + } + }, + "required": [ + "id", + "trendable", + "usable", + "requires_review" + ] + }, "TermsOfService": { "type": "object", "description": "Represents the terms of service of the instance.", "properties": { "effective_date": { "description": "The date these terms of service are coming or have come in effect.", - "$ref": "#/components/schemas/Date" + "type": "string", + "format": "date-time" }, "effective": { "description": "Whether these terms of service are currently in effect.", @@ -7354,7 +7879,8 @@ }, "succeeded_by": { "description": "If there are newer terms of service, their effective date.", - "$ref": "#/components/schemas/Date" + "type": "string", + "format": "date-time" } }, "required": [ @@ -7432,6 +7958,58 @@ "provider" ] }, + "Translation::Poll": { + "type": "object", + "description": "Additional entity definition for Translation::Poll", + "properties": { + "id": { + "description": "The ID of the poll.", + "type": "string" + }, + "options": { + "description": "The translated poll options.", + "type": "array", + "items": { + "$ref": "#/components/schemas/Translation::Poll::Option" + } + } + }, + "required": [ + "id", + "options" + ] + }, + "Translation::Poll::Option": { + "type": "object", + "description": "Additional entity definition for Translation::Poll::Option", + "properties": { + "title": { + "description": "The translated title of the poll option.", + "type": "string" + } + }, + "required": [ + "title" + ] + }, + "Translation::Attachment": { + "type": "object", + "description": "Additional entity definition for Translation::Attachment", + "properties": { + "id": { + "description": "The id of the attachment.", + "type": "string" + }, + "description": { + "description": "The translated description of the attachment.", + "type": "string" + } + }, + "required": [ + "id", + "description" + ] + }, "V1::Filter": { "type": "object", "description": "Represents a user-defined filter for determining which statuses should not be shown to the user. Contains a single keyword or phrase.", @@ -7453,7 +8031,8 @@ }, "expires_at": { "description": "When the filter should no longer be applied.", - "$ref": "#/components/schemas/Datetime" + "type": "string", + "format": "date-time" }, "irreversible": { "description": "Should matching entities in home and notifications be dropped by the server? See [implementation guidelines for filters]({{< relref \"api/guidelines#filters\" >}}).", @@ -7813,7 +8392,26 @@ "securitySchemes": { "OAuth2": { "type": "oauth2", - "description": "OAuth 2.0 authentication" + "description": "OAuth 2.0 authentication", + "flows": { + "authorizationCode": { + "authorizationUrl": "https://mastodon.example/oauth/authorize", + "tokenUrl": "https://mastodon.example/oauth/token", + "scopes": { + "read": "Read access", + "write": "Write access", + "follow": "Follow/unfollow accounts", + "push": "Push notifications" + } + }, + "clientCredentials": { + "tokenUrl": "https://mastodon.example/oauth/token", + "scopes": { + "read": "Read access", + "write": "Write access" + } + } + } }, "BearerAuth": { "type": "http", diff --git a/package-lock.json b/package-lock.json index 41d9c0b..7202060 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "gray-matter": "^4.0.3" }, "devDependencies": { + "@seriousme/openapi-schema-validator": "^2.4.1", "@types/jest": "^29.5.0", "@types/js-yaml": "^4.0.9", "@types/node": "^22.15.30", @@ -919,6 +920,43 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@seriousme/openapi-schema-validator": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@seriousme/openapi-schema-validator/-/openapi-schema-validator-2.4.1.tgz", + "integrity": "sha512-OX15CKLV2JFGcoXxFVD/CMtWzys+r6G9gArKY8iaUqOkIEqp80ispclk5c8j5i1iEIIlyCRJ0R0N5MddHFg2xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.17.1", + "ajv-draft-04": "^1.0.0", + "ajv-formats": "^3.0.1", + "js-yaml": "^4.1.0" + }, + "bin": { + "bundle-api": "bin/bundle-api-cli.js", + "validate-api": "bin/validate-api-cli.js" + } + }, + "node_modules/@seriousme/openapi-schema-validator/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/@seriousme/openapi-schema-validator/node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -1134,6 +1172,56 @@ "node": ">=0.4.0" } }, + "node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-draft-04": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz", + "integrity": "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^8.5.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/ansi-escapes": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", @@ -1846,6 +1934,13 @@ "node": ">=0.10.0" } }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -1853,6 +1948,23 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-uri": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz", + "integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fb-watchman": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", @@ -2962,6 +3074,13 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -3454,6 +3573,16 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.22.10", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", diff --git a/package.json b/package.json index d5eff3b..1da0cdb 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "start": "node dist/index.js", "dev": "ts-node src/index.ts", "generate": "ts-node src/generate.ts", + "validate": "validate-api dist/schema.json", "test": "jest", "test:watch": "jest --watch", "clean": "rm -rf dist", @@ -24,6 +25,7 @@ "author": "", "license": "MIT", "devDependencies": { + "@seriousme/openapi-schema-validator": "^2.4.1", "@types/jest": "^29.5.0", "@types/js-yaml": "^4.0.9", "@types/node": "^22.15.30", diff --git a/src/__tests__/validate.test.ts b/src/__tests__/validate.test.ts new file mode 100644 index 0000000..019b52b --- /dev/null +++ b/src/__tests__/validate.test.ts @@ -0,0 +1,49 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { execSync } from 'child_process'; + +describe('OpenAPI Schema Validation', () => { + const schemaPath = path.join(__dirname, '..', '..', 'dist', 'schema.json'); + + beforeAll(() => { + // Ensure schema.json exists + if (!fs.existsSync(schemaPath)) { + execSync('npm run generate', { cwd: path.join(__dirname, '..', '..') }); + } + }); + + it('should have a schema.json file in dist directory', () => { + expect(fs.existsSync(schemaPath)).toBe(true); + }); + + it('should contain valid JSON in schema.json', () => { + const content = fs.readFileSync(schemaPath, 'utf-8'); + expect(() => JSON.parse(content)).not.toThrow(); + + const schema = JSON.parse(content); + expect(schema.openapi).toBe('3.0.3'); + expect(schema.info).toBeDefined(); + expect(schema.paths).toBeDefined(); + expect(schema.components).toBeDefined(); + }); + + it('should be able to run the validate script', () => { + // The validate script will exit with code 1 if validation fails + // But we just want to test that the script runs without throwing + expect(() => { + try { + execSync('npm run validate', { + cwd: path.join(__dirname, '..', '..'), + stdio: 'pipe', + }); + } catch (error: any) { + // Expect the command to exit with code 1 due to validation errors + // but that's okay - we just want to ensure the script runs + expect(error.status).toBe(1); + // Verify the output contains validation results + const output = error.stdout.toString(); + expect(output).toContain('"valid"'); + } + }).not.toThrow(); + }); +}); diff --git a/src/generators/OpenAPIGenerator.ts b/src/generators/OpenAPIGenerator.ts index d842275..664a429 100644 --- a/src/generators/OpenAPIGenerator.ts +++ b/src/generators/OpenAPIGenerator.ts @@ -35,6 +35,25 @@ class OpenAPIGenerator { OAuth2: { type: 'oauth2', description: 'OAuth 2.0 authentication', + flows: { + authorizationCode: { + authorizationUrl: 'https://mastodon.example/oauth/authorize', + tokenUrl: 'https://mastodon.example/oauth/token', + scopes: { + read: 'Read access', + write: 'Write access', + follow: 'Follow/unfollow accounts', + push: 'Push notifications', + }, + }, + clientCredentials: { + tokenUrl: 'https://mastodon.example/oauth/token', + scopes: { + read: 'Read access', + write: 'Write access', + }, + }, + }, }, BearerAuth: { type: 'http', @@ -143,16 +162,26 @@ class OpenAPIGenerator { return { type: 'array' }; } - // Handle references to other entities + // Handle references to other entities (only for actual entity names, not documentation links) if (typeString.includes('[') && typeString.includes(']')) { const refMatch = typeString.match(/\[([^\]]+)\]/); if (refMatch) { const refName = refMatch[1]; - // Clean up reference name - const cleanRefName = refName.replace(/[^\w:]/g, ''); - return { - $ref: `#/components/schemas/${cleanRefName}`, - }; + + // Only treat as entity reference if it's an actual entity name + // Skip documentation references like "Datetime", "Date", etc. + const isDocumentationLink = + refName.toLowerCase().includes('/') || + refName.toLowerCase() === 'datetime' || + refName.toLowerCase() === 'date'; + + if (!isDocumentationLink) { + // Clean up reference name (preserve :: for nested entities) + const cleanRefName = refName.replace(/[^\w:]/g, ''); + return { + $ref: `#/components/schemas/${cleanRefName}`, + }; + } } } diff --git a/src/interfaces/OpenAPISchema.ts b/src/interfaces/OpenAPISchema.ts index aa2afb5..42fa534 100644 --- a/src/interfaces/OpenAPISchema.ts +++ b/src/interfaces/OpenAPISchema.ts @@ -9,11 +9,24 @@ interface OpenAPIServer { description?: string; } +interface OAuthFlow { + authorizationUrl?: string; + tokenUrl: string; + refreshUrl?: string; + scopes: Record; +} + interface OpenAPISecurityScheme { type: string; scheme?: string; bearerFormat?: string; description?: string; + flows?: { + implicit?: OAuthFlow; + password?: OAuthFlow; + clientCredentials?: OAuthFlow; + authorizationCode?: OAuthFlow; + }; } interface OpenAPIProperty { @@ -95,6 +108,7 @@ export { OpenAPIInfo, OpenAPIServer, OpenAPISecurityScheme, + OAuthFlow, OpenAPIProperty, OpenAPISchema, OpenAPIParameter, diff --git a/src/parsers/EntityParser.ts b/src/parsers/EntityParser.ts index d9f1808..c325b19 100644 --- a/src/parsers/EntityParser.ts +++ b/src/parsers/EntityParser.ts @@ -28,9 +28,11 @@ class EntityParser { for (const file of files) { try { - const entity = this.parseEntityFile(path.join(this.entitiesPath, file)); - if (entity) { - entities.push(entity); + const fileEntities = this.parseEntityFile( + path.join(this.entitiesPath, file) + ); + if (fileEntities) { + entities.push(...fileEntities); } } catch (error) { console.error(`Error parsing file ${file}:`, error); @@ -40,49 +42,106 @@ class EntityParser { return entities; } - private parseEntityFile(filePath: string): EntityClass | null { + private parseEntityFile(filePath: string): EntityClass[] { const content = fs.readFileSync(filePath, 'utf-8'); const parsed = matter(content); - // Extract class name from frontmatter title + const entities: EntityClass[] = []; + + // Extract main class name from frontmatter title const className = parsed.data.title; if (!className) { console.warn(`No title found in ${filePath}`); - return null; + return entities; } // Extract description from frontmatter const description = parsed.data.description || ''; - // Parse attributes from markdown content + // Parse main entity attributes from markdown content const attributes = this.parseAttributes(parsed.content); - return { + entities.push({ name: className, description, attributes, - }; + }); + + // Parse additional entity definitions in the same file + const additionalEntities = this.parseAdditionalEntities(parsed.content); + entities.push(...additionalEntities); + + return entities; } private parseAttributes(content: string): EntityAttribute[] { const attributes: EntityAttribute[] = []; - // Find the "## Attributes" section + // Find the "## Attributes" section (for main entity only) + // Stop at any additional entity definitions const attributesMatch = content.match( - /## Attributes\s*([\s\S]*?)(?=\n## |$)/ + /## Attributes\s*([\s\S]*?)(?=\n## .* entity attributes|\n## |$)/ ); if (!attributesMatch) { return attributes; } const attributesSection = attributesMatch[1]; + return this.parseAttributesFromSection(attributesSection); + } + + private parseAdditionalEntities(content: string): EntityClass[] { + const entities: EntityClass[] = []; + + // Find all sections that define additional entities + // Pattern 1: ## [EntityName] entity attributes {#[id]} + // Pattern 2: ## [EntityName] attributes {#[id]} + const entitySectionRegex1 = /## ([^#\n]+?) entity attributes \{#([^}]+)\}/g; + const entitySectionRegex2 = /## ([^#\n]+?) attributes \{#([^}]+)\}/g; + + // Process both patterns + [entitySectionRegex1, entitySectionRegex2].forEach((regex) => { + let match; + while ((match = regex.exec(content)) !== null) { + const [fullMatch, entityName, entityId] = match; + + // Skip if we already processed this entity (avoid duplicates) + if (entities.some((e) => e.name === entityName.trim())) { + continue; + } + + // Find the content for this entity (from this heading to the next ## heading or end of file) + const startIndex = match.index + fullMatch.length; + const nextSectionMatch = content.substring(startIndex).match(/\n## /); + const endIndex = nextSectionMatch + ? startIndex + (nextSectionMatch.index || 0) + : content.length; + + const entityContent = content.substring(startIndex, endIndex); + + // Parse attributes for this entity + const attributes = this.parseAttributesFromSection(entityContent); + + entities.push({ + name: entityName.trim(), + description: `Additional entity definition for ${entityName.trim()}`, + attributes, + }); + } + }); + + return entities; + } + + private parseAttributesFromSection(content: string): EntityAttribute[] { + const attributes: EntityAttribute[] = []; - // Match each attribute definition + // Match each attribute definition in this section const attributeRegex = /### `([^`]+)`[^{]*(?:\{\{%([^%]+)%\}\})?\s*\{#[^}]+\}\s*\n\n\*\*Description:\*\*\s*([^\n]+).*?\n\*\*Type:\*\*\s*([^\n]+)/g; let match; - while ((match = attributeRegex.exec(attributesSection)) !== null) { + while ((match = attributeRegex.exec(content)) !== null) { const [, name, modifiers, description, type] = match; const attribute: EntityAttribute = {