Compare commits

..

No commits in common. "master" and "2023-10-15" have entirely different histories.

14 changed files with 107 additions and 334 deletions

2
.gitignore vendored
View File

@ -3,5 +3,3 @@
*.db
.env.rb
coverage
vendor
.ruby-version

View File

@ -1,6 +0,0 @@
AllCops:
NewCops: enable
SuggestExtensions: false
Metrics/BlockLength:
AllowedMethods: ['describe', 'context', 'route', 'r.on', 'r.post']

33
Gemfile
View File

@ -1,35 +1,32 @@
# frozen_string_literal: true
source 'https://rubygems.org'
source "https://rubygems.org"
gem 'rackup', '~> 2.1'
gem 'falcon', '~> 0.42.3'
gem "roda", "~> 3.72"
gem 'roda', '~> 3.72'
gem "sequel", "~> 5.72"
gem 'sequel', '~> 5.72'
gem "tilt", "~> 2.2"
gem 'tilt', '~> 2.2'
gem "sqlite3", "~> 1.6"
gem 'sqlite3', '~> 1.6'
gem 'erubi', '~> 1.12'
gem "erubi", "~> 1.12"
group :test do
gem 'cucumber', '~> 9.0'
gem 'capybara', '~> 3.39'
gem "cucumber", "~> 9.0"
gem 'rspec', '~> 3.12'
gem "capybara", "~> 3.39"
gem 'selenium-webdriver', '~> 4.13'
gem "rspec", "~> 3.12"
gem 'simplecov'
gem "selenium-webdriver", "~> 4.13"
gem 'rubocop', require: false
gem "simplecov"
gem 'ruby_audit', '~> 2.2'
gem 'bundle-audit', '~> 0.1.0'
end
gem "puma", "~> 6.4"
gem "rackup", "~> 2.1"

View File

@ -3,38 +3,8 @@ GEM
specs:
addressable (2.8.5)
public_suffix (>= 2.0.2, < 6.0)
ast (2.4.2)
async (2.6.5)
console (~> 1.10)
fiber-annotation
io-event (~> 1.1)
timers (~> 4.1)
async-container (0.16.12)
async
async-io
async-http (0.61.0)
async (>= 1.25)
async-io (>= 1.28)
async-pool (>= 0.2)
protocol-http (~> 0.25.0)
protocol-http1 (~> 0.16.0)
protocol-http2 (~> 0.15.0)
traces (>= 0.10.0)
async-http-cache (0.4.3)
async-http (~> 0.56)
async-io (1.37.0)
async
async-pool (0.4.0)
async (>= 1.25)
base64 (0.1.1)
bigdecimal (3.1.4)
build-environment (1.13.0)
builder (3.2.4)
bundle-audit (0.1.0)
bundler-audit
bundler-audit (0.9.1)
bundler (>= 1.2.0, < 3)
thor (~> 1.0)
capybara (3.39.2)
addressable
matrix
@ -44,9 +14,6 @@ GEM
rack-test (>= 0.6.3)
regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2)
console (1.23.2)
fiber-annotation
fiber-local
cucumber (9.0.2)
builder (~> 3.2, >= 3.2.4)
cucumber-ci-environment (~> 9.2, >= 9.2.0)
@ -74,53 +41,18 @@ GEM
diff-lcs (1.5.0)
docile (1.4.0)
erubi (1.12.0)
falcon (0.42.3)
async
async-container (~> 0.16.0)
async-http (~> 0.57)
async-http-cache (~> 0.4.0)
async-io (~> 1.22)
build-environment (~> 1.13)
bundler
localhost (~> 1.1)
openssl (~> 3.0)
process-metrics (~> 0.2.0)
protocol-rack (~> 0.1)
samovar (~> 2.1)
ffi (1.16.2)
fiber-annotation (0.2.0)
fiber-local (1.0.0)
io-event (1.3.3)
json (2.6.3)
language_server-protocol (3.17.0.3)
localhost (1.1.10)
mapping (1.1.1)
matrix (0.4.2)
mini_mime (1.1.5)
mini_portile2 (2.8.4)
multi_test (1.1.0)
nio4r (2.5.9)
nokogiri (1.15.4)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
openssl (3.2.0)
parallel (1.23.0)
parser (3.2.2.4)
ast (~> 2.4.1)
racc
process-metrics (0.2.1)
console (~> 1.8)
samovar (~> 2.1)
protocol-hpack (1.4.2)
protocol-http (0.25.0)
protocol-http1 (0.16.0)
protocol-http (~> 0.22)
protocol-http2 (0.15.1)
protocol-hpack (~> 1.4)
protocol-http (~> 0.18)
protocol-rack (0.2.6)
protocol-http (~> 0.23)
rack (>= 1.0)
public_suffix (5.0.3)
puma (6.4.0)
nio4r (~> 2.0)
racc (1.7.1)
rack (3.0.8)
rack-test (2.1.0)
@ -128,7 +60,6 @@ GEM
rackup (2.1.0)
rack (>= 3)
webrick (~> 1.8)
rainbow (3.1.1)
regexp_parser (2.8.1)
rexml (3.2.6)
roda (3.72.0)
@ -146,27 +77,7 @@ GEM
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.12.0)
rspec-support (3.12.1)
rubocop (1.57.1)
base64 (~> 0.1.1)
json (~> 2.3)
language_server-protocol (>= 3.17.0)
parallel (~> 1.10)
parser (>= 3.2.2.4)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 1.8, < 3.0)
rexml (>= 3.2.5, < 4.0)
rubocop-ast (>= 1.28.1, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 3.0)
rubocop-ast (1.29.0)
parser (>= 3.2.1.0)
ruby-progressbar (1.13.0)
ruby_audit (2.2.0)
bundler-audit (~> 0.9.0)
rubyzip (2.3.2)
samovar (2.2.0)
console (~> 1.0)
mapping (~> 1.0)
selenium-webdriver (4.13.1)
rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 3.0)
@ -183,11 +94,7 @@ GEM
mini_portile2 (~> 2.8.0)
sys-uname (1.2.3)
ffi (~> 1.1)
thor (1.3.0)
tilt (2.3.0)
timers (4.3.5)
traces (0.11.1)
unicode-display_width (2.5.0)
webrick (1.8.1)
websocket (1.2.10)
xpath (3.2.0)
@ -197,16 +104,13 @@ PLATFORMS
x86_64-freebsd-13
DEPENDENCIES
bundle-audit (~> 0.1.0)
capybara (~> 3.39)
cucumber (~> 9.0)
erubi (~> 1.12)
falcon (~> 0.42.3)
puma (~> 6.4)
rackup (~> 2.1)
roda (~> 3.72)
rspec (~> 3.12)
rubocop
ruby_audit (~> 2.2)
selenium-webdriver (~> 4.13)
sequel (~> 5.72)
simplecov

136
Jenkinsfile vendored
View File

@ -1,21 +1,20 @@
def buildArtifact = true
pipeline {
agent { label 'ruby && freebsd' }
agent any
environment {
APP_SESSION_SECRET = ''
DB_NAME = 'url_shortener.db'
}
stages {
stage('Init') {
steps {
sh ''' #!/usr/local/bin/bash
rbenv local 3.2.2
echo "# frozen_string_literal: true\n" > .env.rb
echo "ENV['APP_SESSION_SECRET'] ||= '$(ruby -rsecurerandom -e 'puts SecureRandom.base64(64)')'" >> .env.rb
echo "ENV['DB_NAME'] ||= '${DB_NAME}'" >> .env.rb
cat .env.rb
'''
sh 'rbenv local 3.2.2'
script {
env.APP_SESSION_SECRET = sh(script: 'ruby secret.rb', returnStdout: true)
}
sh 'echo "ENV[\\\"APP_SESSION_SECRET\\\"] ||= $(ruby secret.rb)" > .env.rb'
sh 'echo "ENV[\\\"DB_NAME\\\"] ||= \\\"${DB_NAME}\\\"" >> .env.rb'
sh 'cat .env.rb'
}
}
stage('Build dependencies') {
@ -24,131 +23,27 @@ pipeline {
sh 'sequel -m db/migrations sqlite://db/${DB_NAME}'
}
}
stage('Audit Dependencies') {
steps {
catchError(buildResult: 'FAILURE', stageResult: 'FAILURE') {
script {
try {
sh 'bundle exec ruby-audit check'
sh 'bundle exec bundle-audit check >> audit.html'
} catch (e) {
script {
buildArtifact = false
}
}
}
}
}
}
stage('Code Linting') {
steps {
catchError(buildResult: 'UNSTABLE', stageResult: 'FAILURE') {
script {
try {
sh 'bundle exec rubocop --format html --out rubocop.html'
} catch (e) {
script {
buildArtifact = false
}
}
}
}
}
}
stage('Run tests') {
steps {
catchError(buildResult: 'UNSTABLE', stageResult: 'FAILURE') {
script {
try {
sh 'bundle exec cucumber features --format html --out cucumber.html'
} catch (e) {
script {
buildArtifact = false
}
}
}
sh 'cucumber features --format html --out cucumber.html'
}
catchError(buildResult: 'UNSTABLE', stageResult: 'FAILURE') {
script {
try {
sh 'bundle exec rspec spec --format html --out spec.html'
} catch (e) {
script {
buildArtifact = false
}
}
}
sh 'rspec spec --format html --out spec.html'
}
}
}
stage('Report results') {
steps {
archive(includes: 'pkg/*.gem')
publishHTML (target: [
allowMissing: false,
alwaysLinkToLastBuild: false,
keepAll: true,
reportDir: '.',
reportFiles: 'rubocop.html, audit.html, cucumber.html, spec.html, coverage/index.html',
reportFiles: 'cucumber.html, spec.html, coverage/index.html',
reportName: 'Test Results',
reportTitles: 'Rubocop Results, Bundler Audit Results, Cucumber Results, RSpec Results, Test Coverage'])
}
}
stage('Build production deliverable') {
when {
expression {
buildArtifact
}
}
steps {
catchError(buildResult: 'FAILURE', stageResult: 'FAILURE') {
sh ''' #!/usr/local/bin/bash
ZIP_FILE="url-shortener_${BRANCH_NAME}_$(cat VERSION | cut -d"'" -f2).tgz"
CUR_DIR=$(pwd)
bundle config set --local without 'test'
bundle config set --local path "vendor"
bundle install
mkdir -p /tmp/url-shortener
cp -R * /tmp/url-shortener
cp .env.rb /tmp/url-shortener/
cp .ruby-version /tmp/url-shortener/
cd /tmp/url-shortener
rm -rf features spec coverage db/*.db .git* Jenkinsfile *.html .rubocop.yml
cd /tmp
tar -czvf $ZIP_FILE url-shortener/
mv /tmp/$ZIP_FILE $CUR_DIR/
'''
}
archiveArtifacts artifacts: '*.tgz'
}
}
stage('Generate SHA256 Hash and Upload to Artifact Repository') {
environment {
ARTIFACTS_KEY = credentials('artifactor-key')
}
steps {
sshagent(['artifactor-key']) {
sh ''' #!/usr/local/bin/bash
UUID=$(uuidgen -r)
VERSION=$(cat VERSION | cut -d"'" -f2)
BRANCH=${BRANCH_NAME}
ZIP_FILE="url-shortener_${BRANCH_NAME}_$(cat VERSION | cut -d"'" -f2).tgz"
sha256 $ZIP_FILE >> SHA256.sig
ssh artifactor@artifacts mkdir -p projects/url-shortener/$BRANCH/$VERSION/$UUID
rsync SHA256.sig artifactor@artifacts:projects/url-shortener/$BRANCH/$VERSION/$UUID/
rsync $ZIP_FILE artifactor@artifacts:projects/url-shortener/$BRANCH/$VERSION/$UUID/
'''
}
}
}
stage('Clean up deliverable') {
when {
expression {
buildArtifact
}
}
steps {
sh 'rm -rf /tmp/url-shortener'
sh 'rm SHA256.sig'
reportTitles: 'Cucumber Results, RSpec Results, Test Coverage'])
}
}
}
@ -162,8 +57,5 @@ pipeline {
failure {
mattermostSend channel: 'git-messages', color: 'danger', message: "[${JOB_NAME}](${JOB_URL}) [#${BUILD_NUMBER}](${BUILD_URL}) ([Gitea](${GIT_URL}))", text: "Build Failed"
}
always {
cleanWs deleteDirs: true, patterns: [[pattern: '*', type: 'INCLUDE']]
}
}
}

View File

@ -1,11 +1,11 @@
# simple url shortener
this project is a simple solution to build out a URL shortener using
Roda, Puma, Sequel, and SQLite on a FreeBSD Jail
Roda, Falcon, Sequel, and SQLite
the point of this project is to quickly build something and work on continuous deployment while making small refinements to the functional pieces.
the outside pieces of software that this project relies on are rbenv, ruby-build, sqlite3, and pkgconf.
the outside pieces of software that this project relies on are sqlite3 and pkgconf.
@ -14,9 +14,9 @@ first you have to install the dependencies:
`bundle install`
if you want to skip the test dependencies:
if you want the development group included run this first:
`bundle config set --local without 'test'`
`bundle config set --local with 'development'`
then create a .env.rb file in the root directory that contains the following ENV attributes:
@ -24,19 +24,15 @@ then create a .env.rb file in the root directory that contains the following ENV
```
ENV["APP_SESSION_SECRET"] = {output of a random 64 byte secret}
ENV["DB_NAME"] = {DB_NAME}
ENV["DB_NAME"] = {db file name}
```
after the dependencies are installed, you have to create the db and schema
after the dependencies are installed, you have to create the db
`sequel -m db/migrations sqlite://db/{DB_NAME}`
to start the application with Puma:
to start the application with Falcon:
`rackup -o {ip address} -p {port}`
to run it as a daemon:
`rackup -o {ip address} -p {port} -D`
`rackup -o {ip address} -p {port} -s falcon`

View File

@ -1 +0,0 @@
VERSION = '1.0.1'

63
app.rb
View File

@ -1,12 +1,9 @@
# frozen_string_literal: true
require 'roda'
require 'securerandom'
require 'json'
require 'sequel'
require 'open-uri'
# URL Shortener App class
class App < Roda
plugin :sessions, secret: ENV.delete('APP_SESSION_SECRET')
plugin :render, escape: true
@ -14,92 +11,92 @@ class App < Roda
plugin :json_parser
plugin :request_headers
DB = Sequel.sqlite("db/#{ENV.fetch('DB_NAME', nil)}")
DB = Sequel.sqlite("db/#{ENV['DB_NAME']}")
links = DB[:links]
route do |r|
r.root do
@message = flash['message'] || 'Enter a URL'
@message = flash['message'] || "Enter a URL"
view :home
end
r.get String do | url_code |
link = links.filter(code: url_code)
link = links.filter(:code => url_code)
r.redirect link.first[:url] unless link.first.nil?
@message = "Link #{url_code} doesn't exist"
response.status = 404
view :home
end
r.post 'create' do
r.post "create" do
url = r.params['url']
if url.nil? || url.empty?
flash['message'] = 'Please enter a valid URL'
if url.nil? or url.empty?
flash['message'] = "Please enter a valid URL";
r.redirect '/'
end
begin
OpenURI.open_uri(url)
rescue URI::BadURIError
flash['message'] = 'Invalid URL'
flash['message'] = "Invalid URL"
r.redirect '/'
rescue OpenURI::HTTPError
flash['message'] = 'URL not found'
flash['message'] = "URL not found"
r.redirect '/'
rescue SocketError
flash['message'] = 'URL does not resolve'
rescue SocketError => e
flash['message'] = "URL does not resolve"
r.redirect '/'
end
if links.filter(url:).first.nil?
if links.filter(:url => url).first.nil?
code = SecureRandom.urlsafe_base64 4
links.insert(url:, code:)
@message = 'Link created'
links.insert(url: url, code: code)
@message = "Link created"
end
code = links.filter(url:).first[:code]
@message ||= 'Link exists'
@new_link = "http://#{request.env['HTTP_HOST']}/#{code}"
code = links.filter(:url => url).first[:code]
@message ||= "Link exists"
@new_link = 'http://' + request.env['HTTP_HOST'] + '/' + code
view :create
end
r.on 'links' do
r.on "links" do
r.post do
if r.headers['CONTENT_TYPE'] != 'application/json'
if 'application/json' != r.headers['CONTENT_TYPE']
response.status = 400
return { message: 'not a valid json request' }.to_json
return {message: "not a valid json request"}.to_json
end
url = r.params['url']
if url.nil?
response.status = 400
return { message: 'missing url parameter' }.to_json
return {message: "missing url parameter"}.to_json
end
if url.empty?
response.status = 400
return { message: 'invalid url parameter' }.to_json
return {message: "invalid url parameter"}.to_json
end
begin
OpenURI.open_uri(url)
rescue URI::BadURIError
response.status = 400
return { message: 'invalid url parameter' }.to_json
return {message: "invalid url parameter"}.to_json
rescue OpenURI::HTTPError
response.status = 400
return { message: 'url not found' }.to_json
rescue SocketError
return {message: "url not found"}.to_json
rescue SocketError => e
response.status = 400
return { message: 'url does not resolve' }.to_json
return {message: "url does not resolve"}.to_json
end
if links.filter(url:).first.nil?
if links.filter(:url => url).first.nil?
code = SecureRandom.urlsafe_base64 4
links.insert(url:, code:)
links.insert(url: url, code: code)
end
code = links.filter(url:).first[:code]
@new_link = "http://#{request.env['HTTP_HOST']}/#{code}"
return { url:, code:, link: @new_link }.to_json
code = links.filter(:url => url).first[:code]
@new_link = 'http://' + request.env['HTTP_HOST'] + '/' + code
return {url: url, code: code, link: @new_link}.to_json
end
end
end

View File

@ -1,5 +1,3 @@
# frozen_string_literal: true
require './.env'
require './app'

View File

@ -1,5 +1,3 @@
# frozen_string_literal: true
Sequel.migration do
change do
create_table :links do

View File

@ -1,10 +1,9 @@
# frozen_string_literal: true
# BEFORE
Before('@db-test') do
@links = Sequel.sqlite("db/#{ENV.fetch('DB_NAME', nil)}")[:links]
@links = Sequel.sqlite("db/#{ENV['DB_NAME']}")[:links]
end
# GIVEN
Given('I visit the {string} page') do |string|
@ -12,13 +11,14 @@ Given('I visit the {string} page') do |string|
end
Given('A link already exists with the url {string}') do |string|
@links.insert(url: string, code: 'aaaaaa')
@links.insert(url: string, code: "aaaaaa")
end
Given('A link already exists with the url {string} and code {string}') do |url, code|
@links.insert(url:, code:)
@links.insert(url: url, code: code)
end
# WHEN
When('I click the {string} button') do |string|
@ -33,6 +33,7 @@ When('I visit the {string} location') do |string|
visit string
end
# THEN
Then('I should see text {string}') do |string|
@ -65,6 +66,7 @@ Then('The status code should be {int}') do |code|
page.status_code.should eq(code)
end
# AFTER
After('@db-test') do

View File

@ -1,27 +1,25 @@
# frozen_string_literal: true
require 'simplecov'
SimpleCov.start
require_relative '../../.env'
ENV['DB_NAME'] = "test_#{ENV.fetch('DB_NAME', nil)}"
ENV["DB_NAME"] = "test_#{ENV["DB_NAME"]}"
require_relative '../../app'
require 'rubygems'
require 'roda'
require 'sequel'
require 'capybara'
require 'capybara/dsl'
require 'capybara/cucumber'
require 'rspec'
###
require 'rspec/expectations'
require 'rspec/matchers'
# DB initialization
Sequel.extension :migration
Sequel.sqlite("db/#{ENV.fetch('DB_NAME', nil)}") do |db|
Sequel::Migrator.apply(db, 'db/migrations')
Sequel.sqlite("db/#{ENV['DB_NAME']}") do |db|
Sequel::Migrator.apply(db, "db/migrations")
end
# attach app to Capybara
Capybara.app = App
include Capybara::DSL
include RSpec::Expectations
include RSpec::Matchers

2
secret.rb 100644
View File

@ -0,0 +1,2 @@
require 'securerandom'
puts SecureRandom.base64(64).inspect()

View File

@ -1,10 +1,8 @@
# frozen_string_literal: true
require 'simplecov'
SimpleCov.start
require_relative '../.env'
ENV['DB_NAME'] = "test_#{ENV.fetch('DB_NAME', nil)}"
ENV["DB_NAME"] = "test_#{ENV["DB_NAME"]}"
require_relative '../app'
require 'rubygems'
require 'roda'
@ -14,27 +12,27 @@ require 'rack/test'
# DB initialization
Sequel.extension :migration
Sequel.sqlite("db/#{ENV.fetch('DB_NAME', nil)}") do |db|
Sequel::Migrator.apply(db, 'db/migrations')
Sequel.sqlite("db/#{ENV['DB_NAME']}") do |db|
Sequel::Migrator.apply(db, "db/migrations")
end
def app
App
end
describe 'Submit API request to create new link' do
describe "Submit API request to create new link" do
include Rack::Test::Methods
before :each do
@links = Sequel.sqlite("db/#{ENV.fetch('DB_NAME', nil)}")[:links]
@links = Sequel.sqlite("db/#{ENV['DB_NAME']}")[:links]
end
after :each do
@links.delete
end
it 'should return link data in json format when a valid url is submitted' do
it "should return link data in json format when a valid url is submitted" do
data = {
url: 'http://google.com'
}
post('/links', data.to_json, 'CONTENT_TYPE' => 'application/json')
post('/links', data.to_json, "CONTENT_TYPE" => "application/json")
expect(last_response).to be_ok
response_json = JSON.parse(last_response.body)
expect(response_json['url']).to eq(data[:url])
@ -46,15 +44,16 @@ describe 'Submit API request to create new link' do
data = {
url: ''
}
post('/links', data.to_json, 'CONTENT_TYPE' => 'application/json')
post('/links', data.to_json, "CONTENT_TYPE" => "application/json")
expect(last_response.status).to eq(400)
response_json = JSON.parse(last_response.body)
expect(response_json['message']).to eq('invalid url parameter')
end
it "should return with a 400 status and 'missing url parameter' message when an empty url is submitted" do
data = {}
post('/links', data.to_json, 'CONTENT_TYPE' => 'application/json')
data = {
}
post('/links', data.to_json, "CONTENT_TYPE" => "application/json")
expect(last_response.status).to eq(400)
response_json = JSON.parse(last_response.body)
expect(response_json['message']).to eq('missing url parameter')
@ -64,7 +63,7 @@ describe 'Submit API request to create new link' do
data = {
url: 'not-an-url'
}
post('/links', data.to_json, 'CONTENT_TYPE' => 'application/json')
post('/links', data.to_json, "CONTENT_TYPE" => "application/json")
expect(last_response.status).to eq(400)
response_json = JSON.parse(last_response.body)
expect(response_json['message']).to eq('invalid url parameter')
@ -74,7 +73,7 @@ describe 'Submit API request to create new link' do
data = {
url: 'http://google.com/example'
}
post('/links', data.to_json, 'CONTENT_TYPE' => 'application/json')
post('/links', data.to_json, "CONTENT_TYPE" => "application/json")
expect(last_response.status).to eq(400)
response_json = JSON.parse(last_response.body)
expect(response_json['message']).to eq('url not found')
@ -84,14 +83,13 @@ describe 'Submit API request to create new link' do
data = {
url: 'http://bad.tld'
}
post('/links', data.to_json, 'CONTENT_TYPE' => 'application/json')
post('/links', data.to_json, "CONTENT_TYPE" => "application/json")
expect(last_response.status).to eq(400)
response_json = JSON.parse(last_response.body)
expect(response_json['message']).to eq('url does not resolve')
end
it "should return with a 400 status and 'not a valid json request'
message when a request is made with the wrong content type header" do
it "should return with a 400 status and 'not a valid json request' message when a request is made with the wrong content type header" do
data = {
url: 'http://google.com'
}