Skip to content
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

Generate deployment client from the OpenAPI spec #1149

Merged
merged 23 commits into from
Oct 29, 2019
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ group :development do
gem 'awesome_print', :require => 'ap'
gem 'guard-rspec', '~> 4.5'
gem 'hirb-unicode'
gem 'pry'
gem 'redcarpet'
gem 'wirb'
gem 'wirble'
Expand All @@ -28,6 +27,12 @@ group :test do
gem 'webmock', '~> 3.4', '>= 3.4.2'
end

group :test, :development do
gem 'activesupport'
gem 'oas_parser'
gem 'pry-byebug'
end

platforms :rbx do
gem 'psych'
gem 'rubysl', '~> 2.0'
Expand Down
10 changes: 10 additions & 0 deletions Rakefile
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,13 @@ namespace :doc do
rescue LoadError
end
end

desc "Generate the API client files based on the OpenAPI route docs."
task :generate do
require_relative "lib/openapi_client_generator"
OpenAPIClientGenerator::API.at(OasParser::Definition.resolve("../routes/openapi/api.github.com/index.json")) do |api|
Copy link
Member

Choose a reason for hiding this comment

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

This depends on the right path, but since we're still developing this I think it's alright.

File.open("lib/octokit/client/#{api.resource}.rb", "w") do |f|
f.puts api.to_s
end
end
end
82 changes: 51 additions & 31 deletions lib/octokit/client/deployments.rb
Original file line number Diff line number Diff line change
@@ -1,71 +1,91 @@
module Octokit
class Client

# Methods for the Deployments API
#
# @see https://developer.github.com/v3/repos/commits/deployments/
# @see https://developer.github.com/v3/repos/deployments/
module Deployments

# Fetch a single deployment for a repository
# Get a single deployment
#
# @param repo [Integer, String, Repository, Hash] A GitHub repository
# @param deployment_id [Integer, String, Repository, Hash] A GitHub repository
# @param deployment_id [Integer] The ID of the deployment
# @return <Sawyer::Resource> A single deployment
# @see https://developer.github.com/v3/repos/deployments/#get-a-single-deployment
def deployment(repo, deployment_id, options = {})
get("#{Repository.path repo}/deployments/#{deployment_id}", options)
get "#{Repository.path repo}/deployments/#{deployment_id}", options
end

# List all deployments for a repository
# List deployments
#
# @param repo [Integer, String, Repository, Hash] A GitHub repository
# @param options [String] :sha The SHA recorded at creation time.
# @param options [String] :ref The name of the ref. This can be a branch, tag, or SHA.
# @param options [String] :task The name of the task for the deployment (e.g., `deploy` or `deploy:migrations`).
# @param options [String] :environment The name of the environment that was deployed to (e.g., `staging` or `production`).
# @return [Array<Sawyer::Resource>] A list of deployments
# @see https://developer.github.com/v3/repos/deployments/#list-deployments
def deployments(repo, options = {})
get("#{Repository.path repo}/deployments", options)
get "#{Repository.path repo}/deployments", options
end
alias :list_deployments :deployments

# Create a deployment for a ref
# Create a deployment
#
# @param repo [Integer, String, Repository, Hash] A GitHub repository
# @param ref [String] The ref to deploy
# @option options [String] :task Used by the deployment system to allow different execution paths. Defaults to "deploy".
# @option options [String] :payload Meta info about the deployment
# @option options [Boolean] :auto_merge Optional parameter to merge the default branch into the requested deployment branch if necessary. Default: true
# @option options [Array<String>] :required_contexts Optional array of status contexts verified against commit status checks.
# @option options [String] :environment Optional name for the target deployment environment (e.g., production, staging, qa). Default: "production"
# @option options [String] :description Optional short description.
# @return [Sawyer::Resource] A deployment
# @param ref [String] The ref to deploy. This can be a branch, tag, or SHA.
# @param options [String] :task Specifies a task to execute (e.g., `deploy` or `deploy:migrations`).
# @param options [Boolean] :auto_merge Attempts to automatically merge the default branch into the requested ref, if it's behind the default branch.
# @param options [Array] :required_contexts The [status](https://developer.github.com/v3/repos/statuses/) contexts to verify against commit status checks. If you omit this parameter, GitHub verifies all unique contexts before creating a deployment. To bypass checking entirely, pass an empty array. Defaults to all unique contexts.
# @param options [String] :payload JSON payload with extra information about the deployment.
# @param options [String] :environment Name for the target deployment environment (e.g., `production`, `staging`, `qa`).
# @param options [String] :description Short description of the deployment.
# @param options [Boolean] :transient_environment Specifies if the given environment is specific to the deployment and will no longer exist at some point in the future. Default: `false` **Note:** This parameter requires you to use the [`application/vnd.github.ant-man-preview+json`](https://developer.github.com/v3/previews/#enhanced-deployments) custom media type. **Note:** This parameter requires you to use the [`application/vnd.github.ant-man-preview+json`](https://developer.github.com/v3/previews/#enhanced-deployments) custom media type.
# @param options [Boolean] :production_environment Specifies if the given environment is one that end-users directly interact with. Default: `true` when `environment` is `production` and `false` otherwise. **Note:** This parameter requires you to use the [`application/vnd.github.ant-man-preview+json`](https://developer.github.com/v3/previews/#enhanced-deployments) custom media type.
# @return <Sawyer::Resource> The new deployment
# @see https://developer.github.com/v3/repos/deployments/#create-a-deployment
def create_deployment(repo, ref, options = {})
options[:ref] = ref
post("#{Repository.path repo}/deployments", options)
post "#{Repository.path repo}/deployments", options
end

# List all statuses for a Deployment
# Get a single deployment status
#
# @param deployment_url [String] A URL for a deployment resource
# @param repo [Integer, String, Repository, Hash] A GitHub repository
# @param deployment_id [Integer] The ID of the deployment
# @param status_id [Integer] The ID of the status
# @return <Sawyer::Resource> A single deployment status
# @see https://developer.github.com/v3/repos/deployments/#get-a-single-deployment-status
def deployment_status(repo, deployment_id, status_id, options = {})
Copy link
Member

Choose a reason for hiding this comment

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

Just making a note that this is a breaking change, but we're ok with that. It matches the rest of the client.

get "#{Repository.path repo}/deployments/#{deployment_id}/statuses/#{status_id}", options
end

# List deployment statuses
#
# @param repo [Integer, String, Repository, Hash] A GitHub repository
# @param deployment_id [Integer] The ID of the deployment
# @return [Array<Sawyer::Resource>] A list of deployment statuses
# @see https://developer.github.com/v3/repos/deployments/#list-deployment-statuses
def deployment_statuses(deployment_url, options = {})
indigok marked this conversation as resolved.
Show resolved Hide resolved
deployment = get(deployment_url, :accept => options[:accept])
get(deployment.rels[:statuses].href, options)
def deployment_statuses(repo, deployment_id, options = {})
get "#{Repository.path repo}/deployments/#{deployment_id}/statuses", options
end
alias :list_deployment_statuses :deployment_statuses

# Create a deployment status for a Deployment
# Create a deployment status
#
# @param deployment_url [String] A URL for a deployment resource
# @param state [String] The state: pending, success, failure, error
# @option options [String] :target_url The target URL to associate with this status. Default: ""
# @option options [String] :description A short description of the status. Maximum length of 140 characters. Default: ""
# @return [Sawyer::Resource] A deployment status
# @param repo [Integer, String, Repository, Hash] A GitHub repository
# @param deployment_id [Integer] The ID of the deployment
# @param state [String] The state of the status. Can be one of `error`, `failure`, `inactive`, `in_progress`, `queued` `pending`, or `success`. **Note:** To use the `inactive` state, you must provide the [`application/vnd.github.ant-man-preview+json`](https://developer.github.com/v3/previews/#enhanced-deployments) custom media type. To use the `in_progress` and `queued` states, you must provide the [`application/vnd.github.flash-preview+json`](https://developer.github.com/v3/previews/#deployment-statuses) custom media type.
# @param options [String] :target_url The target URL to associate with this status. This URL should contain output to keep the user updated while the task is running or serve as historical information for what happened in the deployment. **Note:** It's recommended to use the `log_url` parameter, which replaces `target_url`.
# @param options [String] :log_url The full URL of the deployment's output. This parameter replaces `target_url`. We will continue to accept `target_url` to support legacy uses, but we recommend replacing `target_url` with `log_url`. Setting `log_url` will automatically set `target_url` to the same value. Default: `""` **Note:** This parameter requires you to use the [`application/vnd.github.ant-man-preview+json`](https://developer.github.com/v3/previews/#enhanced-deployments) custom media type. **Note:** This parameter requires you to use the [`application/vnd.github.ant-man-preview+json`](https://developer.github.com/v3/previews/#enhanced-deployments) custom media type.
# @param options [String] :description A short description of the status. The maximum description length is 140 characters.
# @param options [String] :environment Name for the target deployment environment, which can be changed when setting a deploy status. For example, `production`, `staging`, or `qa`. **Note:** This parameter requires you to use the [`application/vnd.github.flash-preview+json`](https://developer.github.com/v3/previews/#deployment-statuses) custom media type.
# @param options [String] :environment_url Sets the URL for accessing your environment. Default: `""` **Note:** This parameter requires you to use the [`application/vnd.github.ant-man-preview+json`](https://developer.github.com/v3/previews/#enhanced-deployments) custom media type. **Note:** This parameter requires you to use the [`application/vnd.github.ant-man-preview+json`](https://developer.github.com/v3/previews/#enhanced-deployments) custom media type.
# @param options [Boolean] :auto_inactive Adds a new `inactive` status to all prior non-transient, non-production environment deployments with the same repository and `environment` name as the created status's deployment. An `inactive` status is only added to deployments that had a `success` state. Default: `true` **Note:** To add an `inactive` status to `production` environments, you must use the [`application/vnd.github.flash-preview+json`](https://developer.github.com/v3/previews/#deployment-statuses) custom media type. **Note:** This parameter requires you to use the [`application/vnd.github.ant-man-preview+json`](https://developer.github.com/v3/previews/#enhanced-deployments) custom media type.
# @return <Sawyer::Resource> The new deployment status
# @see https://developer.github.com/v3/repos/deployments/#create-a-deployment-status
def create_deployment_status(deployment_url, state, options = {})
deployment = get(deployment_url, :accept => options[:accept])
def create_deployment_status(repo, deployment_id, state, options = {})
options[:state] = state.to_s.downcase
post(deployment.rels[:statuses].href, options)
post "#{Repository.path repo}/deployments/#{deployment_id}/statuses", options
end
end
end
Expand Down
241 changes: 241 additions & 0 deletions lib/openapi_client_generator.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
require 'forwardable'
indigok marked this conversation as resolved.
Show resolved Hide resolved
require 'json'
require 'pathname'
require 'active_support/inflector'
require 'oas_parser'

module OpenAPIClientGenerator

class Endpoint
class PositionalParameterizer
def parameterize(args)
"#{args.join(", ")}, options = {}"
end
end

class KwargsParameterizer
def parameterize(args)
"#{args.map {|arg| arg + ":"}.join(", ")}, **options"
end
end

extend Forwardable

VERB_PRIORITY = %w(GET POST)

attr_reader :definition, :parameterizer
def initialize(oas_endpoint, parameterizer: OpenAPIClientGenerator::Endpoint::PositionalParameterizer)
@definition = oas_endpoint
@parameterizer = parameterizer.new
end

def to_s
[
tomdoc,
method_definition,
alias_definition,
].compact.join("\n")
end

def tomdoc
<<-TOMDOC.chomp
# #{definition.summary}
#
# #{parameter_documentation.join("\n # ")}
# @return #{return_type_description} #{return_value_description}
# @see #{definition.raw["externalDocs"]["url"]}
TOMDOC
end

def method_definition
<<-DEF.chomp
def #{method_name}(#{parameters})
#{method_implementation}
end
DEF
end

def alias_definition
return unless alternate_name
" alias :#{alternate_name} :#{method_name}"
end

def singular?
definition.path.path.split("/").last.include? "id"
end

def method_implementation
[
*option_overrides,
api_call,
].reject(&:empty?).join("\n ")
end

def option_overrides
required_params.reject do |param|
param.name == "repo" || param.name.include?("id")
end.map do |param|
normalization = ""
if !!param.enum
normalization = ".to_s.downcase"
end
"options[:#{param.name}] = #{param.name}#{normalization}"
end
end

def api_call
"#{definition.method} \"#{api_path}\", options"
end

def api_path
path = definition.path.path.gsub("/repos/{owner}/{repo}", "\#{Repository.path repo}")
path = required_params.reduce(path) do |path, param|
path.gsub("{#{param.name}}", "\#{#{param.name}}")
end
end

def return_type_description
if verb == "GET" && !singular?
"[Array<Sawyer::Resource>]"
else
"<Sawyer::Resource>"
end
end

def required_params
params = definition.parameters.select(&:required).reject {|param| ["owner", "accept"].include?(param.name)}
if definition.request_body
params += definition.request_body.properties_for_format("application/json").select { |param| definition.request_body.content["application/json"]["schema"]["required"].include? param.name }
Copy link
Member

Choose a reason for hiding this comment

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

This is pretty long, can we make this a multiline block?

end
params
end

def optional_params
params = definition.parameters.reject(&:required).reject {|param| ["accept", "per_page", "page"].include?(param.name)}
if definition.request_body
params += definition.request_body.properties_for_format("application/json").reject { |param| definition.request_body.content["application/json"]["schema"]["required"].include? param.name }
end
params
end

def parameter_type(param)
{
"repo" => "[Integer, String, Repository, Hash]",
}[param.name] || "[#{param.type.capitalize}]"
end

def parameter_description(param)
return "A GitHub repository" if param.name == "repo"
return "The ID of the #{param.name.gsub("_id", "").gsub("_", " ")}" if param.name.end_with? "_id"
return param.description.gsub("\n", "")
end

def parameter_documentation
required_params.map {|param|
"@param #{param.name} #{parameter_type(param)} #{parameter_description(param)}"
} + optional_params.map {|param|
"@param options [#{param.type.capitalize}] :#{param.name} #{param.description.gsub("\n", "")}"
}
end

def return_value_description
case verb
when "GET"
if singular?
"A single #{namespace.gsub("_", " ")}"
else
"A list of #{namespace.gsub("_", " ")}"
end
when "POST"
"The new #{namespace.singularize.gsub("_", " ")}"
else
end
end

def verb
definition.method.upcase
end

def parameters
parameterizer.parameterize(required_params.map(&:name))
end

def namespace
definition.operation_id.split("/").last.split("-").drop(1).join("_")
end

def method_name
case verb
when "GET"
namespace
when "POST"
"create_#{namespace}"
else
end
end

def alternate_name
return unless verb == "GET"
return if singular?
"list_#{namespace}"
end

def parts
definition.path.path.split("/").reject { |segment| segment.include? "id" }
end

def priority
[parts.count, VERB_PRIORITY.index(verb), singular?? 0 : 1]
end
end

class API
def self.at(definition, parameterizer: OpenAPIClientGenerator::Endpoint::PositionalParameterizer)
grouped_paths = definition.paths.group_by do |oas_path|
resource_for_path(oas_path.path)
end
grouped_paths.delete(:unsupported)
grouped_paths.each do |resource, paths|
endpoints = paths.each_with_object([]) do |path, arr|
path.endpoints.each do |endpoint|
arr << OpenAPIClientGenerator::Endpoint.new(endpoint, parameterizer: parameterizer)
end
end
yield new(resource, endpoints: endpoints)
end
end

def self.resource_for_path(path)
return :unsupported unless path.include? "deployment"
Copy link
Member

Choose a reason for hiding this comment

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

Can we make this into an Array? So that we can just add more resources easier?

path_segments = path.split("/").reject{ |segment| segment == "" }
resource = path_segments[3]
Copy link
Member

Choose a reason for hiding this comment

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

This seems pretty magic, does this ever change?

Copy link
Author

Choose a reason for hiding this comment

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

It definitely will, but it's pretty consistent for the paths under "repos" which was my next focus. I can look further into it, but I felt like it was good enough for now 🤔

resource ||= "repositories"
end

attr_reader :resource, :endpoints
def initialize(resource, endpoints: [])
@resource = resource
@endpoints = endpoints
end

def documentation_url
endpoints.first.definition.raw["externalDocs"]["url"].gsub(/#.*/, "")
end

def to_s
<<-FILE
module Octokit
class Client
# Methods for the #{resource.capitalize} API
#
# @see #{documentation_url}
module #{resource.capitalize}

#{endpoints.sort_by(&:priority).join("\n\n")}
end
end
end
FILE
end
end
end
Loading