classlink_client 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: e6c3a2602b4e9e440690d0b50383257218022c4713a7f2c3e5fa4645fafab35e
4
+ data.tar.gz: 21e291e3ff6ec97970880e0ced7619a5d402e5eb46d68f14794a8c8d336346b2
5
+ SHA512:
6
+ metadata.gz: be2a529532a515dfc506c336769314bc103e4b8df7b66725bfa6eba6f2de1155481666dc8e97a762189c3331d4f72c8e2747f10e231141cb3c94a2554bccf22a
7
+ data.tar.gz: 1b578db8f149324c9e03a295f2a1b9024b03f0703317b50181200508af667f2c44c153f86b182a23ec40b1308121b73fd02d91d681ae20a4fa2ec77054979f45
data/.gitignore ADDED
@@ -0,0 +1,8 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /_yardoc/
4
+ /coverage/
5
+ /doc/
6
+ /pkg/
7
+ /spec/reports/
8
+ /tmp/
data/.gitlab-ci.yml ADDED
@@ -0,0 +1,9 @@
1
+ image: ruby:3.0.0
2
+
3
+ before_script:
4
+ - gem install bundler -v 2.2.3
5
+ - bundle install
6
+
7
+ example_job:
8
+ script:
9
+ - bundle exec rake
data/.rubocop.yml ADDED
@@ -0,0 +1,10 @@
1
+ Style/StringLiterals:
2
+ Enabled: true
3
+ EnforcedStyle: double_quotes
4
+
5
+ Style/StringLiteralsInInterpolation:
6
+ Enabled: true
7
+ EnforcedStyle: double_quotes
8
+
9
+ Layout/LineLength:
10
+ Max: 120
data/Gemfile ADDED
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ # Specify your gem's dependencies in classlink_client.gemspec
6
+ gemspec
7
+
8
+ gem "rake", "~> 13.0"
9
+
10
+ gem "minitest", "~> 5.0"
11
+
12
+ gem "rubocop", "~> 0.80"
13
+
14
+ gem "pry"
data/Gemfile.lock ADDED
@@ -0,0 +1,59 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ classlink_client (0.1.0)
5
+ activesupport (>= 5.0.0)
6
+
7
+ GEM
8
+ remote: https://rubygems.org/
9
+ specs:
10
+ activesupport (7.0.3.1)
11
+ concurrent-ruby (~> 1.0, >= 1.0.2)
12
+ i18n (>= 1.6, < 2)
13
+ minitest (>= 5.1)
14
+ tzinfo (~> 2.0)
15
+ ast (2.4.2)
16
+ coderay (1.1.3)
17
+ concurrent-ruby (1.1.10)
18
+ i18n (1.12.0)
19
+ concurrent-ruby (~> 1.0)
20
+ method_source (1.0.0)
21
+ minitest (5.16.2)
22
+ parallel (1.22.1)
23
+ parser (3.1.2.1)
24
+ ast (~> 2.4.1)
25
+ pry (0.14.1)
26
+ coderay (~> 1.1)
27
+ method_source (~> 1.0)
28
+ rainbow (3.1.1)
29
+ rake (13.0.6)
30
+ regexp_parser (2.5.0)
31
+ rexml (3.2.5)
32
+ rubocop (0.93.1)
33
+ parallel (~> 1.10)
34
+ parser (>= 2.7.1.5)
35
+ rainbow (>= 2.2.2, < 4.0)
36
+ regexp_parser (>= 1.8)
37
+ rexml
38
+ rubocop-ast (>= 0.6.0)
39
+ ruby-progressbar (~> 1.7)
40
+ unicode-display_width (>= 1.4.0, < 2.0)
41
+ rubocop-ast (1.21.0)
42
+ parser (>= 3.1.1.0)
43
+ ruby-progressbar (1.11.0)
44
+ tzinfo (2.0.5)
45
+ concurrent-ruby (~> 1.0)
46
+ unicode-display_width (1.8.0)
47
+
48
+ PLATFORMS
49
+ x86_64-darwin-20
50
+
51
+ DEPENDENCIES
52
+ classlink_client!
53
+ minitest (~> 5.0)
54
+ pry
55
+ rake (~> 13.0)
56
+ rubocop (~> 0.80)
57
+
58
+ BUNDLED WITH
59
+ 2.2.3
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2022 Raptor Technologies
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,68 @@
1
+ # ClassLink Ruby Client
2
+
3
+ This is a simple client library for the ClassLink OneRoster API.
4
+
5
+ ## Installation
6
+
7
+ Add this line to your application's Gemfile:
8
+
9
+ ```ruby
10
+ gem "classlink_client"
11
+ ```
12
+
13
+ And then execute:
14
+
15
+ $ bundle install
16
+
17
+ Or install it yourself as:
18
+
19
+ $ gem install classlink_client
20
+
21
+ ## Usage
22
+
23
+ ### Authentication
24
+
25
+ Initialize the client using either the Direct, or Proxy Authentication.
26
+
27
+ #### Direct
28
+
29
+ For direct access, pass `endpoint`, `client_id`, and `client_secret`:
30
+
31
+ ```ruby
32
+ client = ClassLink::Client.new(
33
+ endpoint: "https://sandbox-vn-v2.oneroster.com",
34
+ client_id: "some_client_id",
35
+ client_secret: "some_client_secret"
36
+ )
37
+ ```
38
+
39
+ #### Proxy
40
+
41
+ For proxy access, pass `app_id` and `access_token`:
42
+
43
+ ```ruby
44
+ client = ClassLink::Client.new(
45
+ app_id: "some_app_id",
46
+ access_token: "some_access_token"
47
+ )
48
+ ```
49
+
50
+ The resources described in [`lib/classlink_client/interface.rb`](lib/classlink_client/interface.rb) are available as methods on the client. Call them singular with an ID to fetch an individual resource (`client.student(client_id)`), or plural to fetch the index (`client.students`).
51
+
52
+ ### Lazy loading
53
+
54
+ Resources are lazy loaded; `client.students` doesn't actually make any API requests, but `client.students.first` will.
55
+
56
+ ## Development
57
+
58
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
59
+
60
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
61
+
62
+ ## Contributing
63
+
64
+ Bug reports and pull requests are welcome on GitHub at https://github.com/cpoms/classlink_client.
65
+
66
+ ## License
67
+
68
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rake/testtask"
5
+
6
+ Rake::TestTask.new(:test) do |t|
7
+ t.libs << "test"
8
+ t.libs << "lib"
9
+ t.test_files = FileList["test/**/*_test.rb"]
10
+ end
11
+
12
+ require "rubocop/rake_task"
13
+
14
+ RuboCop::RakeTask.new
15
+
16
+ task default: %i[test rubocop]
data/bin/console ADDED
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler/setup"
5
+ require "classlink_client"
6
+
7
+ # You can add fixtures and/or initialization code here to make experimenting
8
+ # with your gem easier. You can also use a different console, if you like.
9
+
10
+ # (If you use this, don't forget to add pry to your Gemfile!)
11
+ # require "pry"
12
+ # Pry.start
13
+
14
+ require "irb"
15
+ IRB.start(__FILE__)
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/classlink_client/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "classlink_client"
7
+ spec.version = ClasslinkClient::VERSION
8
+ spec.authors = ["Mike Campbell"]
9
+ spec.email = ["mike.campbell@cpoms.co.uk"]
10
+
11
+ spec.summary = "Client library for ClassLink's OneRoster API"
12
+ spec.homepage = "https://github.com/cpoms/classlink_client"
13
+ spec.license = "MIT"
14
+ spec.required_ruby_version = Gem::Requirement.new(">= 2.3.0")
15
+
16
+ spec.metadata["homepage_uri"] = spec.homepage
17
+ spec.metadata["source_code_uri"] = "https://github.com/cpoms/classlink_client"
18
+
19
+ # Specify which files should be added to the gem when it is released.
20
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
21
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
22
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{\A(?:test|spec|features)/}) }
23
+ end
24
+ spec.bindir = "exe"
25
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
26
+ spec.require_paths = ["lib"]
27
+
28
+ spec.add_dependency "activesupport", ">= 5.0.0"
29
+ end
@@ -0,0 +1,44 @@
1
+ require "uri"
2
+ require_relative "request_signing"
3
+ require_relative "interface"
4
+ require_relative "request_builder"
5
+
6
+ module ClassLink
7
+ class Client
8
+ include Interface
9
+
10
+ DIRECT_OPTIONS = %i(client_id client_secret endpoint)
11
+ PROXY_OPTIONS = %i(access_token app_id)
12
+
13
+ (DIRECT_OPTIONS + PROXY_OPTIONS).each do |opt|
14
+ attr_reader opt
15
+ end
16
+
17
+ attr_reader :base_url, :proxy_api
18
+
19
+ def initialize(options)
20
+ handle_options!(options)
21
+
22
+ # if an endpoint hasn't been supplied, then we're using the proxy
23
+ @proxy_api = !endpoint
24
+ @base_url = endpoint || "https://oneroster-proxy.classlink.io/#{app_id}"
25
+ @base_url << "/ims/oneroster/v1p1/"
26
+ end
27
+
28
+ private
29
+ def handle_options!(options)
30
+ sorted_options = options.keys.sort
31
+
32
+ if sorted_options != DIRECT_OPTIONS && sorted_options != PROXY_OPTIONS
33
+ raise ArgumentError, <<~MSG.squish
34
+ must pass options for direct access (#{DIRECT_OPTIONS}),
35
+ or options for proxy access (#{PROXY_OPTIONS})
36
+ MSG
37
+ end
38
+
39
+ options.each do |opt, value|
40
+ instance_variable_set("@#{opt}", value)
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,40 @@
1
+ module ClassLink
2
+ module Interface
3
+ include RequestSigning
4
+
5
+ RESOURCES = %w(
6
+ demographics
7
+ resources
8
+ academic_sessions
9
+ classes
10
+ courses
11
+ enrollments
12
+ orgs
13
+ users
14
+ grading_periods
15
+ schools
16
+ students
17
+ teachers
18
+ terms
19
+ )
20
+
21
+ METHOD_NAME_PROXY = Hash.new{ |h, k| h[k] = k }.merge(
22
+ "classes" => "klasses"
23
+ )
24
+
25
+ RESOURCES.each do |resource|
26
+ define_method METHOD_NAME_PROXY[resource] do |options = {}|
27
+ builder.chain(resource, options)
28
+ end
29
+
30
+ define_method METHOD_NAME_PROXY[resource].singularize do |id|
31
+ builder.chain(resource, { segments: [id] })
32
+ end
33
+ end
34
+
35
+ private
36
+ def builder
37
+ RequestBuilder === self ? self : RequestBuilder.new(self)
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,73 @@
1
+ # require "rack"
2
+
3
+ module ClassLink
4
+ class RequestBuilder
5
+ include Interface
6
+ extend Forwardable
7
+
8
+ TEST_ARRAY = [].freeze
9
+ TEST_HASH = {}.freeze
10
+
11
+ def_delegators :@client,
12
+ :base_url,
13
+ :access_token,
14
+ :client_id,
15
+ :client_secret,
16
+ :proxy_api
17
+
18
+ def initialize(client)
19
+ @client = client
20
+
21
+ @path_parts = []
22
+ @query = {}
23
+ @last_request_call = nil
24
+ end
25
+
26
+ def chain(request, options)
27
+ @last_request_call = request
28
+ @query.merge!(options[:query]) if options[:query]
29
+ @path_parts += [request, *options[:segments]].map{ |s| "#{s}/" }
30
+
31
+ self
32
+ end
33
+
34
+ def result
35
+ @result ||= execute
36
+ end
37
+
38
+ def execute
39
+ request(build_url)
40
+ end
41
+
42
+ def build_url
43
+ URI.join(base_url, *@path_parts).tap do |url|
44
+ url.query = @query.to_query
45
+ end
46
+ end
47
+
48
+ def method_missing(method_name, *args, &block)
49
+ # prevent accidental method_missing recursion in development
50
+ # which can make things hard to debug.
51
+ super if @in_method_missing
52
+ @in_method_missing = true
53
+
54
+ if result.respond_to?(method_name)
55
+ result.public_send(method_name, *args, &block)
56
+ else
57
+ super
58
+ end
59
+ ensure
60
+ @in_method_missing = false
61
+ end
62
+
63
+ def respond_to_missing?(method_name, include_private = false)
64
+ if RESOURCES.include?(@last_request_call)
65
+ TEST_ARRAY.respond_to?(method_name)
66
+ elsif RESOURCES.map(&:singularize).include?(@last_request_call)
67
+ TEST_HASH.respond_to?(method_name)
68
+ else
69
+ super
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,88 @@
1
+ require "net/https"
2
+ require "base64"
3
+ require_relative "response"
4
+
5
+ module ClassLink
6
+ module RequestSigning
7
+ def request(url)
8
+ if proxy_api
9
+ get(url, "Bearer #{access_token}")
10
+ else
11
+ get(url, auth_header(url))
12
+ end
13
+ end
14
+
15
+ private
16
+ def auth_header(url)
17
+ # Generate timestamp and nonce
18
+ timestamp = Time.now.to_i.to_s
19
+ nonce = ('A'..'Z').to_a.sample(8).join
20
+
21
+ oauth = {
22
+ "oauth_consumer_key" => client_id,
23
+ "oauth_signature_method" => "HMAC-SHA256",
24
+ "oauth_timestamp" => timestamp,
25
+ "oauth_nonce" => nonce,
26
+ }
27
+
28
+ # Combine oauth params and url params
29
+ params = url.query ? CGI.parse(url.query).transform_values(&:first) : {}
30
+ params.merge!(oauth)
31
+
32
+ # Generate the auth signature
33
+ base_info = build_base_string(url.to_s.split("?")[0], "GET", params)
34
+ composite_key = CGI.escape(client_secret) + "&"
35
+ auth_signature = generate_auth_signature(base_info, composite_key)
36
+ oauth["oauth_signature"] = auth_signature
37
+
38
+ # Generate auth header
39
+ build_auth_header(oauth)
40
+ end
41
+
42
+ def build_base_string(base_url, http_method, params)
43
+ param_string = params.to_query.gsub("+", "%20")
44
+
45
+ http_method + "&" + CGI.escape(base_url) + "&" + CGI.escape(param_string)
46
+ end
47
+
48
+ def generate_auth_signature(base_info, composite_key)
49
+ Base64.encode64(OpenSSL::HMAC::digest(
50
+ OpenSSL::Digest.new("sha256"),
51
+ composite_key,
52
+ base_info
53
+ ))
54
+ end
55
+
56
+ def build_auth_header(oauth)
57
+ values = oauth.map{ |k, v| "#{k}=\"#{CGI.escape(v)}\"" }
58
+ values << values.pop[0..-5] + "\""
59
+ result = "OAuth #{values.join(",")}"
60
+ end
61
+
62
+ def get(url, header)
63
+ all = []
64
+ offset = 0
65
+ limit = 200
66
+ response_size = nil
67
+
68
+ until response_size&.< limit
69
+ url.query = "limit=#{limit}&offset=#{offset}"
70
+ req = Net::HTTP::Get.new(url.request_uri)
71
+ req["Authorization"] = header
72
+ http = Net::HTTP.new(url.hostname, url.port)
73
+ http.use_ssl = true
74
+
75
+ response = Response.new(http.request(req))
76
+
77
+ response_size = response.size
78
+
79
+ if response_size.positive?
80
+ all.concat(response)
81
+ offset += limit
82
+ end
83
+ end
84
+
85
+ all
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,38 @@
1
+ require "json"
2
+
3
+ module ClassLink
4
+ class Response
5
+ extend Forwardable
6
+
7
+ def_delegators :@net_http_response, :code, :message, :uri, :body
8
+
9
+ def initialize(net_http_response)
10
+ @net_http_response = net_http_response
11
+
12
+ raise RequestError unless code.start_with?("2")
13
+ end
14
+
15
+ def parsed
16
+ @parsed ||= JSON.parse(body).then do |res|
17
+ # API responses are keyed (somewhat randomly)
18
+ res.is_a?(Hash) ? res.values[0] : res
19
+ end
20
+ rescue JSON::ParserError
21
+ warn "Error parsing response as JSON, returning raw body instead."
22
+
23
+ body
24
+ end
25
+
26
+ def method_missing(method_name, *args, &block)
27
+ if parsed.respond_to?(method_name)
28
+ parsed.public_send(method_name, *args, &block)
29
+ else
30
+ super
31
+ end
32
+ end
33
+
34
+ def respond_to_missing?(method_name, include_private = false)
35
+ parsed.respond_to?(method_name) || super
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClasslinkClient
4
+ VERSION = "0.1.1"
5
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/string"
4
+ require_relative "classlink_client/version"
5
+ require_relative "classlink_client/client"
6
+
7
+ module ClassLink
8
+ class RequestError < StandardError; end
9
+ end
metadata ADDED
@@ -0,0 +1,77 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: classlink_client
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.1
5
+ platform: ruby
6
+ authors:
7
+ - Mike Campbell
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2023-05-25 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activesupport
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: 5.0.0
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: 5.0.0
27
+ description:
28
+ email:
29
+ - mike.campbell@cpoms.co.uk
30
+ executables: []
31
+ extensions: []
32
+ extra_rdoc_files: []
33
+ files:
34
+ - ".gitignore"
35
+ - ".gitlab-ci.yml"
36
+ - ".rubocop.yml"
37
+ - Gemfile
38
+ - Gemfile.lock
39
+ - LICENSE.txt
40
+ - README.md
41
+ - Rakefile
42
+ - bin/console
43
+ - bin/setup
44
+ - classlink_client.gemspec
45
+ - lib/classlink_client.rb
46
+ - lib/classlink_client/client.rb
47
+ - lib/classlink_client/interface.rb
48
+ - lib/classlink_client/request_builder.rb
49
+ - lib/classlink_client/request_signing.rb
50
+ - lib/classlink_client/response.rb
51
+ - lib/classlink_client/version.rb
52
+ homepage: https://github.com/cpoms/classlink_client
53
+ licenses:
54
+ - MIT
55
+ metadata:
56
+ homepage_uri: https://github.com/cpoms/classlink_client
57
+ source_code_uri: https://github.com/cpoms/classlink_client
58
+ post_install_message:
59
+ rdoc_options: []
60
+ require_paths:
61
+ - lib
62
+ required_ruby_version: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - ">="
65
+ - !ruby/object:Gem::Version
66
+ version: 2.3.0
67
+ required_rubygems_version: !ruby/object:Gem::Requirement
68
+ requirements:
69
+ - - ">="
70
+ - !ruby/object:Gem::Version
71
+ version: '0'
72
+ requirements: []
73
+ rubygems_version: 3.3.10
74
+ signing_key:
75
+ specification_version: 4
76
+ summary: Client library for ClassLink's OneRoster API
77
+ test_files: []