TLDR;
- I built a CLI to search Vlad Mihalcea's blog: https://github.com/maciejwalkowiak/vlad-cli
- Tools used: oclif, Inquirer.js, opn
- 🎼 "I can't wait to Hibernate" 🎼 https://www.youtube.com/watch?v=L-ar19ysy8E
Vlad Mihalcea's blog is a default place I go to when I struggle with Hibernate or JPA, which lead to the following tweet exchange:
Joke or not joke - building such CLI can be a quick and fun project, so why not?
Searching through the blog
The first question is how to search through the blog. None of the public search engines provide search API, there are some third party options that scrape Google results and return them as JSON - but first - as far as I can tell it violates Google terms of use and it's a paid API. Nothing wrong with paying for API but not for this kind of project.
Fortunately, Vlad's blog is based on Wordpress. Wordpress by default comes with a search API - not sure what is used under the hood and how good are the search results but for sure they are as good as search functionality on Vlad's blog - so we can assume it's good enough.
A blog can be searched with executing curl
on search endpoint:
$ curl -s https://vladmihalcea.com/wp-json/wp/v2/search\?search\=how\ to\ map\ json
[
{
"id": 5905,
"title": "How to map JSON objects using generic Hibernate Types",
"url": "https://vladmihalcea.com/how-to-map-json-objects-using-generic-hibernate-types/",
"type": "post",
"subtype": "post",
"_links": {
"self": [
{
"embeddable": true,
"href": "https://vladmihalcea.com/wp-json/wp/v2/posts/5905"
}
],
"about": [
{
"href": "https://vladmihalcea.com/wp-json/wp/v2/types/post"
}
],
"collection": [
{
"href": "https://vladmihalcea.com/wp-json/wp/v2/search"
}
]
}
},
...
]
vlad-cli as a bash script
The initial idea was just to write a very simple bash script that will open the first search result in a browser.
With jq we can easily extract url
from json
returned by curl
:
curl -s https://vladmihalcea.com/wp-json/wp/v2/search\?search\="how to map json" | jq -r ".[0].url?"
https://vladmihalcea.com/how-to-map-json-objects-using-generic-hibernate-types/
jq
is a super handy utility I totally recommend checking. ".[0].url?"
translates into: extract url
property from the first entry in array and do not fail if it's not present.
Adding -r
strips quotes from the result.
The last thing is to pass the result to the command that opens a web browser and put it together in a bash script
#!/bin/sh
str="$*"
open $(curl -s https://vladmihalcea.com/wp-json/wp/v2/search\?search\="$str" | jq -r ".[0].url?")
exit;
str="$*"
concatenates all arguments so that we can execute the script like:
$ ./vlad.sh how to map json
This script works, but has the following drawbacks:
open
command works only on Mac - other OS have different commands that serve the same purpose- the first result is not necessarily the best
- how to distribute bash script as an easily installable package?
I decided to address them by rewriting the script to NodeJS, mainly because most developers are used to installing packages through npm
, there is a mindblowing number of available packages to use and it's freaking easy to publish package making it available to anyone to install.
Building clis with Node
Finding reliable CLI framework in Node ecosystem is quite straightforward - oclif - made by Heroku and used by Heroku to build their CLI (which is awesome). It's also used by Twillio. I don't really need more recommendations than that.
First, we need to create project:
npx oclif single vlad-cli
Then there are few usual questions to answer like who is the owner, what is the package name etc and finally if we want to build it with TypeScript
or JavaScript
.
? npm package name vlad-cli
? command bin name the CLI will export vlad-cli
? description
? author Maciej Walkowiak @maciejwalkowiak
? version 0.0.0
? license MIT
? Who is the GitHub owner of repository (https://github.com/OWNER/repo) maciejwalkowiak
? What is the GitHub name of repository (https://github.com/owner/REPO) vlad-cli
? Select a package manager yarn
? TypeScript No
? Use eslint (linter for JavaScript and Typescript) No
? Use mocha (testing framework) No
Now we have application skeleton generated, we can already run it with ./bin/run
$ ./bin/run
hello world from ./src/index.js
Passing the search query to the command
At this stage if we just want to pass search query to the command we will get the following output:
$ ./bin/run how to map json
› Error: Unexpected arguments: how, to, map, json
› See more help with --help
Each word is considered to be a separate argument and by default with oclif you have to explicitly list all available arguments.
We can disable this behavior by setting static strict = false
on VladCliCommand
. Then we can obtain an array of arguments from the result of parse
:
class VladCliCommand extends Command {
static strict = false
async run() {
const {argv} = this.parse(VladCliCommand)
const query = argv.join(' ')
// ...
}
}
Fetching search results
Now we need to fetch search results from Wordpress search API. If you have worked with JavaScript at least a bit you are likely familiar with fetch
function. It's not available in Node environment so we need to add a dependency:
$ yarn add node-fetch
... and fetch the results. We can use async/await
methods if you don't want to break your neck with promises:
const fetch = require('node-fetch')
class VladCliCommand extends Command {
static strict = false
async run() {
const {argv} = this.parse(VladCliCommand)
const query = argv.join(' ')
const searchResponse = await fetch(`https://vladmihalcea.com/wp-json/wp/v2/search?search=${query}`)
const json = await searchResponse.json()
// ...
}
}
Prompting user to choose article
We are not going to open the first available search result, instead, we want to give the user option to choose one from the list.
There is no utility for that built into oclif
but there is a fantastic JavaScript library that does it (and much more): Inquirer.js
$ yarn add inquirer
const fetch = require('node-fetch')
const inquirer = require('inquirer')
class VladCliCommand extends Command {
static strict = false
async run() {
const {flags, argv} = this.parse(VladCliCommand)
const query = argv.join(' ')
const searchResponse = await fetch(`https://vladmihalcea.com/wp-json/wp/v2/search?search=${query}`)
const json = await searchResponse.json()
let response = await inquirer.prompt([{
name: 'stage',
message: '🤔 Choose article',
type: 'rawlist',
choices: json.map(function (entry) {
return {name: entry.title}
})
}])
// ...
}
}
This will give us a list of options that can be chosen with arrows or putting the number:
./bin/run how to map json
? 🤔 Choose article
1) How to map JSON objects using generic Hibernate Types
2) How to map SQL Server JSON columns using JPA and Hibernate
3) How to map Oracle JSON columns using JPA and Hibernate
4) How to map a String JPA property to a JSON column using Hibernate
5) How to map a JSON collection using JPA and Hibernate
6) How to map a PostgreSQL ARRAY to a Java List with JPA and Hibernate
(Move up and down to reveal more choices)
Answer:
The choice is saved in response.stage
property so to get the url of a chosen article we need to filter previously returned json
by comparing titles. Not the greatest solution since it would fail if two articles have the same title, but since it's a just-for-fun project and it's unlikely it would ever happen we can just follow this path:
const result = json.filter(entry => entry.title === responses.stage)
Opening article in a browser
As I mentioned - Node ecosystem is crazy wide. There is a library for everything - including cross platform open
command: opn.
$ yarn add opn
And just call opn
function on the url
. That's how we end up with final version of the script:
const fetch = require('node-fetch')
const inquirer = require('inquirer')
const opn = require('opn')
class VladCliCommand extends Command {
static strict = false
async run() {
const {flags, argv} = this.parse(VladCliCommand)
const query = argv.join(' ')
const searchResponse = await fetch(`https://vladmihalcea.com/wp-json/wp/v2/search?search=${query}`)
const json = await searchResponse.json()
let response = await inquirer.prompt([{
name: 'stage',
message: '🤔 Choose article',
type: 'rawlist',
choices: json.map(function (entry) {
return {name: entry.title}
})
}])
const result = json.filter(entry => entry.title === response.stage)
opn(result[0].url)
}
}
That's more or less it. I skipped some parts like handling connection errors, empty results etc. but I think you got the point.
Publishing to NPM
In comparison to Maven official repository, it's very easy to publish a NPM package. You just need to sign up at https://www.npmjs.com/ and run npm publish
. If you prefer other distribution than NPM, oclif is your friend. You can build installers for Mac, Windows, Ubuntu/Debian packages and more.
Summary
The vlad-cli
package is available now to install:
$ npm install -g vlad-cli
It was definitely fun experience, oclif is a fantastic tool and if I only come up with another idea for CLI tool I am definitely going to use it too. The only other tool I can think of I would be keen to use is Quarkus with it's new command mode, but I am not sure how to actually realease quarkus-built-package without putting too much effort into it 😉
Full source code is available at https://github.com/maciejwalkowiak/vlad-cli.
The last but not least - highlight of this whole experiment 🎼 "I can't wait to Hibernate" 🎼 song: